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

feat: optimize stylist retrieval with StarRocks SQL classification and UI grouping

parent c1f361ca
"""
Fashion Matches — API Route
Manages AI-powered fashion matching (ai_matches) per product.
Endpoints:
GET /api/fashion-matches/{code} → lấy ai_matches
POST /api/fashion-matches/{code}/update → lưu manual edits
POST /api/fashion-matches/{code}/regen → trigger AI engine 1 SP
POST /api/fashion-matches/batch → trigger engine toàn bộ
GET /api/fashion-matches/batch/status → poll batch progress
GET /api/fashion-matches/rules/config → lấy fashion_rules.json
PUT /api/fashion-matches/rules/config → cập nhật rules
GET /api/fashion-matches/rules/meta → structured metadata (colors, styles, occasions)
POST /api/fashion-matches/color-logic → kiểm tra synergy 2 màu
POST /api/fashion-matches/outfit-suggest → gợi ý outfit đầy đủ từ 1 product code
POST /api/fashion-matches/score-test → test pairwise score breakdown
"""
import json
import logging
import os
from typing import Optional
from fastapi import APIRouter, BackgroundTasks
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from common.pool_wrapper import get_pooled_connection_compat
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/fashion-matches", tags=["Fashion Matches"])
TABLE = "dashboard_canifa.ultra_descriptions"
RULES_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "worker", "fashion_rules.json")
# ─── Pydantic ────────────────────────────────────────────────
class UpdateMatchesRequest(BaseModel):
ai_matches: dict
class UpdateRulesRequest(BaseModel):
rules: dict
class OutfitSuggestRequest(BaseModel):
code: str
occasion: Optional[str] = None
class ColorLogicRequest(BaseModel):
src_color: str
tgt_color: str
# ─── Helpers ─────────────────────────────────────────────────
def _get_ai_matches(code: str) -> Optional[dict]:
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"SELECT ai_matches FROM {TABLE} WHERE magento_ref_code = %s OR internal_ref_code = %s LIMIT 1",
(code, code),
)
row = cur.fetchone()
cur.close()
if not row:
return None
data = row[0]
if isinstance(data, str):
data = json.loads(data)
return data or {}
except Exception as e:
logger.error("[FashionMatches] get error %s: %s", code, e)
return None
finally:
if conn:
conn.close()
def _save_ai_matches(code: str, ai_matches: dict) -> bool:
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"""UPDATE {TABLE}
SET ai_matches = %s::jsonb, updated_at = NOW()
WHERE magento_ref_code = %s OR internal_ref_code = %s""",
(json.dumps(ai_matches, ensure_ascii=False), code, code),
)
updated = cur.rowcount > 0
conn.commit()
cur.close()
return updated
except Exception as e:
if conn:
conn.rollback()
logger.error("[FashionMatches] save error %s: %s", code, e)
return False
finally:
if conn:
conn.close()
def _run_engine_background(code: Optional[str] = None):
try:
from worker.stylist_engine import StylistEngine
engine = StylistEngine()
if code:
engine.run_for_code(code)
else:
engine.run_batch()
except Exception as e:
logger.error("[FashionMatches] engine error: %s", e)
def _load_rules() -> dict:
with open(RULES_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def _detect_color_key(color_str: str, color_keys: dict) -> Optional[str]:
c = color_str.lower()
for key, variants in color_keys.items():
if any(v in c for v in variants):
return key
return None
def _color_group_explain(src_key: Optional[str], tgt_key: Optional[str], rules: dict) -> dict:
color_groups = rules.get("color_groups", {})
group_matrix = rules.get("color_group_matrix", {})
color_matrix = rules.get("color_matrix", {})
default_score = rules.get("default_color_score", 12)
src_group = color_groups.get(src_key) if src_key else None
tgt_group = color_groups.get(tgt_key) if tgt_key else None
if src_group and tgt_group:
score = group_matrix.get(src_group, {}).get(tgt_group, default_score)
advice = _color_advice(src_group, tgt_group, src_key, tgt_key)
return {
"src_key": src_key, "src_group": src_group,
"tgt_key": tgt_key, "tgt_group": tgt_group,
"score": score, "max": 30,
"method": "group_matrix",
"advice": advice,
}
if src_key and tgt_key:
score = color_matrix.get(src_key, {}).get(tgt_key, default_score)
return {
"src_key": src_key, "src_group": src_group or "?",
"tgt_key": tgt_key, "tgt_group": tgt_group or "?",
"score": score, "max": 30,
"method": "color_matrix",
"advice": f"Phối {src_key} + {tgt_key} (legacy matrix)",
}
return {
"src_key": src_key or "?", "src_group": src_group or "?",
"tgt_key": tgt_key or "?", "tgt_group": tgt_group or "?",
"score": default_score, "max": 30,
"method": "default",
"advice": "Không xác định được màu — dùng điểm mặc định",
}
def _color_advice(src_group: str, tgt_group: str, src_key: Optional[str], tgt_key: Optional[str]) -> str:
combo = (src_group, tgt_group)
if combo == ("neutral", "neutral"):
return f"✅ Công thức An Toàn: {src_key} + {tgt_key} — luôn hài hòa, phù hợp mọi dịp."
if combo in [("neutral", "light"), ("light", "neutral")]:
return f"✅ Công thức Điểm Nhấn: màu sáng ({src_key or tgt_key}) + trung tính ({tgt_key or src_key}) — nổi bật, tươi trẻ."
if combo in [("neutral", "dark"), ("dark", "neutral")]:
return f"✅ Công thức Điểm Nhấn: màu nổi ({src_key or tgt_key}) + trung tính ({tgt_key or src_key}) — cá tính, ấn tượng."
if combo in [("light", "dark"), ("dark", "light")]:
return f"⚠️ Tương phản mạnh: {src_key} + {tgt_key} — táo bạo, cần phụ kiện trung tính để cân bằng."
if combo == ("light", "light"):
return f"⚠️ Cùng tông sáng: {src_key} + {tgt_key} — pastel dịu dàng nhưng dễ bị nhạt, cần phụ kiện tương phản."
if combo == ("dark", "dark"):
return f"⚠️ Cùng tông đậm: {src_key} + {tgt_key} — mạnh mẽ, cần chú ý không bị nặng nề. Thêm phụ kiện trung tính."
return f"Phối {src_key} + {tgt_key}"
def _build_color_strategy(src_key: Optional[str], src_group: Optional[str], rules: dict) -> dict:
group_matrix = rules.get("color_group_matrix", {})
color_groups_map = rules.get("color_groups", {})
if not src_group:
return {"summary": "Không xác định được màu sắc sản phẩm.", "strategies": []}
if src_group == "neutral":
summary = f"{src_key} là màu Trung Tính — dễ phối nhất, áp dụng được cả 3 công thức."
strategies = [
{
"name": '🟢 Công thức "An Toàn"',
"desc": "Phối với màu Neutral khác (Trắng, Đen, Xám, Be, Nâu) — luôn hài hòa.",
"score": group_matrix.get("neutral", {}).get("neutral", 30),
"example_colors": _get_colors_by_group("neutral", color_groups_map, exclude=src_key),
},
{
"name": '✨ Công thức "Điểm Nhấn" (sáng)',
"desc": "Phối với màu Light (Hồng, Vàng, Xanh lam, Tím) — tươi trẻ, nổi bật.",
"score": group_matrix.get("neutral", {}).get("light", 22),
"example_colors": _get_colors_by_group("light", color_groups_map),
},
{
"name": '🔥 Công thức "Cá Tính" (đậm)',
"desc": "Phối với màu Dark (Đỏ, Cam, Xanh Jeans, Xanh navy) — mạnh mẽ, ấn tượng.",
"score": group_matrix.get("neutral", {}).get("dark", 25),
"example_colors": _get_colors_by_group("dark", color_groups_map),
},
]
elif src_group == "light":
summary = f"{src_key} là màu Sáng/Pastel — phối BOTTOM Neutral để tiết chế, tránh phụ kiện quá sặc sỡ."
strategies = [
{
"name": "✅ Phối với Neutral",
"desc": f"Màu {src_key} (sáng/pastel) → phối BOTTOM Neutral (Trắng, Đen, Xám, Be) để cân bằng.",
"score": group_matrix.get("light", {}).get("neutral", 22),
"example_colors": _get_colors_by_group("neutral", color_groups_map),
},
{
"name": "⚠️ Phụ kiện nên Neutral",
"desc": "Chọn phụ kiện đen/trắng để không rối mắt. Tuân thủ quy tắc tối đa 3 màu.",
"score": None,
"example_colors": ["Đen", "Trắng", "Xám"],
},
]
else:
summary = f"{src_key} là màu Đậm/Nổi — ưu tiên BOTTOM Neutral để cân bằng. Phụ kiện cũng nên Neutral."
strategies = [
{
"name": "✅ Phối với Neutral",
"desc": f"Màu {src_key} (đậm/nổi) → phối BOTTOM Neutral (Trắng, Đen, Xám, Be) để hài hòa.",
"score": group_matrix.get("dark", {}).get("neutral", 20),
"example_colors": _get_colors_by_group("neutral", color_groups_map),
},
{
"name": "🎯 Mono-color (cùng tông)",
"desc": f"Mặc head-to-toe {src_key} — mạnh mẽ, cần phụ kiện kim loại Gold/Silver làm điểm nhấn.",
"score": group_matrix.get("dark", {}).get("dark", 10),
"example_colors": _get_colors_by_group("dark", color_groups_map, exclude=src_key),
},
]
return {"summary": summary, "strategies": strategies}
def _get_colors_by_group(group: str, color_groups_map: dict, exclude: Optional[str] = None) -> list:
return [k for k, g in color_groups_map.items() if g == group and k != exclude]
# ─── Endpoints ───────────────────────────────────────────────
@router.get("/{code}")
async def get_fashion_matches(code: str):
from worker.stylist_engine import StylistEngine
engine = StylistEngine()
data = engine.compute_dynamic_rule_matches(code)
classifications = engine.compute_super_classifications_sql(code)
# Return empty ai_matches if not found (or gracefully handle) to avoid UI breaking
if data is None:
data = {}
return {"ok": True, "code": code, "ai_matches": data, "classifications": classifications}
@router.post("/{code}/update")
async def update_fashion_matches(code: str, req: UpdateMatchesRequest):
ok = _save_ai_matches(code, req.ai_matches)
if ok:
logger.info("[FashionMatches] Manual update saved: %s", code)
return {"ok": True, "message": "Đã lưu gợi ý phối đồ"}
return JSONResponse({"ok": False, "error": "Không tìm thấy sản phẩm"}, status_code=404)
@router.post("/{code}/regen")
async def regen_fashion_matches(code: str, background_tasks: BackgroundTasks):
background_tasks.add_task(_run_engine_background, code)
logger.info("[FashionMatches] Regen triggered: %s", code)
return {"ok": True, "message": f"Đang tính toán phối đồ cho {code}..."}
@router.post("/batch")
async def batch_fashion_matches(background_tasks: BackgroundTasks):
from worker.stylist_engine import BATCH_STATE
if BATCH_STATE.get("is_running"):
return JSONResponse({"ok": False, "error": "Batch đang chạy, vui lòng chờ"}, status_code=409)
background_tasks.add_task(_run_engine_background, None)
return {"ok": True, "message": "Đã khởi động batch Stylist Engine..."}
@router.get("/batch/status")
async def batch_status():
from worker.stylist_engine import BATCH_STATE
state = dict(BATCH_STATE)
pct = 0
if state.get("total", 0) > 0:
pct = round(state["done"] / state["total"] * 100, 1)
state["progress_pct"] = pct
return {"ok": True, "state": state}
@router.get("/rules/config")
async def get_rules():
try:
rules = _load_rules()
return {"ok": True, "rules": rules}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@router.put("/rules/config")
async def update_rules(req: UpdateRulesRequest):
try:
with open(RULES_PATH, "w", encoding="utf-8") as f:
json.dump(req.rules, f, ensure_ascii=False, indent=2)
return {"ok": True, "message": "Rules đã được cập nhật"}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@router.get("/rules/meta")
async def get_rules_meta():
try:
rules = _load_rules()
color_hex_map = {
"Trắng": "#F5F5F5", "Đen": "#1A1A1A", "Be": "#D4B896", "Xám": "#9E9E9E",
"Nâu": "#795548", "Vàng": "#FDD835", "Hồng": "#F48FB1", "Xanh lam": "#64B5F6",
"Tím": "#BA68C8", "Đỏ": "#EF5350", "Cam": "#FFA726", "Xanh lá": "#66BB6A",
"Xanh navy": "#1A237E", "Xanh Jeans": "#5C6BC0", "Xanh than": "#00897B",
}
color_meta = []
for key, variants in rules.get("color_keys", {}).items():
group = rules.get("color_groups", {}).get(key, "unknown")
color_meta.append({
"key": key, "group": group,
"hex": color_hex_map.get(key, "#CCCCCC"),
"keywords": variants,
})
return {
"ok": True,
"meta": {
"colors": color_meta,
"color_group_matrix": rules.get("color_group_matrix", {}),
"styles": list(rules.get("style_compat", {}).keys()),
"occasions": [
{"key": k, "label": v}
for k, v in rules.get("occasion_labels", {}).items()
],
"roles": [
{"key": k, "label": v}
for k, v in rules.get("role_labels", {}).items()
],
"score_weights": rules.get("score_weights", {}),
"version": rules.get("version", "?"),
},
}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@router.post("/color-logic")
async def color_logic(req: ColorLogicRequest):
try:
rules = _load_rules()
color_keys = rules.get("color_keys", {})
src_key = _detect_color_key(req.src_color, color_keys)
tgt_key = _detect_color_key(req.tgt_color, color_keys)
result = _color_group_explain(src_key, tgt_key, rules)
return {"ok": True, "result": result}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@router.post("/outfit-suggest")
async def outfit_suggest(req: OutfitSuggestRequest):
try:
rules = _load_rules()
color_keys = rules.get("color_keys", {})
from worker.stylist_engine import StylistEngine
engine = StylistEngine()
catalog = engine._load_catalog()
# Find source product in catalog
src_product = next((p for p in catalog if p.get("code") == req.code or p.get("internal_ref_code") == req.code), None)
if not src_product:
return JSONResponse(
{"ok": False, "error": f"Không tìm thấy sản phẩm: {req.code}"},
status_code=404,
)
logger.info("[OutfitSuggest] Computing real-time dynamic matches for %s", req.code)
ai_matches = engine.compute_dynamic_rule_matches(req.code)
src_color = src_product.get("color", "")
src_key = _detect_color_key(src_color, color_keys)
src_group = rules.get("color_groups", {}).get(src_key) if src_key else None
desc_data = src_product.get("description_data", {})
src_name = src_product.get("name", "")
src_line = src_product.get("product_line", "")
src_gender = src_product.get("gender", "")
src_img = src_product.get("image", "")
src_color = src_color or ""
src_key = _detect_color_key(src_color, color_keys)
src_group = rules.get("color_groups", {}).get(src_key) if src_key else None
occasions = rules.get("occasions", [])
active_occasions = [req.occasion] if (req.occasion and req.occasion in occasions) else occasions
outfit_by_occasion = {}
for occ in active_occasions:
occ_data = ai_matches.get(occ, {})
if not occ_data:
continue
slots = {}
for role, items in occ_data.items():
enriched = []
for item in items:
item_color_key = _detect_color_key(item.get("color", ""), color_keys)
color_info = _color_group_explain(src_key, item_color_key, rules)
enriched.append({
**item,
"color_key": item_color_key,
"color_group": rules.get("color_groups", {}).get(item_color_key, "?") if item_color_key else "?",
"color_synergy": color_info,
})
slots[role] = enriched
if slots:
outfit_by_occasion[occ] = {
"label": rules.get("occasion_labels", {}).get(occ, occ),
"slots": slots,
}
color_strategy = _build_color_strategy(src_key, src_group, rules)
# Inject our brand new SQL-based super classification matches
classifications = engine.compute_super_classifications_sql(req.code)
return {
"ok": True,
"source_product": {
"code": req.code,
"name": src_name or "",
"color": src_color,
"color_key": src_key,
"color_group": src_group,
"product_line": src_line or "",
"gender": src_gender or "",
"image": src_img or "",
"style": desc_data.get("phong_cach", ""),
"occasion": desc_data.get("dip_mac", ""),
"material": desc_data.get("vat_lieu", ""),
},
"color_strategy": color_strategy,
"outfit_by_occasion": outfit_by_occasion,
"classifications": classifications
}
except Exception as e:
logger.error("[OutfitSuggest] error: %s", e, exc_info=True)
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
class ScoreTestRequest(BaseModel):
source_code: str
target_code: str
@router.post("/score-test")
async def score_test(req: ScoreTestRequest):
try:
from worker.stylist_engine import StylistEngine
engine = StylistEngine()
catalog = engine._load_catalog()
source = next((p for p in catalog if p.get("code") == req.source_code), None)
target = next((p for p in catalog if p.get("code") == req.target_code), None)
if not source:
return JSONResponse({"ok": False, "error": f"Không tìm thấy SP nguồn: {req.source_code}"}, status_code=404)
if not target:
return JSONResponse({"ok": False, "error": f"Không tìm thấy SP đích: {req.target_code}"}, status_code=404)
breakdown = engine._score_breakdown(source, target)
total = sum(v["score"] for v in breakdown.values())
occ0 = (engine.rules.get("occasions") or ["hang_ngay"])[0]
reason = engine._build_reason(source, target, occ0, total)
return {
"ok": True,
"result": {
"source_code": req.source_code,
"target_code": req.target_code,
"target_name": target.get("name", ""),
"roles_str": target.get("product_line", ""),
"total_score": total,
"reason": reason,
"breakdown": breakdown,
},
}
except Exception as e:
logger.error("[ScoreTest] error: %s", e)
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
......@@ -124,7 +124,8 @@ async function loadProduct(code) {
const desc = metaJson.description || {};
const dd = desc.description_data || desc.description || {};
aiMatches = fmJson.ok ? (fmJson.ai_matches || {}) : {};
window.aiMatches = fmJson.ok ? (fmJson.ai_matches || {}) : {};
window.aiClassifications = fmJson.ok ? (fmJson.classifications || {}) : {};
// Populate header
document.getElementById('prodImage').src = desc.product_image_url || '';
......@@ -154,40 +155,104 @@ function switchDetailTab(tab, btn) {
document.getElementById('tabTest').style.display = tab === 'test' ? 'flex' : 'none';
}
// ══ MATCHES TAB ═══════════════════════════════
// ══ MATCHES TAB & SQL GROUPING ═══════════════════════════════
let currentGroupBy = 'occ';
let activeGroupTab = null;
const COLOR_TABS = {
neutral: "<i data-lucide='circle' class='icon-sm'></i> Màu Neutral",
light: "<i data-lucide='sun-dim' class='icon-sm'></i> Màu Light",
dark: "<i data-lucide='moon' class='icon-sm'></i> Màu Dark"
};
const MATERIAL_TABS = {
summer: "<i data-lucide='sun' class='icon-sm'></i> Mùa hè",
winter: "<i data-lucide='snowflake' class='icon-sm'></i> Mùa đông"
};
function getVirtualMatches() {
if (currentGroupBy === 'occ') {
return window.aiMatches || {};
} else if (currentGroupBy === 'color' || currentGroupBy === 'material') {
return window.aiClassifications?.[currentGroupBy] || {};
}
return {};
}
function setGroupBy(type) {
currentGroupBy = type;
document.querySelectorAll('#groupTabs .btn').forEach(btn => {
btn.classList.remove('active');
btn.style.color = "var(--muted-fg)";
});
const activeBtn = document.getElementById(
type === 'occ' ? 'btnGroupOcc' : (type === 'color' ? 'btnGroupColor' : 'btnGroupMaterial')
);
if(activeBtn) {
activeBtn.classList.add('active');
activeBtn.style.color = "var(--primary)";
}
if (type === 'occ') activeGroupTab = Object.keys(OCC_LABELS)[0];
else if (type === 'color') activeGroupTab = 'neutral';
else if (type === 'material') activeGroupTab = 'summer';
renderMatchesTab();
}
function renderMatchesTab() {
renderOccTabs();
if (!activeOcc || !aiMatches[activeOcc]) {
activeOcc = Object.keys(aiMatches)[0] || Object.keys(OCC_LABELS)[0];
renderGroupTabs();
const virtual = getVirtualMatches();
if (!activeGroupTab || !virtual[activeGroupTab]) {
const keys = Object.keys(virtual);
activeGroupTab = keys.length ? keys[0] : activeGroupTab;
}
renderMatchContent();
}
function renderOccTabs() {
function renderGroupTabs() {
const container = document.getElementById('occTabs');
container.innerHTML = Object.entries(OCC_LABELS).map(([occ, label]) => {
const count = Object.values(aiMatches[occ] || {}).reduce((s, a) => s + a.length, 0);
const active = occ === activeOcc ? 'active' : '';
return `<button class="occ-tab ${active}" onclick="switchOcc('${occ}')">
let LABELS = OCC_LABELS;
if (currentGroupBy === 'color') LABELS = COLOR_TABS;
else if (currentGroupBy === 'material') LABELS = MATERIAL_TABS;
const virtual = getVirtualMatches();
container.innerHTML = Object.entries(LABELS).map(([key, label]) => {
let count = 0;
const grpF = virtual[key];
if (Array.isArray(grpF)) {
count = grpF.length;
} else if (grpF && typeof grpF === 'object') {
count = Object.values(grpF).reduce((s, a) => s + (Array.isArray(a) ? a.length : 0), 0);
}
const active = key === activeGroupTab ? 'active' : '';
return `<button class="occ-tab ${active}" onclick="switchGroupTab('${key}')">
${label} <span class="occ-count">${count}</span>
</button>`;
}).join('');
if (window.lucide) lucide.createIcons();
}
function switchOcc(occ) {
activeOcc = occ;
renderOccTabs();
renderMatchContent();
function switchGroupTab(key) {
activeGroupTab = key;
renderMatchesTab();
}
function renderMatchContent() {
const container = document.getElementById('matchContent');
const empty = document.getElementById('emptyState');
const occData = aiMatches[activeOcc] || {};
const hasAny = Object.values(occData).some(arr => arr.length > 0);
const virtual = getVirtualMatches();
const occData = virtual[activeGroupTab] || {};
let hasAny = false;
if (Array.isArray(occData)) {
hasAny = occData.length > 0;
} else {
hasAny = Object.values(occData).some(arr => (Array.isArray(arr) && arr.length > 0));
}
if (!hasAny && !Object.keys(aiMatches).length) {
if (!hasAny && !Object.keys(virtual).length) {
container.style.display = 'none';
empty.style.display = 'flex';
return;
......@@ -195,23 +260,37 @@ function renderMatchContent() {
container.style.display = 'flex';
empty.style.display = 'none';
if (Array.isArray(occData)) {
// Flat Array Render for SQL Super Classifications
const items = occData;
const cards = items.map((item, idx) => renderMatchCard(item, activeGroupTab, 'bottom', idx)).join('');
container.innerHTML = `<div class="role-section">
<div class="role-header">
<div class="role-title"><i data-lucide="database" class="icon-sm"></i> Raw SQL SQL Match Results <span class="badge badge-info">${items.length}</span></div>
</div>
<div class="role-body">${cards}</div>
</div>`;
} else {
// Nested Roles Render
const roles = ['bottom', 'outerwear', 'accessory'];
container.innerHTML = roles.map(role => {
const items = occData[role] || [];
const roleInfo = ROLE_LABELS[role] || { label: role, emoji: '📦' };
const cards = items.map((item, idx) => renderMatchCard(item, activeOcc, role, idx)).join('');
const addBtn = `<div class="add-card" onclick="openAddModal('${activeOcc}','${role}')">
const cards = items.map((item, idx) => renderMatchCard(item, activeGroupTab, role, idx)).join('');
const addBtn = `<div class="add-card" onclick="openAddModal('${activeGroupTab}','${role}')">
<div class="add-card-icon">➕</div>
<div class="add-card-label">Thêm SP</div>
</div>`;
return `<div class="role-section">
<div class="role-header">
<div class="role-title">${roleInfo.emoji} ${roleInfo.label} <span class="badge badge-info">${items.length}</span></div>
<button class="btn btn-ghost btn-sm" onclick="openAddModal('${activeOcc}','${role}')">+ Thêm</button>
<button class="btn btn-ghost btn-sm" onclick="openAddModal('${activeGroupTab}','${role}')">+ Thêm</button>
</div>
<div class="role-body">${cards}${addBtn}</div>
</div>`;
}).join('');
}
if (window.lucide) lucide.createIcons();
}
function renderMatchCard(item, occ, role, idx) {
......@@ -235,10 +314,11 @@ function renderMatchCard(item, occ, role, idx) {
}
function removeItem(occ, role, idx) {
if (!aiMatches[occ]?.[role]) return;
aiMatches[occ][role].splice(idx, 1);
if (!aiMatches[occ][role].length) delete aiMatches[occ][role];
if (!Object.keys(aiMatches[occ] || {}).length) delete aiMatches[occ];
const virtual = getVirtualMatches();
if (!virtual[occ]?.[role]) return;
virtual[occ][role].splice(idx, 1);
if (!virtual[occ][role].length) delete virtual[occ][role];
if (!Object.keys(virtual[occ] || {}).length) delete virtual[occ];
renderMatchesTab();
}
......@@ -430,7 +510,7 @@ function addItemFromModal(code, name, image) {
}
aiMatches[occ][role].push({ code, name, image, score: 70, reason: 'Thêm thủ công' });
document.getElementById('addModal').style.display = 'none';
addingCtx = null; activeOcc = occ;
addingCtx = null; activeGroupTab = occ;
renderMatchesTab();
showToast(`✅ Đã thêm ${name}`, 'success');
}
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>Tài liệu AI Stylist Framework</title>
<link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css">
<script src="/static/common/frame-detect.js"></script>
<style>
body { background: var(--background); margin: 0; font-family: var(--font-sans); color: var(--foreground); }
.docs-container { max-width: 800px; margin: 0 auto; padding: 40px 20px; }
.docs-header { text-align: center; margin-bottom: 40px; }
.docs-header h1 { font-size: 28px; font-weight: 800; color: var(--text-main); margin-bottom: 12px; }
.docs-header p { color: var(--muted-fg); font-size: 15px; }
.card-section {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
}
.card-section h2 { margin-top: 0; font-size: 20px; color: var(--primary); display: flex; align-items: center; gap: 8px; margin-bottom: 20px; }
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 16px; }
.compare-box { padding: 20px; border-radius: 12px; border: 1px solid var(--border); }
.compare-box.bad { background: rgba(var(--error-rgb), 0.05); border-color: rgba(var(--error-rgb), 0.2); }
.compare-box.good { background: rgba(var(--primary-rgb), 0.05); border-color: rgba(var(--primary-rgb), 0.2); }
.compare-box h3 { font-size: 15px; margin: 0 0 12px 0; }
.dimension-item {
display: flex; gap: 16px; margin-bottom: 20px; padding-bottom: 20px;
border-bottom: 1px dashed var(--border);
}
.dimension-item:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.dim-icon { font-size: 32px; flex-shrink: 0; }
.dim-content h3 { margin: 0 0 6px 0; font-size: 16px; display: flex; align-items: center; gap: 8px; }
.dim-content p { margin: 0; font-size: 14px; line-height: 1.6; color: var(--muted-fg); }
.badge-weight { background: var(--primary); color: var(--primary-fg); padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.tabs { display: flex; gap: 10px; margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
.tab-btn { padding: 8px 16px; background: transparent; border: 1px solid transparent; border-radius: 8px; color: var(--muted-fg); font-weight: 600; cursor: pointer; transition: 0.2s; }
.tab-btn:hover { color: var(--foreground); background: var(--muted); }
.tab-btn.active { color: var(--primary); background: rgba(var(--primary-rgb), 0.1); border-color: rgba(var(--primary-rgb), 0.2); }
.tab-content { display: none; }
.tab-content.active { display: block; animation: fadeIn 0.3s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
.mermaid-box { background: #ffffff; padding: 20px; border-radius: 12px; border: 1px solid var(--border); box-shadow: inset 0 2px 10px rgba(0,0,0,0.02); overflow-x: auto; margin-bottom: 20px;}
</style>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
.topbar h1 { display: flex; align-items: center; gap: 8px; }
.page-title-icon { width: 22px; height: 22px; color: var(--gold, #B45309); flex-shrink: 0; }
</style>
</head>
<body>
<div class="docs-container">
<div class="docs-header">
<h1><svg width="24" height="24" viewBox="0 0 24 24" fill="none" class="page-title-icon" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg> AI Stylist Framework & Logic</h1>
<p>Tài liệu giải thích thuật toán chấm điểm phối đồ tự động (Weighted Point System) của Canifa AI, dựa trên Business Logic thời trang.</p>
</div>
<!-- TABS NAVIGATION -->
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('overview', this)">1. Tổng quan Hệ điểm</button>
<button class="tab-btn" onclick="switchTab('flowchart', this)">2. Node Luồng Thuật toán</button>
<button class="tab-btn" onclick="switchTab('color-matrix', this)">3. Node Ma trận Hòa sắc</button>
<button class="tab-btn" onclick="switchTab('business-logic', this)">4. Bảng Tiêu Chuẩn (Business Logic)</button>
</div>
<!-- TAB 4: BẢNG TIÊU CHUẨN (EXCEL MAPPING) -->
<div id="business-logic" class="tab-content">
<!-- DEFINITIONS -->
<div class="card-section" style="border-left: 4px solid var(--primary);">
<h2>📚 1. Định nghĩa Thuật ngữ (Terminology)</h2>
<div class="dimension-item" style="display:grid; grid-template-columns:repeat(4, 1fr); gap:16px; border:none; padding:0; margin-top:20px;">
<div style="background:var(--bg); padding:16px; border-radius:8px; border:1px solid var(--border);">
<div style="font-weight:700; color:var(--primary); margin-bottom:8px;">👚 TOP</div>
<div style="font-size:13px; color:var(--muted-fg)">Sản phẩm mặc trên: Áo thun, áo khoác, áo len, sơ mi, polo...</div>
</div>
<div style="background:var(--bg); padding:16px; border-radius:8px; border:1px solid var(--border);">
<div style="font-weight:700; color:var(--primary); margin-bottom:8px;">👖 BOTTOM</div>
<div style="font-size:13px; color:var(--muted-fg)">Sản phẩm mặc dưới: Quần dài, quần short, chân váy...</div>
</div>
<div style="background:var(--bg); padding:16px; border-radius:8px; border:1px solid var(--border);">
<div style="font-weight:700; color:var(--primary); margin-bottom:8px;">🎒 ACCESSORIES</div>
<div style="font-size:13px; color:var(--muted-fg)">Phụ kiện đi kèm: Mũ, khăn, bóng, tất, túi xách, thắt lưng...</div>
</div>
<div style="background:var(--bg); padding:16px; border-radius:8px; border:1px solid var(--border);">
<div style="font-weight:700; color:var(--primary); margin-bottom:8px;">🎁 SET</div>
<div style="font-size:13px; color:var(--muted-fg)">Đồ đi theo bộ đóng gói sẵn (Đã bao gồm cả TOP + BOTTOM).</div>
</div>
</div>
</div>
<!-- FORMULAS -->
<div class="card-section" style="border-left: 4px solid var(--gold, #B45309);">
<h2>⚖️ 2. Công thức Phối màu Cơ bản (Synergy Formula)</h2>
<div class="compare-grid" style="grid-template-columns: 1fr 1fr;">
<div class="compare-box good" style="border-left:3px solid var(--success)">
<h3 style="color:var(--success);">🛡️ Công thức "An Toàn" (Neutral + Neutral)</h3>
<ul style="font-size: 13px; color: var(--muted-fg); padding-left: 16px; line-height:1.6; margin:0;">
<li><strong>TOP:</strong> Các màu sáng trung tính như Trắng hoặc Be (Beige).</li>
<li><strong>BOTTOM:</strong> Đen, Xám hoặc Nâu.</li>
<li><strong>Phụ kiện:</strong> Nên cùng tông với BOTTOM để tạo hiệu ứng kéo dài vóc dáng.</li>
</ul>
</div>
<div class="compare-box good" style="border-left:3px solid #f59e0b">
<h3 style="color:#f59e0b;">✨ Công thức "Điểm Nhấn" (Neutral + Light/Dark)</h3>
<ul style="font-size: 13px; color: var(--muted-fg); padding-left: 16px; line-height:1.6; margin:0;">
<li><strong>TOP:</strong> Màu chói, nổi hoặc sáng (Vàng, Đỏ, Hồng, Cam).</li>
<li><strong>BOTTOM:</strong> Màu trung tính (Trắng, Đen, Be) để kìm lại độ chói.</li>
<li><strong>Phụ kiện:</strong> Trắng/Đen để tiết chế sự rườm rà.</li>
</ul>
</div>
</div>
<div style="background:rgba(var(--primary-rgb),0.05); padding:16px; border-radius:8px; margin-top:20px; font-size:13px; color:var(--muted-fg);">
<strong style="color:var(--primary)">💡 MẸO STYLIST TỪ CANIFA:</strong>
<ul style="margin:8px 0 0; padding-left:16px; line-height:1.6;">
<li><strong>Quy tắc 3 màu:</strong> Không diện quá 3 màu trên cùng 1 outfit để tránh rối mắt, kém sang trọng.</li>
<li><strong>Sắc độ tinh tế:</strong> Nếu mặc nguyên "cây" trung tính (vd: Full xám), phải dùng phụ kiện Kim loại (Gold/Silver) hoặc da bóng làm điểm nhấn.</li>
<li><strong>Du lịch:</strong> Đi biển chọn đồ rộng chất lanh/cotton mát, màu rực/trắng mộc. Đi núi/mạo hiểm chọn đồ dài, legging, chất cản gió ôm sát.</li>
</ul>
</div>
</div>
<!-- DEMOGRAPHICS & OCCASION EXAMPLES -->
<div class="card-section" style="border-left: 4px solid #8b5cf6;">
<h2>👥 3. Lưới Logic Phong cách theo Giới tính & Dịp (Framework)</h2>
<div style="overflow-x:auto;">
<table style="width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 13px; text-align: left; background: var(--bg); border-radius: 8px; border: 1px solid var(--border);">
<thead>
<tr style="background: var(--muted); border-bottom: 2px solid var(--border);">
<th style="padding: 12px; width: 15%;">Tệp khách / Demographic</th>
<th style="padding: 12px; width: 25%; border-left: 1px solid var(--border);">Hòa sắc ưu tiên (Colors)</th>
<th style="padding: 12px; width: 30%; border-left: 1px solid var(--border);">Styles & Dịp (Occasions)</th>
<th style="padding: 12px; border-left: 1px solid var(--border);">Quy tắc Chất liệu / Mùa</th>
</tr>
</thead>
<tbody>
<!-- Nữ -->
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 12px; font-weight:700;">NỮ</td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.5;">Linh hoạt nhất, dùng tốt cả Neutral, Light, Dark.<br><br>VD: <em>TOP Hồng phấn + BOTTOM Trắng + Phụ kiện Hồng</em></td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.6;"><strong>Đi làm:</strong> Modern Minimal, Feminine (Blouse + Quần rộng).<br><strong>Đi chơi:</strong> Trend, Cute (Baby Tee/Trễ vai + Váy ngắn).<br><strong>Nghỉ mát:</strong> Áo 2 dây + Chân váy Maxi + Kính râm.</td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.6;"><strong>Hè:</strong> Cotton/Linen thoát nhiệt, màu sáng.<br><strong>Đông:</strong> Layering Áo body + Dạ + Leggings.<br><strong>Giao mùa:</strong> Thun polo + Gió se lạnh.</td>
</tr>
<!-- Nam -->
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 12px; font-weight:700;">NAM</td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.5;">Ưu tiên an toàn nhóm Neutral.<br><br>VD: <em>TOP Beige + BOTTOM Xanh than + Phụ kiện Nâu</em></td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.6;"><strong>Công sở:</strong> Smart Casual, Basic (Sơ mi/Polo + Khaki).<br><strong>Đường phố:</strong> Street, Dynamic (Graphic Tee + Jeans + Mũ).<br><strong>Du lịch / Mạo hiểm:</strong> Utility (Jogger Cargo + Gió + Sneaker).</td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.6;"><strong>Hè:</strong> Polo/Phông + Khaki/Soóc + Mũ thể thao.<br><strong>Đông:</strong> Áo giữ nhiệt + Nỉ len + Áo Lông vũ.</td>
</tr>
<!-- Unisex người lớn -->
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 12px; font-weight:700;">UNISEX<br>(Adults)</td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.5;">Tập trung màu Neutral & Khối Dark mạnh mẽ.<br><br>VD: <em>TOP Đỏ + BOTTOM Đỏ + Túi Đỏ</em></td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.6;"><strong>Mọi dịp:</strong> Essential (Sơ mi Oversize + Tây dáng đứng + Bandana).</td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.6;">Tuyệt vời cho Activewear (Bộ thể thao, Bra top, năng động).</td>
</tr>
<!-- Bé Gái -->
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 12px; font-weight:700;">BÉ GÁI</td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.5;">Sinh ra cho nhóm Pastel (Light) và Màu Nổi (Tím, Hồng).<br><br>VD: <em>TOP Tím nhạt + BOTTOM Trắng + Giày Trắng</em></td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.6;"><strong>Ngày thường:</strong> Cute, Dynamic (Áo Hoạt hình + Quần Yếm).<br><strong>Kỳ nghỉ:</strong> SET lanh họa tiết nhiệt đới + Mũ che gáy.</td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.6;"><strong>Đông:</strong> Khoác Gilet chần bông + Quần nỉ + Găng tay.<br><strong>Hè mùa đi chơi:</strong> Váy liền Cotton / Đồ lanh.</td>
</tr>
<!-- Bé Trai -->
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 12px; font-weight:700;">BÉ TRAI</td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.5;">Nghiêng về nhóm Dark nam tính từ nhỏ.<br><br>VD: <em>TOP Vàng (Cam/Nổi) + BOTTOM Xanh Jeans (Dark)</em></td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.6;"><strong>Hoạt động:</strong> Basic, Dynamic (Áo phông thấm hút + Legging co giãn).<br><strong>Dã ngoại:</strong> Áo thun dài tay + Quần dài + Balo nhỏ.</td>
<td style="padding: 12px; border-left: 1px solid var(--border); color:var(--muted-fg); line-height:1.6;"><strong>Đông:</strong> Gilet + Nỉ dày + Giữ nhiệt.<br><strong>Hè:</strong> Cotton mềm mát, form thoái mái chạy nhảy.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- TAB 1: TỔNG QUAN -->
<div id="overview" class="tab-content active">
<!-- MAIN ARCHITECTURE -->
<div class="card-section" style="border-left: 4px solid var(--primary);">
<h2>🌟 Kiến trúc Tầng Lớp: 2 Lọc (Dual-Layer Engine)</h2>
<p style="font-size: 14px; line-height: 1.6; color: var(--muted-fg);">
Cỗ máy AI Stylist hoạt động dựa trên sự kết hợp hoàn hảo giữa <strong>Luật thời trang cứng (Hard Rules)</strong><strong>Chấm điểm thẩm mỹ mờ (Weighted Scoring)</strong>. Đảm bảo Gợi ý luôn Đúng kiến thức và Đúng màu sắc nghệ thuật theo thời gian thực.
</p>
<div class="dimension-item" style="margin-top: 20px; align-items: start;">
<div class="dim-icon">🛡️</div>
<div class="dim-content">
<h3 style="color: var(--primary);">Lớp 1: Khung xương Định hướng (Postgres)</h3>
<p>Khống chế hoàn toàn AI, KHÔNG BAO GIỜ cho phép nó gợi ý mặc "Áo Polo" với "Quần Đùi Đồ Ngủ" (Sai kiến thức nền tảng). Bảng <code>chatbot_fashion_rules</code> sẽ ép thuật toán tìm đúng Thể loại Khớp nối. <em>Kết quả: Vớt được 1000 món đồ đúng Loại và Đang Còn Hàng.</em></p>
</div>
</div>
<div class="dimension-item" style="border-bottom: none; align-items: start;">
<div class="dim-icon">🎨</div>
<div class="dim-content">
<h3 style="color: var(--gold, #B45309);">Lớp 2: Đúc hồn Nghệ thuật (Point System)</h3>
<p>Trong 1000 món đồ đúng loại, hệ thống đẩy qua Thanh trượt UI 100đ <code>fashion_rules.json</code> để chấm điểm thẩm mỹ (Màu sắc, Form dáng, Chất liệu). Lọc ra TOP 3 xuất sắc nhất (Ví dụ: 95đ, loại bỏ Quần màu sai lệch được 20đ).</p>
</div>
</div>
</div>
<!-- TẠI SAO LẠI DÙNG HỆ ĐIỂM -->
<div class="card-section">
<h2>⚖️ Bổ trợ: Tại sao dùng Hệ Điểm thay vì IF/ELSE tĩnh?</h2>
<p style="font-size: 14px; line-height: 1.6; color: var(--muted-fg);">
Thời trang không phải là toán học tuyệt đối (1+1=2). Lập trình theo luật cứng gán ép màu sắc (Ví dụ: Áo Vàng PHẢI ĐI VỚI Quần Đen) sẽ khiến AI trở nên nghèo nàn. Chúng ta sử dụng <strong>Hệ Điểm (Point System)</strong> để mô phỏng "sự đánh đổi" ở Lớp 2:
</p>
<div class="compare-grid">
<div class="compare-box bad">
<h3 style="color:var(--error);">❌ Luật cứng (IF/ELSE)</h3>
<ul style="font-size: 13px; color: var(--muted-fg); padding-left: 16px; margin:0;" class="fm-desc-content">
<li style="margin-bottom: 8px;">Robot, lặp đi lặp lại 1 kết quả.</li>
<li style="margin-bottom: 8px;">Nếu một món đồ có form cực đẹp, nhưng màu bị lệch 1 chút so với luật cứng ➜ Sẽ bị loại thẳng tay.</li>
<li><strong>Chết cứng:</strong> AI không tự học được data mới, phải chờ Dev viết code lại từng IF/ELSE.</li>
</ul>
</div>
<div class="compare-box good">
<h3 style="color:var(--primary);">✨ Hệ điểm (Weighted Scoring)</h3>
<ul style="font-size: 13px; color: var(--muted-fg); padding-left: 16px; margin:0;" class="fm-desc-content">
<li style="margin-bottom: 8px;">Linh hoạt, "Đánh đổi" bù trừ.</li>
<li style="margin-bottom: 8px;">Món đồ màu trung bình (15đ), form xuất sắc (22đ), hợp mùa hè (10đ) ➜ Tổng 47đ ➜ Vẫn lọt Top vì xuất sắc tổng thể.</li>
<li><strong>AI Tự Học:</strong> Agent tương lai có thể tự tăng/giảm trọng số (Ví dụ: Thấy khách thích mùa hè mát mẻ, tự ép trọng số chất liệu lên 20%).</li>
</ul>
</div>
</div>
</div>
<!-- 6 LĂNG KÍNH CHẤM ĐIỂM -->
<div class="card-section">
<h2>🔍 6 Lăng kính (Dimensions) Chấm điểm</h2>
<p style="font-size: 14px; line-height: 1.6; color: var(--muted-fg); margin-bottom: 24px;">
Hệ thống càn quét hàng ngàn SKUs, so từng sản phẩm đích với sản phẩm gốc qua 6 lăng kính, có tổng 100 điểm. Trọng số lớn nhất tập trung vào phần Giao diện thị giác (Màu sắc) dựa trên Framework Fashion.
</p>
<div class="dimension-item">
<div class="dim-icon">🎨</div>
<div class="dim-content">
<h3>Hòa sắc (Color Synergy) <span class="badge-weight">28 ĐIỂM</span></h3>
<p>Thuật toán chi phối lớn nhất. Chia tất cả màu hệ thống thành rổ: Neutral, Light, Dark.</p>
</div>
</div>
<div class="dimension-item">
<div class="dim-icon">👗</div>
<div class="dim-content">
<h3>Đồng điệu Phong cách (Style) <span class="badge-weight">22 ĐIỂM</span></h3>
<p>Mặc áo đẹp nhưng "sai Vibe" thì vẫn thảm họa. Áo mang tag `Dynamic` phối quần `Smart Casual` được điểm cao, nếu ép chung với quần `Lounge` sẽ bị trừ 0 điểm.</p>
</div>
</div>
<div class="dimension-item">
<div class="dim-icon">📅</div>
<div class="dim-content">
<h3>Dịp mặc (Occasion Boost) <span class="badge-weight">20 ĐIỂM</span></h3>
<p>Tối ưu cho ngữ cảnh. Khi khách đang muốn tìm đồ "Đi làm", hệ thống tự động buff 20 điểm cho các Quần Âu, Áo Sơ mi trong kho để đẩy trồi lên ưu tiên.</p>
</div>
</div>
<div class="dimension-item">
<div class="dim-icon">📦</div>
<div class="dim-content">
<h3>Vai trò Loại trừ (Role) <span class="badge-weight">12 ĐIỂM</span></h3>
<p>Ngăn lỗi vớ vẩn: "Không gợi ý Áo khi xem Áo". Nếu món gốc là TOP, AI chỉ đi tìm BOTTOM, OUTERWEAR, ACCESSORY. Món nào sai vai trò tự động điểm = 0.</p>
</div>
</div>
<div class="dimension-item">
<div class="dim-icon">🌿</div>
<div class="dim-content">
<h3>Chất liệu & Mùa (Material/Season) <span class="badge-weight">10 ĐIỂM</span></h3>
<p>Khắc phục lỗi cơ học (Phối quần đùi Linen mát mẻ của Hè với Áo Phao chần bông của Đông). Khác mùa là rớt đài.</p>
</div>
</div>
<div class="dimension-item">
<div class="dim-icon">🔀</div>
<div class="dim-content">
<h3>Penalty Đa dạng hóa (Diversity) <span class="badge-weight">8 ĐIỂM</span></h3>
<p>Đã duyệt 1 cái Quần Khaki Trắng rồi, thì chiếc Khaki Trắng khác xuất hiện sẽ bị giáng "Phạt Trùng Lặp" (Trừ điểm mạnh) để nhường slot cho SKU khác màu khác dáng.</p>
</div>
</div>
</div>
</div>
<!-- TAB 2: FLOWCHART D3 -->
<div id="flowchart" class="tab-content">
<div class="card-section">
<h2>🔄 Knowledge Graph: Luồng Thuật toán AI</h2>
<p style="font-size: 14px; color: var(--muted-fg); margin-bottom: 20px;">Kéo thả các node để tương tác. Đồ thị mô phỏng cách hệ thống càn quét và chấm điểm.</p>
<div class="mermaid-box" style="height: 500px; padding:0; overflow:hidden;" id="d3-flowchart-container">
<!-- D3 SVG will go here -->
</div>
</div>
</div>
<!-- TAB 3: COLOR MATRIX D3 -->
<div id="color-matrix" class="tab-content">
<div class="card-section">
<h2>🎨 Knowledge Graph: Ma trận Hòa sắc</h2>
<p style="font-size: 14px; color: var(--muted-fg); margin-bottom: 20px;">Đồ thị mạng lưới quan hệ giữa các phân nhóm màu (Bản đồ tương tác).</p>
<div class="mermaid-box" style="height: 400px; padding:0; overflow:hidden;" id="d3-matrix-container">
<!-- D3 SVG will go here -->
</div>
<p style="font-size: 13px; color: var(--muted-fg); margin-top: 20px; font-style: italic;">
* Mạng lưới này là kim chỉ nam quyết định điểm "Hòa sắc 28đ". Đường nối càng dày thì màu đi càng mượt.
</p>
</div>
</div>
</div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
function switchTab(tabId, btn) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
btn.classList.add('active');
// Re-trigger layout forces when tab is shown to fix size issues
if(tabId === 'flowchart' && simFlow) simFlow.alpha(1).restart();
if(tabId === 'color-matrix' && simMatrix) simMatrix.alpha(1).restart();
}
// ════════════════════════════════════════
// D3 GRAPH ENGINE
// ════════════════════════════════════════
const typeColors = {
'Input': '#6D28D9', 'Action': '#EA580C', 'Check': '#B45309',
'Dim': '#0891B2', 'Process': '#1D4ED8', 'Output': '#10B981', 'Drop': '#EF4444',
'Neutral': '#9CA3AF', 'Dark': '#1E3A8A', 'Light': '#FBCFE8'
};
function buildGraph(containerId, data) {
const parent = document.getElementById(containerId);
parent.innerHTML = '';
const width = parent.clientWidth || 800;
const height = parent.clientHeight || 400;
const svg = d3.select(parent).append('svg').attr('width', width).attr('height', height);
const g = svg.append('g');
// Add zoom
const zoom = d3.zoom().on('zoom', e => g.attr('transform', e.transform));
svg.call(zoom);
// Defs for arrowhead
svg.append('defs').append('marker')
.attr('id', 'arrow-'+containerId).attr('viewBox', '0 -5 10 10')
.attr('refX', 22).attr('refY', 0).attr('markerWidth', 5).attr('markerHeight', 5).attr('orient', 'auto')
.append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', '#C4C0B8');
const sim = d3.forceSimulation(data.nodes)
.force('link', d3.forceLink(data.links).id(d => d.id).distance(120))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width/2, height/2))
.force('collide', d3.forceCollide(35));
// Links
const link = g.append('g').selectAll('line').data(data.links).enter().append('line')
.attr('stroke', '#E2E0D8').attr('stroke-width', d => d.w || 2)
.attr('marker-end', d => d.bidirectional ? '' : 'url(#arrow-'+containerId+')');
// Link Labels
const linkLabels = g.append('g').selectAll('text').data(data.links).enter().append('text')
.text(d => d.label).attr('font-size', '10px').attr('fill', '#78716C').attr('text-anchor', 'middle')
.style('pointer-events', 'none').style('background', '#fff');
// Nodes
const node = g.append('g').selectAll('g').data(data.nodes).enter().append('g')
.call(d3.drag()
.on('start', (e,d) => { if(!e.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on('drag', (e,d) => { d.fx=e.x; d.fy=e.y; })
.on('end', (e,d) => { if(!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; })
).style('cursor', 'pointer');
node.append('circle').attr('r', 18).attr('fill', d => typeColors[d.type] || '#ccc').attr('stroke', '#fff').attr('stroke-width', 2);
node.append('text').text(d => d.name).attr('dy', 32).attr('text-anchor', 'middle').attr('font-size', '12px').attr('fill', '#444').attr('font-weight', '600');
sim.on('tick', () => {
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
linkLabels.attr('x', d => (d.source.x + d.target.x)/2).attr('y', d => (d.source.y + d.target.y)/2);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
return sim;
}
const flowData = {
nodes: [
{id:'n1', name:'Polo Gốc', type:'Input'}, {id:'n2', name:'Quét DB', type:'Action'},
{id:'n3', name:'Role Filter', type:'Check'}, {id:'n4', name:'Bỏ', type:'Drop'},
{id:'n6', name:'Vòng Chấm Điểm', type:'Process'}, {id:'n7', name:'Màu (28đ)', type:'Dim'},
{id:'n8', name:'Style (22đ)', type:'Dim'}, {id:'n9', name:'Dịp (20đ)', type:'Dim'},
{id:'n10', name:'Mùa (10đ)', type:'Dim'}, {id:'n11', name:'Sum', type:'Process'},
{id:'n12', name:'Penalty', type:'Check'}, {id:'n13', name:'Ranking', type:'Process'},
{id:'n14', name:'Suggest KQ', type:'Output'}
],
links: [
{source:'n1', target:'n2', label:''}, {source:'n2', target:'n3', label:'Hàng ngàn SKU'},
{source:'n3', target:'n4', label:'Là Áo'}, {source:'n3', target:'n6', label:'Là Quần/Váy'},
{source:'n6', target:'n7', label:''}, {source:'n6', target:'n8', label:''},
{source:'n6', target:'n9', label:''}, {source:'n6', target:'n10', label:''},
{source:'n7', target:'n11', label:''}, {source:'n8', target:'n11', label:''},
{source:'n9', target:'n11', label:''}, {source:'n10', target:'n11', label:''},
{source:'n11', target:'n12', label:'77đ'}, {source:'n12', target:'n13', label:'-0đ (Ko trùng)'},
{source:'n13', target:'n14', label:'Top 3'}
]
};
const matrixData = {
nodes: [
{id:'n1', name:'NEUTRAL', type:'Neutral'}, {id:'n2', name:'DARK', type:'Dark'}, {id:'n3', name:'LIGHT', type:'Light'}
],
links: [
{source:'n1', target:'n1', label:'30đ (Siêu Mượt)', w:4, bidirectional:true},
{source:'n1', target:'n2', label:'25đ (Cân bằng)', w:3, bidirectional:true},
{source:'n1', target:'n3', label:'22đ (Thanh lịch)', w:3, bidirectional:true},
{source:'n2', target:'n2', label:'10đ (Chói/Gắt)', w:1, bidirectional:true},
{source:'n3', target:'n3', label:'15đ (Nhòa nhạt)', w:1, bidirectional:true},
{source:'n2', target:'n3', label:'8đ (Tương phản lớn)', w:1, bidirectional:true}
]
};
let simFlow, simMatrix;
setTimeout(() => {
simFlow = buildGraph('d3-flowchart-container', flowData);
simMatrix = buildGraph('d3-matrix-container', matrixData);
}, 500);
</script>
</body>
</html>
......@@ -7,25 +7,38 @@
<link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css">
<script src="/static/common/frame-detect.js"></script>
<link rel="stylesheet" href="/static/fashion-matches/style.css">
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="/static/fashion-matches/style.css?v=3">
<style>
.lucide { vertical-align: middle; }
.icon-sm { width: 14px; height: 14px; }
.icon-md { width: 16px; height: 16px; }
.icon-lg { width: 24px; height: 24px; }
.icon-xl { width: 40px; height: 40px; color: var(--muted-fg); }
.fm-tabs .lucide { margin-right: 6px; }
</style>
</head>
<body>
<div class="fm-layout">
<!-- ══ LEFT PANEL — Product List ══════════════════════════ -->
<div class="fm-left">
<div class="fm-left-header">
<div style="font-size:15px;font-weight:700;">👗 Fashion Matches</div>
<div style="display:flex;gap:6px;">
<button class="btn btn-outline btn-sm" onclick="openRulesModal()" title="Xem công thức phối đồ">📐 Công thức</button>
<button id="btnBatchRegen" class="btn btn-primary btn-sm" onclick="triggerBatch()">▶ Batch AI</button>
<!-- Header -->
<div class="fm-left-header" style="flex-direction:column; align-items:stretch; gap:12px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div style="font-weight:700;font-size:15px;color:var(--foreground);">Sản phẩm</div>
<div style="display:flex; gap:6px;">
<button class="btn btn-outline btn-sm" onclick="openRulesModal()" title="Chỉnh sửa công thức"><i data-lucide="settings" class="icon-sm"></i> Công thức</button>
<button id="btnBatchRegen" class="btn btn-primary btn-sm" onclick="triggerBatch()"><i data-lucide="play" class="icon-sm"></i> Batch</button>
</div>
</div>
<button class="btn btn-secondary btn-sm" style="width:100%; border:1px dashed var(--border); font-size:12px;" onclick="let a=document.createElement('a'); a.setAttribute('data-page','fashion-matches/docs.html'); window.parent.navigateTo(a)"><i data-lucide="book-open" class="icon-sm" style="margin-right:6px"></i> Xem Tài liệu Logic AI (Docx)</button>
</div>
<!-- Batch bar -->
<div id="batchBar" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);">
<div style="display:flex;align-items:center;gap:8px;font-size:12px;">
<span id="batchLabel"></span>
<span id="batchLabel"><i data-lucide="loader" class="icon-sm" style="animation: spin 2s linear infinite;"></i></span>
<div class="progress-bar-wrap" style="flex:1;">
<div class="progress-bar-fill" id="progressFill" style="width:0%"></div>
</div>
......@@ -36,7 +49,7 @@
<!-- Search -->
<div style="padding:10px 12px;border-bottom:1px solid var(--border);">
<div class="search-bar">
<span class="search-icon">🔍</span>
<span class="search-icon"><i data-lucide="search" class="icon-md"></i></span>
<input type="text" id="listSearch" placeholder="Tìm mã SP hoặc tên..." oninput="onListSearch(this.value)">
</div>
</div>
......@@ -54,7 +67,7 @@
<!-- Product list -->
<div class="fm-list" id="productList">
<div class="empty-state" style="padding:40px 16px;">
<div class="empty-icon" style="font-size:24px;opacity:.4;">📦</div>
<div class="empty-icon" style="opacity:.4;"><i data-lucide="package" class="icon-lg"></i></div>
<p style="font-size:12px;">Đang tải...</p>
</div>
</div>
......@@ -72,7 +85,7 @@
<!-- Welcome state -->
<div class="empty-state" id="welcomeState" style="height:100%;justify-content:center;">
<div class="empty-icon">👗</div>
<div class="empty-icon"><i data-lucide="shirt" class="icon-xl"></i></div>
<h3>Chọn sản phẩm từ danh sách</h3>
<p>Click vào sản phẩm bên trái để xem và chỉnh sửa phối đồ</p>
</div>
......@@ -92,30 +105,40 @@
</div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0;">
<button id="btnRegen" class="btn btn-outline btn-sm" onclick="regenOne()">🤖 AI Regen</button>
<button id="btnSave" class="btn btn-primary btn-sm" onclick="saveMatches()">💾 Lưu</button>
<button id="btnRegen" class="btn btn-outline btn-sm" onclick="regenOne()"><i data-lucide="bot" class="icon-sm"></i> AI Regen</button>
<button id="btnSave" class="btn btn-primary btn-sm" onclick="saveMatches()"><i data-lucide="save" class="icon-sm"></i> Lưu</button>
</div>
</div>
<!-- Tabs: Mô tả | Phối đồ -->
<div class="fm-tabs" id="detailTabs">
<button class="fm-tab active" onclick="switchDetailTab('matches', this)">🎨 Phối đồ AI</button>
<button class="fm-tab" onclick="switchDetailTab('desc', this)">📝 Mô tả SP</button>
<button class="fm-tab" onclick="switchDetailTab('test', this)">🧪 Test Score</button>
<button class="fm-tab active" onclick="switchDetailTab('matches', this)"><i data-lucide="palette" class="icon-sm"></i> Phối đồ AI</button>
<button class="fm-tab" onclick="switchDetailTab('desc', this)"><i data-lucide="file-text" class="icon-sm"></i> Mô tả SP</button>
<button class="fm-tab" onclick="switchDetailTab('test', this)"><i data-lucide="flask-conical" class="icon-sm"></i> Test Score</button>
<button class="fm-tab" onclick="switchDetailTab('logic', this)"><i data-lucide="sparkles" class="icon-sm"></i> AI Logic & Colors</button>
</div>
<!-- TAB: Phối đồ AI -->
<div id="tabMatches" class="fm-tab-body">
<!-- Occasion pills -->
<div id="occTabs" class="fm-occ-tabs"></div>
<!-- Super Classification Tabs (Group By) -->
<div id="groupTabs" style="display:flex; gap:8px; padding:12px 16px; border-bottom:1px solid rgba(0,0,0,0.05); background:rgba(0,0,0,0.02);">
<button class="btn btn-ghost active" id="btnGroupOcc" onclick="setGroupBy('occ')" style="font-weight:600;"><i data-lucide="shopping-bag" class="icon-sm"></i> Theo Dịp Mặc</button>
<button class="btn btn-ghost" id="btnGroupColor" onclick="setGroupBy('color')" style="font-weight:600;"><i data-lucide="palette" class="icon-sm"></i> Theo Màu Sắc</button>
<button class="btn btn-ghost" id="btnGroupMaterial" onclick="setGroupBy('material')" style="font-weight:600;"><i data-lucide="leaf" class="icon-sm"></i> Theo Chất Liệu</button>
</div>
<!-- Dynamic Sub-Pills -->
<div id="occTabs" class="fm-occ-tabs" style="padding-top:12px;"></div>
<!-- Cards -->
<div id="matchContent" class="fm-match-scroll"></div>
<!-- Empty -->
<div id="emptyState" class="empty-state" style="display:none;padding:48px 24px;">
<div class="empty-icon">🎨</div>
<div class="empty-icon"><i data-lucide="palette" class="icon-xl"></i></div>
<h3>Chưa có gợi ý phối đồ</h3>
<p>Bấm AI Regen để tạo tự động</p>
<button class="btn btn-primary" style="margin-top:16px;" onclick="regenOne()">🤖 Tạo ngay</button>
<button class="btn btn-primary" style="margin-top:16px;" onclick="regenOne()"><i data-lucide="bot" class="icon-sm"></i> Tạo ngay</button>
</div>
</div>
......@@ -129,7 +152,7 @@
<div style="font-size:13px;color:var(--muted-fg);margin-bottom:14px;">Nhập mã SP đích để xem điểm phối đồ với SP hiện tại</div>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:16px;">
<div class="search-bar" style="flex:1;">
<span class="search-icon">🎯</span>
<span class="search-icon"><i data-lucide="target" class="icon-sm"></i></span>
<input type="text" id="testTargetCode" placeholder="Mã SP đích (vd: 6QK25S003-SN810)">
</div>
<button class="btn btn-primary btn-sm" onclick="runScoreTest()">Tính điểm</button>
......@@ -137,6 +160,37 @@
<div id="scoreTestResult"></div>
</div>
<!-- TAB: AI Logic & Colors -->
<div id="tabLogic" class="fm-tab-body" style="display:none;overflow-y:auto;padding:16px;background:var(--bg);">
<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:16px;">
<h4 style="margin:0 0 12px;font-size:14px;display:flex;align-items:center;gap:8px;"><i data-lucide="palette" class="icon-sm"></i> Color Synergy Checker</h4>
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px;">
<input type="text" id="color1Input" class="select" placeholder="Màu 1 (vd: Đỏ)" style="flex:1;">
<span style="color:var(--muted-fg)">+</span>
<input type="text" id="color2Input" class="select" placeholder="Màu 2 (vd: Đen)" style="flex:1;">
<button class="btn btn-outline" onclick="checkColorSynergy()">Kiểm tra</button>
</div>
<div id="colorSynergyResult" style="font-size:13px;"></div>
</div>
<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h4 style="margin:0;font-size:14px;display:flex;align-items:center;gap:6px;"><i data-lucide="sparkles" class="icon-sm"></i> Tham khảo AI Outfit Suggestion</h4>
<div style="display:flex;gap:8px;">
<select id="logicOccasion" class="select" style="font-size:12px;padding:4px 8px;">
<option value="">-- Mọi dịp --</option>
<option value="hang_ngay">Hàng ngày</option>
<option value="di_lam">Đi làm</option>
<option value="di_choi">Đi chơi</option>
<option value="di_tiec">Đi tiệc</option>
</select>
<button class="btn btn-primary btn-sm" onclick="runLogicSuggest()"><i data-lucide="bot" class="icon-sm"></i> Run AI</button>
</div>
</div>
<div id="logicSuggestResult" style="font-size:13px;color:var(--muted-fg);">Bấm Run AI để xem thuật toán màu sắc và phong cách hoạt động ra sao.</div>
</div>
</div>
</div>
</div>
......@@ -146,13 +200,13 @@
<div id="addModal" class="fm-modal-overlay" style="display:none" onclick="closeAddModal(event)">
<div class="fm-modal-box" onclick="event.stopPropagation()">
<div class="fm-modal-header">
<div style="font-size:15px;font-weight:700;"> Thêm sản phẩm phối cùng</div>
<button onclick="closeAddModal()" class="btn btn-ghost btn-sm"></button>
<div style="font-size:15px;font-weight:700;display:flex;align-items:center;gap:6px;"><i data-lucide="plus" class="icon-md"></i> Thêm sản phẩm phối cùng</div>
<button onclick="closeAddModal()" class="btn btn-ghost btn-sm"><i data-lucide="x" class="icon-sm"></i></button>
</div>
<div style="padding:8px 20px 0;font-size:12px;color:var(--muted-fg);" id="modalOccRole"></div>
<div style="padding:10px 20px 0;">
<div class="search-bar">
<span class="search-icon">🔍</span>
<span class="search-icon"><i data-lucide="search" class="icon-sm"></i></span>
<input type="text" id="modalSearch" placeholder="Tìm mã SP hoặc tên..." oninput="onModalSearch(this.value)">
</div>
</div>
......@@ -164,20 +218,21 @@
<div id="rulesModal" class="fm-modal-overlay" style="display:none" onclick="closeRulesModal(event)">
<div class="fm-modal-box" style="width:740px;max-height:88vh;" onclick="event.stopPropagation()">
<div class="fm-modal-header">
<div style="font-size:15px;font-weight:700;">📐 Công thức phối đồ — fashion_rules.json</div>
<div style="font-size:15px;font-weight:700;display:flex;align-items:center;gap:6px;"><i data-lucide="ruler" class="icon-md"></i> Công thức phối đồ — fashion_rules.json</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary btn-sm" onclick="saveRules()">💾 Lưu rules</button>
<button onclick="closeRulesModal()" class="btn btn-ghost btn-sm"></button>
<button class="btn btn-primary btn-sm" onclick="saveRules()"><i data-lucide="save" class="icon-sm"></i> Lưu rules</button>
<button onclick="closeRulesModal()" class="btn btn-ghost btn-sm"><i data-lucide="x" class="icon-sm"></i></button>
</div>
</div>
<!-- Rules Tabs -->
<div style="display:flex;gap:0;padding:0 20px;border-bottom:1px solid var(--border);">
<button class="fm-tab active" onclick="switchRulesTab('weights', this)">⚖️ Weights</button>
<button class="fm-tab" onclick="switchRulesTab('colors', this)">🎨 Màu sắc</button>
<button class="fm-tab" onclick="switchRulesTab('styles', this)">👗 Style</button>
<button class="fm-tab" onclick="switchRulesTab('roles', this)">📦 Roles</button>
<button class="fm-tab" onclick="switchRulesTab('occasions', this)">📅 Dịp</button>
<button class="fm-tab active" onclick="switchRulesTab('weights', this)"><i data-lucide="scale" class="icon-sm"></i> Weights</button>
<button class="fm-tab" onclick="switchRulesTab('colors', this)"><i data-lucide="palette" class="icon-sm"></i> Màu sắc</button>
<button class="fm-tab" onclick="switchRulesTab('styles', this)"><i data-lucide="shirt" class="icon-sm"></i> Style</button>
<button class="fm-tab" onclick="switchRulesTab('roles', this)"><i data-lucide="package" class="icon-sm"></i> Roles</button>
<button class="fm-tab" onclick="switchRulesTab('occasions', this)"><i data-lucide="calendar" class="icon-sm"></i> Dịp</button>
<button class="fm-tab" onclick="switchRulesTab('materials', this)"><i data-lucide="leaf" class="icon-sm"></i> Chất liệu</button>
</div>
<div style="overflow-y:auto;flex:1;padding:16px 20px;" id="rulesBody">
......@@ -186,9 +241,56 @@
</div>
</div>
<!-- ══ INFO MODAL (MATCH LOGIC) ══════════════════════════════════════ -->
<div id="infoModal" class="fm-modal-overlay" style="display:none" onclick="closeInfoModal(event)">
<div class="fm-modal-box" style="width:550px;max-height:88vh;" onclick="event.stopPropagation()">
<div class="fm-modal-header" style="background: rgba(var(--primary-rgb), 0.05);">
<div style="font-size:15px;font-weight:700;display:flex;align-items:center;gap:6px;color:var(--primary);"><i data-lucide="sparkles" class="icon-md"></i> Giải mã điểm số & Gợi ý phối đồ AI</div>
<button onclick="closeInfoModal()" class="btn btn-ghost btn-sm"><i data-lucide="x" class="icon-sm"></i></button>
</div>
<div style="padding:16px 20px;" id="infoBody">
<!-- Dynamic content injected here -->
</div>
</div>
</div>
<!-- ══ TOAST ══════════════════════════════════════════════ -->
<div id="toast" class="fm-toast"></div>
<script src="/static/fashion-matches/app.js"></script>
<script src="/static/fashion-matches/app.js?v=3"></script>
<script>
if (window.lucide) {
lucide.createIcons();
}
// Make global with debounce to prevent infinite freeze/lag
let refreshTimer = null;
window.refreshIcons = function() {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
if (window.lucide) {
lucide.createIcons();
}
refreshTimer = null;
}, 100);
};
// Auto-refresh when elements added dynamically (like React/Vue, or simple innerHTML injection)
const observer = new MutationObserver((mutations) => {
let shouldRefresh = false;
for (const m of mutations) {
if (m.addedNodes.length > 0) {
// Prevent observing our own svg replacements looping forever if bugged
if (m.addedNodes[0].nodeName === 'svg' || m.addedNodes[0].nodeName === 'SVG') continue;
shouldRefresh = true;
break;
}
}
if (shouldRefresh) {
window.refreshIcons();
}
});
observer.observe(document.body, { childList: true, subtree: true });
</script>
</body>
</html>
......@@ -18,36 +18,42 @@ body{margin:0;display:flex;min-height:100vh}
/* hide topbar since each page has its own */
.page-topbar{padding:0;margin:0;display:none}
/* Professional sidebar tone: keep subtle markers instead of emoji icons */
/* Professional sidebar tone: replace text icons with SVG */
#mainSidebar .brand-icon{
font-size:.78em;
font-weight:700;
letter-spacing:.08em;
}
#mainSidebar .nav-item{gap:10px}
#mainSidebar .nav-item{gap:12px; display: flex; align-items: center;}
#mainSidebar .nav-icon{
position:relative;
width:8px;
min-width:8px;
font-size:0;
line-height:0;
color:transparent;
display: flex; align-items: center; justify-content: center;
width: 20px; height: 20px;
color: var(--m);
transition: color 0.2s ease, transform 0.2s ease;
flex-shrink: 0;
}
#mainSidebar .nav-icon::before{
content:'';
display:block;
width:6px;
height:6px;
border-radius:999px;
background:#CFC6B9;
transition:background .2s ease, transform .2s ease;
}
#mainSidebar .nav-item:hover .nav-icon::before{background:#A99E91}
#mainSidebar .nav-item.active .nav-icon::before{background:var(--gold);transform:scale(1.12)}
#mainSidebar .nav-item:hover .nav-icon{color: var(--t);}
#mainSidebar .nav-item.active .nav-icon{color: var(--gold); transform: scale(1.1);}
@media(max-width:1024px){
.main{margin-left:64px}
body .sidebar { width: 64px !important; }
.main { margin-left: 64px; }
.brand-text,
.nav-group-label,
.nav-item > span:not(.nav-icon),
.nav-badge,
.version-info,
#sidebarUserInfo span[id="userName"],
#sidebarUserInfo button {
display: none !important;
}
#mainSidebar .sidebar-brand { justify-content: center; padding: 16px 0; }
.brand-icon { margin-right: 0 !important; font-size: 14px; }
#mainSidebar .nav-item { justify-content: center; padding: 12px 0; border-radius: 8px; margin: 2px 8px; }
#mainSidebar .nav-icon { margin: 0; }
#sidebarUserInfo { padding: 8px; justify-content: center; margin-bottom: 8px; }
}
.sidebar-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; padding-bottom: 20px; }
.sidebar-scroll::-webkit-scrollbar { width: 4px; background: transparent; }
.sidebar-scroll::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.1); border-radius: 4px; }
......@@ -86,120 +92,154 @@ body{margin:0;display:flex;min-height:100vh}
<div class="sidebar-scroll">
<div class="nav-group">
<div class="nav-group-label">Main</div>
<a data-page="roadmap/roadmap.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RM</span>
<a data-page="roadmap/roadmap.html" class="nav-item" onclick="navigateTo(this)" title="Kế hoạch phát triển">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
<span>Kế hoạch phát triển</span>
</a>
<a data-page="flow/flow.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="flow/flow.html" class="nav-item" onclick="navigateTo(this)" title="Sơ đồ hoạt động">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg></span>
<span>Sơ đồ hoạt động</span>
</a>
<a data-page="experiment_detail.html?id=exp_chatbot_prod" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="experiment_detail.html?id=exp_chatbot_prod" class="nav-item" onclick="navigateTo(this)" title="Chatbot">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span>
<span>Chatbot</span>
<span class="nav-badge badge-live">LIVE</span>
</a>
<a data-page="history/history.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="history/history.html" class="nav-item" onclick="navigateTo(this)" title="History">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>
<span>History</span>
</a>
<a data-page="product/product.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="product/product.html" class="nav-item" onclick="navigateTo(this)" title="Product Perf.">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg></span>
<span>Product Perf.</span>
</a>
<a data-page="product-desc/product-desc.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">UD</span>
<a data-page="product-desc/product-desc.html" class="nav-item" onclick="navigateTo(this)" title="Ultra Description">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></span>
<span>Ultra Description</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="fashion-matches/index.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon">👗</span>
<a data-page="fashion-matches/index.html" class="nav-item" onclick="navigateTo(this)" title="Fashion Matches">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></span>
<span>Fashion Matches</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="ai-report.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="outfit-logic/index.html" class="nav-item" onclick="navigateTo(this)" title="Outfit Logic">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="21"/></svg></span>
<span>Outfit Logic</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="ai-report.html" class="nav-item" onclick="navigateTo(this)" title="AI Data Analyst">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></span>
<span>AI Data Analyst</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="ai-sql.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:11px;font-weight:800;letter-spacing:.04em">SQL</span>
<a data-page="ai-sql.html" class="nav-item" onclick="navigateTo(this)" title="AI sinh SQL">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg></span>
<span>AI sinh SQL</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="live-monitor/live-monitor.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.08em">LIVE</span>
<a data-page="live-monitor/live-monitor.html" class="nav-item" onclick="navigateTo(this)" title="Realtime Monitor">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span>
<span>Realtime Monitor</span>
<span class="nav-badge badge-live">LIVE</span>
</a>
<a data-page="prompt-optimizer/prompt-optimizer.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">PO</span>
<a data-page="prompt-optimizer/prompt-optimizer.html" class="nav-item" onclick="navigateTo(this)" title="Prompt Optimizer">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
<span>Prompt Optimizer</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="user-simulator/user-simulator.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">US</span>
<a data-page="user-simulator/user-simulator.html" class="nav-item" onclick="navigateTo(this)" title="User Simulator">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span>
<span>User Simulator</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="user-insight/user-insight.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">UI</span>
<a data-page="user-insight/user-insight.html" class="nav-item" onclick="navigateTo(this)" title="User Insight">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></span>
<span>User Insight</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="knowledge-graph/knowledge-graph.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">KG</span>
<a data-page="knowledge-graph/knowledge-graph.html" class="nav-item" onclick="navigateTo(this)" title="Knowledge Graph">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg></span>
<span>Knowledge Graph</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="reaction-simulator/reaction-simulator.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">🔮</span>
<a data-page="reaction-simulator/reaction-simulator.html" class="nav-item" onclick="navigateTo(this)" title="Reaction Sim.">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 10c-.83 0-1.5-.67-1.5-1.5v-5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5z"/><path d="M20.5 10H19V8.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/><path d="M9.5 14.5c.83 0 1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5S8 21.83 8 21v-5c0-.83.67-1.5 1.5-1.5z"/><path d="M3.5 14H5v1.5c0 .83-.67 1.5-1.5 1.5S2 16.33 2 15.5 2.67 14 3.5 14z"/></svg></span>
<span>Reaction Sim.</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="regression-test/regression-test.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RT</span>
<a data-page="regression-test/regression-test.html" class="nav-item" onclick="navigateTo(this)" title="Regression Test">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
<span>Regression Test</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="stress-test/stress-test.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">ST</span>
<a data-page="stress-test/stress-test.html" class="nav-item" onclick="navigateTo(this)" title="Stress Test">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg></span>
<span>Stress Test</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="diagram-agent/diagram-agent.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">DA</span>
<a data-page="diagram-agent/diagram-agent.html" class="nav-item" onclick="navigateTo(this)" title="AI Diagram">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="21"/></svg></span>
<span>AI Diagram</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="competitor-research/competitor-research.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">CR</span>
<a data-page="competitor-research/competitor-research.html" class="nav-item" onclick="navigateTo(this)" title="Competitor Research">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<span>Competitor Research</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Social Content</div>
<a data-page="social-inbox/index.html" class="nav-item" onclick="navigateTo(this)" title="Social Inbox">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span>
<span>Social Inbox</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="content-composer/index.html" class="nav-item" onclick="navigateTo(this)" title="Composer">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></span>
<span>Composer</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="content-approval/index.html" class="nav-item" onclick="navigateTo(this)" title="Báo cáo duyệt">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<span>Báo cáo duyệt</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="content-calendar/index.html" class="nav-item" onclick="navigateTo(this)" title="Lịch Content">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
<span>Lịch Content</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="media-library/index.html" class="nav-item" onclick="navigateTo(this)" title="Media Library">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></span>
<span>Media Library</span>
<span class="nav-badge badge-new">NEW</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Workspace</div>
<a data-page="resources/resources.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="resources/resources.html" class="nav-item" onclick="navigateTo(this)" title="Resources">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<span>Resources</span>
</a>
<a data-page="notes/notes.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="notes/notes.html" class="nav-item" onclick="navigateTo(this)" title="Team Notes">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg></span>
<span>Team Notes</span>
</a>
<a data-page="dashboard-note/index.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">📝</span>
<a data-page="dashboard-note/index.html" class="nav-item" onclick="navigateTo(this)" title="Dashboard Note">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/></svg></span>
<span>Dashboard Note</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="changelog/changelog.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="changelog/changelog.html" class="nav-item" onclick="navigateTo(this)" title="Changelog">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg></span>
<span>Changelog</span>
</a>
<a data-page="guide/guide.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="guide/guide.html" class="nav-item" onclick="navigateTo(this)" title="Hướng dẫn">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg></span>
<span>Hướng dẫn</span>
</a>
</div>
......@@ -208,62 +248,67 @@ body{margin:0;display:flex;min-height:100vh}
<div class="nav-group">
<div class="nav-group-label">Thử nghiệm</div>
<a data-page="test_sql/test_sql.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="mock-fe/index.html" class="nav-item" onclick="navigateTo(this)" title="Auto-Train Console">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg></span>
<span>Auto-Train AI</span>
<span class="nav-badge badge-beta">MOCK</span>
</a>
<a data-page="test_sql/test_sql.html" class="nav-item" onclick="navigateTo(this)" title="Text-to-SQL">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg></span>
<span>Text-to-SQL</span>
<span class="nav-badge badge-beta">BETA</span>
</a>
<a data-page="test_db/test_db.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="test_db/test_db.html" class="nav-item" onclick="navigateTo(this)" title="DB Test">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg></span>
<span>DB Test</span>
<span class="nav-badge badge-beta">BETA</span>
</a>
<a data-page="feedback_demo/feedback_demo.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="feedback_demo/feedback_demo.html" class="nav-item" onclick="navigateTo(this)" title="Feedback Demo">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg></span>
<span>Feedback Demo</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="http://172.16.2.210:5006/static/index.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="http://172.16.2.210:5006/static/index.html" class="nav-item" onclick="navigateTo(this)" title="Chatbot (Dev)">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
<span>Chatbot (Dev)</span>
<span class="nav-badge badge-beta">DEV</span>
</a>
<a data-page="cache.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon"></span>
<a data-page="cache.html" class="nav-item" onclick="navigateTo(this)" title="Cache Manager">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg></span>
<span>Cache Manager</span>
</a>
<a data-page="sku-search/sku-search.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">SK</span>
<a data-page="sku-search/sku-search.html" class="nav-item" onclick="navigateTo(this)" title="SKU Search">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<span>SKU Search</span>
<span class="nav-badge badge-beta">DEV</span>
</a>
<a data-page="tag-search/tag-search.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">TG</span>
<a data-page="tag-search/tag-search.html" class="nav-item" onclick="navigateTo(this)" title="Tag Search">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg></span>
<span>Tag Search</span>
<span class="nav-badge badge-beta">DEV</span>
</a>
<a data-page="store-search/store-search.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">ST</span>
<a data-page="store-search/store-search.html" class="nav-item" onclick="navigateTo(this)" title="Store Search">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></svg></span>
<span>Store Search</span>
<span class="nav-badge badge-beta">DEV</span>
</a>
<a data-page="image-search/image-search.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">IM</span>
<a data-page="image-search/image-search.html" class="nav-item" onclick="navigateTo(this)" title="Image Search">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></span>
<span>Image Search</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="lead_flow/lead-flow.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">LG</span>
<a data-page="lead_flow/lead-flow.html" class="nav-item" onclick="navigateTo(this)" title="Lead Generation">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
<span>Lead Generation</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="limit/limit.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">RL</span>
<a data-page="limit/limit.html" class="nav-item" onclick="navigateTo(this)" title="Rate Limit">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20"/><path d="m17 5-5-3-5 3"/><path d="m17 19-5 3-5-3"/></svg></span>
<span>Rate Limit</span>
<span class="nav-badge badge-beta">NEW</span>
</a>
<a data-page="merge_history/index.html" class="nav-item" onclick="navigateTo(this)">
<span class="nav-icon" style="font-size:10px;font-weight:800;letter-spacing:.04em">MH</span>
<a data-page="merge_history/index.html" class="nav-item" onclick="navigateTo(this)" title="Merge History">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg></span>
<span>Merge History</span>
<span class="nav-badge badge-beta">DEV</span>
</a>
......@@ -271,12 +316,12 @@ body{margin:0;display:flex;min-height:100vh}
<div class="nav-group">
<div class="nav-group-label">External</div>
<a href="/docs" target="_blank" class="nav-item">
<span class="nav-icon"></span>
<a href="/docs" target="_blank" class="nav-item" title="API Docs">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg></span>
<span>API Docs</span>
</a>
<a href="/redoc" target="_blank" class="nav-item">
<span class="nav-icon"></span>
<a href="/redoc" target="_blank" class="nav-item" title="ReDoc">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg></span>
<span>ReDoc</span>
</a>
</div>
......
{
"occasions": ["hang_ngay", "di_lam", "di_choi", "di_tiec", "the_thao", "mac_nha"],
"occasions": [
"hang_ngay",
"di_lam",
"di_choi",
"di_tiec",
"the_thao",
"mac_nha",
"du_lich"
],
"occasion_labels": {
"hang_ngay": "Hàng ngày",
"di_lam": "Đi làm",
"di_choi": "Đi chơi",
"di_lam": "Đi làm công sở",
"di_choi": "Đi chơi / dạo phố",
"di_tiec": "Đi tiệc",
"the_thao": "Thể thao",
"mac_nha": "Mặc nhà"
"mac_nha": "Ở nhà / mặc ngủ",
"du_lich": "Du lịch"
},
"occasion_keywords": {
"hang_ngay": ["hàng ngày", "hằng ngày", "casual", "everyday"],
"di_lam": ["đi làm", "công sở", "văn phòng", "work", "office"],
"di_choi": ["đi chơi", "dạo phố", "cafe", "bạn bè", "outing"],
"di_tiec": ["tiệc", "sự kiện", "party", "event", "lễ"],
"the_thao": ["thể thao", "tập gym", "sport", "workout", "gym"],
"mac_nha": ["mặc nhà", "ở nhà", "home", "lounge", "nghỉ ngơi"]
},
"roles": ["bottom", "outerwear", "accessory"],
"hang_ngay": [
"hàng ngày",
"hằng ngày",
"casual",
"everyday"
],
"di_lam": [
"đi làm",
"công sở",
"văn phòng",
"work",
"office"
],
"di_choi": [
"đi chơi",
"dạo phố",
"cafe",
"bạn bè",
"outing"
],
"di_tiec": [
"tiệc",
"sự kiện",
"party",
"event",
"lễ"
],
"the_thao": [
"thể thao",
"tập gym",
"sport",
"workout",
"gym"
],
"mac_nha": [
"mặc nhà",
"ở nhà",
"home",
"lounge",
"nghỉ ngơi"
],
"du_lich": [
"du lịch",
"travel",
"đi biển",
"nghỉ dưỡng",
"resort"
]
},
"roles": [
"top",
"bottom",
"outerwear",
"accessory"
],
"role_labels": {
"top": "Áo",
"bottom": "Quần / Chân váy",
"outerwear": "Layer ngoài",
"accessory": "Phụ kiện"
},
"role_priority": {
"bottom": 1,
"outerwear": 2,
"accessory": 3
"top": 1,
"bottom": 2,
"outerwear": 3,
"accessory": 4
},
"role_max_items": {
"top": 3,
"bottom": 3,
"outerwear": 2,
"accessory": 2
},
"_comment_product_line_to_role": "Ánh xạ tên product_line_vn → role trong outfit",
"product_line_to_role": {
"Áo Polo": "top",
"Áo phông": "top",
"Áo Sơ mi": "top",
"Áo sơ mi": "top",
"Áo kiểu": "top",
"Áo nỉ": "top",
"Áo nỉ có mũ": "top",
"Áo len": "top",
"Áo ba lỗ": "top",
"Áo hai dây": "top",
"Áo Body": "top",
"Áo giữ nhiệt": "top",
"Áo lót": "top",
"Quần jean": "bottom",
"Quần khaki": "bottom",
"Quần Khaki": "bottom",
"Quần dài": "bottom",
"Quần soóc": "bottom",
"Quần nỉ": "bottom",
......@@ -47,6 +118,8 @@
"Quần mặc nhà": "bottom",
"Quần thể thao": "bottom",
"Quần váy": "bottom",
"Quần giữ nhiệt": "bottom",
"Quần Body": "bottom",
"Chân váy": "bottom",
"Váy liền": "bottom",
"Áo khoác": "outerwear",
......@@ -69,73 +142,454 @@
"Khăn": "accessory",
"Túi xách": "accessory"
},
"_comment_exclude": "Product lines bị loại khỏi engine hoàn toàn",
"exclude_product_lines": [
"Áo lót", "Áo ba lỗ", "Áo body", "Áo Body",
"Áo giữ nhiệt", "Quần giữ nhiệt",
"Bộ đồ ngủ", "Pyjama", "Bộ mặc nhà",
"Khăn mặt", "Khăn tắm", "Khăn lau đầu", "Chăn cá nhân",
"Khẩu trang", "Găng tay chống nắng",
"Tất", "Quần lót", "Quần lót đùi", "Quần lót tam giác",
"Bộ thể thao", "Bộ quần áo"
],
"Áo lót",
"Áo ba lỗ",
"Áo body",
"Áo Body",
"Áo giữ nhiệt",
"Quần giữ nhiệt",
"Quần Body",
"Bộ đồ ngủ",
"Pyjama",
"Bộ mặc nhà",
"Áo mặc nhà",
"Quần mặc nhà",
"Khăn mặt",
"Khăn tắm",
"Khăn lau đầu",
"Chăn cá nhân",
"Khẩu trang",
"Găng tay chống nắng",
"Tất",
"Quần lót",
"Quần lót đùi",
"Quần lót tam giác",
"Bộ thể thao",
"Bộ quần áo"
],
"_comment_color": "Ma trận điểm màu sắc (0-30). Detect từ master_color. Chỉ cần match 1 phần tên màu.",
"color_keys": {
"Trắng": ["trắng", "white"],
"Đen": ["đen", "black"],
"Be": ["be", "beige", "kem", "cream"],
"Xám": ["xám", "gray", "grey"],
"Xanh navy": ["navy", "xanh navy", "xanh đậm"],
"Xanh lam": ["xanh lam", "blue", "xanh dương"],
"Xanh lá": ["xanh lá", "green", "xanh rêu"],
"Đỏ": ["đỏ", "red"],
"Nâu": ["nâu", "brown", "chocolate"],
"Hồng": ["hồng", "pink", "rose"],
"Vàng": ["vàng", "yellow", "gold"],
"Cam": ["cam", "orange"]
"Trắng": [
"trắng",
"white"
],
"Đen": [
"đen",
"black"
],
"Be": [
"be",
"beige",
"kem",
"cream"
],
"Xám": [
"xám",
"gray",
"grey"
],
"Xanh navy": [
"navy",
"xanh navy",
"xanh đậm"
],
"Xanh lam": [
"xanh lam",
"blue",
"xanh dương",
"xanh da trời"
],
"Xanh lá": [
"xanh lá",
"xanh lá cây",
"green",
"xanh rêu"
],
"Xanh Jeans": [
"xanh jeans",
"màu xanh jeans",
"jeans",
"indigo",
"denim"
],
"Xanh than": [
"xanh than",
"aqua",
"teal",
"xanh than/aqua"
],
"Đỏ": [
"đỏ",
"red"
],
"Nâu": [
"nâu",
"brown",
"chocolate"
],
"Hồng": [
"hồng",
"pink",
"magenta",
"rose"
],
"Vàng": [
"vàng",
"yellow",
"gold"
],
"Cam": [
"cam",
"orange"
],
"Tím": [
"tím",
"purple",
"violet",
"lavender"
]
},
"color_matrix": {
"Trắng": {"Trắng": 5, "Đen": 30, "Xanh navy": 25, "Be": 28, "Xám": 22, "Xanh lam": 20, "Đỏ": 18, "Xanh lá": 15, "Nâu": 15, "Hồng": 20, "Vàng": 12, "Cam": 10},
"Đen": {"Trắng": 30, "Đen": 8, "Xám": 25, "Be": 22, "Xanh navy": 20, "Đỏ": 18, "Hồng": 15, "Xanh lam": 15, "Nâu": 12, "Xanh lá": 10, "Vàng": 8, "Cam": 8},
"Be": {"Trắng": 28, "Đen": 22, "Nâu": 25, "Xám": 20, "Xanh navy": 18, "Xanh lá": 15, "Hồng": 15, "Vàng": 12, "Be": 8},
"Xám": {"Trắng": 25, "Đen": 28, "Xanh navy": 22, "Be": 20, "Xanh lam": 18, "Đỏ": 15, "Hồng": 12, "Xám": 8},
"Xanh navy": {"Trắng": 28, "Xám": 22, "Be": 20, "Đỏ": 18, "Cam": 15, "Xanh lam": 10, "Xanh navy": 8},
"Xanh lam": {"Trắng": 25, "Be": 20, "Xám": 18, "Đen": 18, "Nâu": 12, "Xanh lam": 8},
"Xanh lá": {"Trắng": 20, "Be": 22, "Nâu": 20, "Đen": 18, "Xám": 12, "Xanh lá": 8},
"Đỏ": {"Trắng": 28, "Đen": 22, "Xanh navy": 18, "Xám": 15, "Be": 12, "Đỏ": 5},
"Nâu": {"Be": 28, "Trắng": 22, "Xanh lá": 20, "Xám": 18, "Đen": 15, "Nâu": 8},
"Hồng": {"Trắng": 25, "Đen": 18, "Xám": 15, "Be": 18, "Xanh navy": 12, "Hồng": 5},
"Vàng": {"Trắng": 20, "Đen": 18, "Xanh navy": 15, "Xám": 12, "Vàng": 5},
"Cam": {"Trắng": 20, "Đen": 18, "Xanh navy": 15, "Xám": 12, "Cam": 5}
"Trắng": {
"Trắng": 5,
"Đen": 30,
"Xanh navy": 25,
"Be": 28,
"Xám": 22,
"Xanh lam": 20,
"Đỏ": 18,
"Xanh lá": 15,
"Nâu": 15,
"Hồng": 20,
"Vàng": 12,
"Cam": 10,
"Tím": 15,
"Xanh Jeans": 22,
"Xanh than": 18
},
"Đen": {
"Trắng": 30,
"Đen": 8,
"Xám": 25,
"Be": 22,
"Xanh navy": 20,
"Đỏ": 18,
"Hồng": 15,
"Xanh lam": 15,
"Nâu": 12,
"Xanh lá": 10,
"Vàng": 8,
"Cam": 8,
"Tím": 12,
"Xanh Jeans": 18,
"Xanh than": 15
},
"Be": {
"Trắng": 28,
"Đen": 22,
"Nâu": 25,
"Xám": 20,
"Xanh navy": 18,
"Xanh lá": 15,
"Hồng": 15,
"Vàng": 12,
"Be": 8,
"Tím": 12,
"Xanh Jeans": 15,
"Xanh than": 12
},
"Xám": {
"Trắng": 25,
"Đen": 28,
"Xanh navy": 22,
"Be": 20,
"Xanh lam": 18,
"Đỏ": 15,
"Hồng": 12,
"Xám": 8,
"Tím": 12,
"Xanh Jeans": 20,
"Xanh than": 15
},
"Xanh navy": {
"Trắng": 28,
"Xám": 22,
"Be": 20,
"Đỏ": 18,
"Cam": 15,
"Xanh lam": 10,
"Xanh navy": 8,
"Xanh Jeans": 15,
"Xanh than": 12
},
"Xanh lam": {
"Trắng": 25,
"Be": 20,
"Xám": 18,
"Đen": 18,
"Nâu": 12,
"Xanh lam": 8,
"Xanh Jeans": 12,
"Xanh than": 15
},
"Xanh lá": {
"Trắng": 20,
"Be": 22,
"Nâu": 20,
"Đen": 18,
"Xám": 12,
"Xanh lá": 8,
"Xanh than": 10
},
"Xanh Jeans": {
"Trắng": 28,
"Đen": 22,
"Xám": 20,
"Be": 18,
"Hồng": 15,
"Đỏ": 12,
"Cam": 10,
"Xanh Jeans": 8
},
"Xanh than": {
"Trắng": 25,
"Đen": 20,
"Be": 18,
"Xám": 15,
"Hồng": 12,
"Xanh than": 8
},
"Đỏ": {
"Trắng": 28,
"Đen": 22,
"Xanh navy": 18,
"Xám": 15,
"Be": 12,
"Đỏ": 5
},
"Nâu": {
"Be": 28,
"Trắng": 22,
"Xanh lá": 20,
"Xám": 18,
"Đen": 15,
"Nâu": 8
},
"Hồng": {
"Trắng": 25,
"Đen": 18,
"Xám": 15,
"Be": 18,
"Xanh navy": 12,
"Xanh Jeans": 15,
"Hồng": 5
},
"Vàng": {
"Trắng": 20,
"Đen": 18,
"Xanh navy": 15,
"Xám": 12,
"Vàng": 5
},
"Cam": {
"Trắng": 20,
"Đen": 18,
"Xanh navy": 15,
"Xám": 12,
"Cam": 5
},
"Tím": {
"Trắng": 25,
"Đen": 18,
"Xám": 15,
"Be": 15,
"Hồng": 10,
"Tím": 5
}
},
"default_color_score": 12,
"_comment_style": "Ma trận phong cách (0-25). Match từ tags/phong_cach trong description_data.",
"_comment_style": "Ma trận phong cách (0-25). Match từ tags/phong_cach trong description_data. Bao phủ toàn bộ styles DB.",
"style_compat": {
"Basic": {"Basic": 25, "Minimalist": 22, "Smart Casual": 20, "Casual": 18, "Streetwear": 10, "Formal": 8, "Sport": 5},
"Minimalist": {"Minimalist": 25, "Basic": 22, "Smart Casual": 20, "Formal": 15, "Casual": 12},
"Smart Casual": {"Smart Casual": 25, "Basic": 20, "Formal": 18, "Minimalist": 18, "Casual": 12, "Streetwear": 8},
"Formal": {"Formal": 25, "Smart Casual": 18, "Minimalist": 15, "Basic": 10},
"Casual": {"Casual": 25, "Basic": 18, "Streetwear": 15, "Smart Casual": 12, "Sport": 10},
"Streetwear": {"Streetwear": 25, "Casual": 18, "Basic": 12, "Sport": 15},
"Sport": {"Sport": 25, "Casual": 15, "Streetwear": 12},
"Boho": {"Boho": 25, "Casual": 18, "Minimalist": 12},
"Vintage": {"Vintage": 25, "Casual": 15, "Streetwear": 12}
"Basic": {
"Basic": 25,
"Minimalist": 22,
"Smart Casual": 20,
"Casual": 18,
"Essential": 20,
"Basic Update": 22,
"Streetwear": 10,
"Dynamic": 12,
"Feminine": 12,
"Formal": 8,
"Sport": 5
},
"Minimalist": {
"Minimalist": 25,
"Basic": 22,
"Smart Casual": 20,
"Essential": 20,
"Formal": 15,
"Casual": 12,
"Feminine": 12
},
"Smart Casual": {
"Smart Casual": 25,
"Basic": 20,
"Formal": 18,
"Minimalist": 18,
"Basic Update": 20,
"Casual": 12,
"Feminine": 15,
"Streetwear": 8
},
"Formal": {
"Formal": 25,
"Smart Casual": 18,
"Minimalist": 15,
"Basic": 10
},
"Casual": {
"Casual": 25,
"Basic": 18,
"Streetwear": 15,
"Smart Casual": 12,
"Dynamic": 12,
"Athleisure": 10,
"Sport": 10
},
"Streetwear": {
"Streetwear": 25,
"Casual": 18,
"Basic": 12,
"Sport": 15,
"Dynamic": 18,
"Trend": 20,
"Athleisure": 12
},
"Sport": {
"Sport": 25,
"Casual": 15,
"Streetwear": 12,
"Athleisure": 20,
"Dynamic": 15
},
"Boho": {
"Boho": 25,
"Casual": 18,
"Minimalist": 12,
"Feminine": 15
},
"Vintage": {
"Vintage": 25,
"Casual": 15,
"Streetwear": 12
},
"Dynamic": {
"Dynamic": 25,
"Basic": 18,
"Casual": 15,
"Sport": 15,
"Streetwear": 12,
"Athleisure": 18
},
"Feminine": {
"Feminine": 25,
"Basic": 18,
"Minimalist": 15,
"Smart Casual": 15,
"Casual": 12,
"Trend": 15
},
"Utility": {
"Utility": 25,
"Basic": 18,
"Streetwear": 15,
"Casual": 12,
"Dynamic": 12,
"Athleisure": 10
},
"Basic Update": {
"Basic Update": 25,
"Basic": 22,
"Smart Casual": 18,
"Casual": 15,
"Minimalist": 18
},
"Trend": {
"Trend": 25,
"Streetwear": 20,
"Casual": 15,
"Basic": 12,
"Feminine": 15,
"Dynamic": 12
},
"Athleisure": {
"Athleisure": 25,
"Sport": 20,
"Casual": 15,
"Basic": 10,
"Dynamic": 18,
"Streetwear": 12
},
"Essential": {
"Essential": 25,
"Basic": 22,
"Minimalist": 20,
"Smart Casual": 15
}
},
"default_style_score": 10,
"_comment_occasion_boost": "Styles được ưu tiên theo từng dịp (+5 bonus)",
"occasion_style_boost": {
"di_lam": ["Smart Casual", "Formal", "Minimalist", "Basic"],
"di_tiec": ["Formal", "Smart Casual", "Minimalist"],
"the_thao": ["Sport", "Casual", "Streetwear"],
"hang_ngay":["Casual", "Basic", "Streetwear", "Minimalist"],
"di_choi": ["Casual", "Streetwear", "Basic", "Boho"],
"mac_nha": ["Casual", "Basic"]
},
"di_lam": [
"Smart Casual",
"Modern Minimal",
"Preppy"
],
"di_tiec": [
"Formal",
"Smart Casual",
"Minimalist",
"Feminine",
"Trend"
],
"the_thao": [
"Sport",
"Casual",
"Streetwear",
"Athleisure",
"Dynamic"
],
"hang_ngay": [
"Casual",
"Basic",
"Streetwear",
"Minimalist",
"Essential",
"Basic Update"
],
"di_choi": [
"Street",
"Trend",
"Cute",
"Feminine",
"Dynamic"
],
"mac_nha": [
"Essential",
"Basic",
"Cute",
"SET"
],
"du_lich": [
"Athleisure",
"Utility",
"Basic Update",
"SET"
]
},
"_comment_weights": "Trọng số (tổng = 100). Thay đổi để tune theo bản 1.0.2.",
"score_weights": {
"color": 28,
......@@ -145,33 +599,164 @@
"material": 10,
"diversity": 8
},
"min_score": 35,
"version": "1.0.2",
"_comment_color_groups": "Phân loại nhóm màu theo Framework Excel",
"_comment_color_groups": "Phân loại nhóm màu theo Framework Excel (Neutral/Light/Dark)",
"color_groups": {
"Trắng": "neutral", "Đen": "neutral", "Xám": "neutral", "Be": "neutral", "Nâu": "neutral",
"Vàng": "light", "Hồng": "light", "Xanh lam": "light",
"Đỏ": "dark", "Cam": "dark", "Xanh lá": "dark", "Tím": "dark", "Xanh navy": "dark"
"Trắng": "neutral",
"Đen": "neutral",
"Xám": "neutral",
"Be": "neutral",
"Nâu": "neutral",
"Vàng": "light",
"Hồng": "light",
"Xanh lam": "light",
"Tím": "light",
"Đỏ": "dark",
"Cam": "dark",
"Xanh lá": "dark",
"Xanh navy": "dark",
"Xanh Jeans": "dark",
"Xanh than": "dark"
},
"_comment_color_group_matrix": "Ma trận điểm synergy giữa các nhóm màu (0-30)",
"color_group_matrix": {
"neutral": {"neutral": 30, "dark": 25, "light": 22},
"light": {"neutral": 22, "light": 15, "dark": 8},
"dark": {"neutral": 20, "light": 8, "dark": 10}
"neutral": {
"neutral": 30,
"dark": 25,
"light": 22
},
"light": {
"neutral": 22,
"light": 15,
"dark": 8
},
"dark": {
"neutral": 20,
"light": 8,
"dark": 10
}
},
"_comment_material_season": "Ánh xạ chất liệu vải phù hợp với các mùa (căn cứ theo catalog)",
"material_season_compat": {
"cotton": ["summer", "spring", "autumn", "mùa hè", "mùa xuân", "mùa thu", "xuân hè"],
"linen": ["summer", "mùa hè", "xuân hè"],
"thun": ["summer", "spring", "autumn", "winter", "mùa hè", "mùa đông", "xuân hè", "thu đông"],
"nỉ": ["winter", "autumn", "mùa đông", "mùa thu", "thu đông"],
"len": ["winter", "mùa đông", "thu đông"],
"chần bông": ["winter", "mùa đông", "thu đông"],
"gió": ["autumn", "spring", "mùa thu", "mùa xuân", "thu đông"],
"kaki": ["spring", "autumn", "summer", "mùa xuân", "mùa thu", "mùa hè"]
"cotton": [
"summer",
"spring",
"autumn",
"mùa hè",
"mùa xuân",
"mùa thu",
"xuân hè"
],
"linen": [
"summer",
"mùa hè",
"xuân hè"
],
"thun": [
"summer",
"spring",
"autumn",
"winter",
"mùa hè",
"mùa đông",
"xuân hè",
"thu đông"
],
"nỉ": [
"winter",
"autumn",
"mùa đông",
"mùa thu",
"thu đông"
],
"len": [
"winter",
"mùa đông",
"thu đông"
],
"chần bông": [
"winter",
"mùa đông",
"thu đông"
],
"gió": [
"autumn",
"spring",
"mùa thu",
"mùa xuân",
"thu đông"
],
"kaki": [
"spring",
"autumn",
"summer",
"mùa xuân",
"mùa thu",
"mùa hè"
]
},
"demographic_color_logic": {
"nu": {
"name": "Nữ",
"desc": "Linh hoạt nhất, sử dụng tốt cả 3 nhóm Neutral, Light, Dark",
"boosts": []
},
"nam": {
"name": "Nam",
"desc": "Ưu tiên nhóm Neutral",
"boosts": [
{
"group": "neutral",
"points": 5
}
],
"penalties": [
{
"group": "light",
"points": -5
}
]
},
"be_gai": {
"name": "Bé gái",
"desc": "Ưu tiên nhóm Light (Pastel) và các màu Nổi (Magenta, Tím)",
"boosts": [
{
"group": "light",
"points": 5
}
]
},
"be_trai": {
"name": "Bé trai",
"desc": "Nghiêng về nhóm Dark",
"boosts": [
{
"group": "dark",
"points": 5
}
],
"penalties": [
{
"group": "light",
"points": -3
}
]
}
},
"formulas": {
"neutral_neutral": {
"name": "Công thức Phối: \"An Toàn\"",
"tip": "MẸO: Quy tắc 3 màu - Không diện quá 3 màu trên một bộ. Nếu mặc nguyên set trung tính, ưu tiên thêm chút bóng của ánh kim (Gold/Silver)."
},
"neutral_light": {
"name": "Công thức Phối: \"Điểm Nhấn\"",
"tip": "MẸO: Lấy màu Nhạt/Sáng làm trọng tâm, dùng màu Trung Tính (Trắng/Đen/Be) để tiết chế độ nhòa của áo."
},
"neutral_dark": {
"name": "Công thức Phối: \"Tương Phản Mạnh\"",
"tip": "MẸO: Lấy tone Trầm tĩnh làm nền để kích hoạt tối đa độ rực của màu Nổi bật!"
}
}
}
\ No newline at end of file
......@@ -167,6 +167,185 @@ class StylistEngine:
return result
def compute_dynamic_rule_matches(self, code: str) -> dict:
"""Dynamically compute matches based on chatbot_fashion_rules (Postgres) and _score()."""
catalog = self._get_catalog()
source = next((p for p in catalog if p["code"] == code), None)
if not source:
logger.warning("[Stylist] Product not found in catalog: %s", code)
return {}
anchor_cat = source.get("product_line", "")
# 1. Fetch Rules from DB
db_rules = []
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
"SELECT occasion_tag, match_role, target_category, ai_reason FROM chatbot_fashion_rules WHERE UPPER(anchor_category) = UPPER(%s)",
(anchor_cat,)
)
for row in cur.fetchall():
db_rules.append({
"occ": row[0],
"role": row[1],
"target_cat": row[2],
"ai_reason": row[3],
})
cur.close()
except Exception as e:
logger.error("[Stylist] DB rule fetch error: %s", e)
finally:
if conn:
conn.close()
# If no DB rules, fallback to full logic
if not db_rules:
logger.info("[Stylist] No DB rules for %s, falling back to full compute", anchor_cat)
return self._compute_matches(source, catalog)
max_items = self.rules.get("role_max_items", {})
buckets: dict[str, dict[str, list[dict]]] = {}
for r in db_rules:
if r["occ"] not in buckets:
buckets[r["occ"]] = {}
if r["role"] not in buckets[r["occ"]]:
buckets[r["occ"]][r["role"]] = []
# Filter + Score
for target in catalog:
if target["code"] == source["code"]:
continue
if not self._pass_hard_filter(source, target):
continue
pl = target.get("product_line", "").lower()
if not pl:
continue
for r in db_rules:
if pl == r["target_cat"].lower():
occ = r["occ"]
role = r["role"]
breakdown = self._score_breakdown(source, target, occ)
total_score = sum(v["score"] for v in breakdown.values())
if total_score < self.rules.get("min_score", 35):
continue
db_reason = r["ai_reason"] or f"Theo định hướng ({occ})"
algo_reason = self._build_reason(source, target, occ, total_score)
combined_reason = f"{algo_reason} | {db_reason}"
buckets[occ][role].append({
"code": target["code"],
"name": target["name"],
"color": target.get("color", ""),
"product_line": target.get("product_line", ""),
"image": target.get("image", ""),
"score": total_score,
"reason": combined_reason,
"breakdown": breakdown,
})
# Sort and Deduplicate
result: dict[str, dict] = {}
for occ, occ_data in buckets.items():
final_occ_data = {}
for role, items in occ_data.items():
top = sorted(items, key=lambda x: -x["score"])
top = self._deduplicate(top, max_items.get(role, 3))
if top:
final_occ_data[role] = top
if final_occ_data:
result[occ] = final_occ_data
return result
def compute_super_classifications_sql(self, code: str) -> dict:
"""
Dynamically fetches top matches directly from DB using custom SQL queries
based on the super classification matrix (Color, Occasion, Material).
"""
catalog = self._get_catalog()
source = next((p for p in catalog if p["code"] == code), None)
if not source:
logger.warning("[Stylist] Product not found for SQL grouping: %s", code)
return {}
gender = source.get("gender", "")
if not gender or gender.lower() == "unisex":
gender = "women" # Default fallback for classification demo
# Hardcode top target categories for simplification in sandbox
# Ideally, this should pull from PG chatbot_fashion_rules based on source category
target_cats = ["sơ mi", "chân váy", "quần âu", "quần jean", "áo phông", "quần soóc"]
like_cond = " OR ".join([f"LOWER(product_line_vn) LIKE '%{c}%'" for c in target_cats])
sr_db = get_db_connection()
SR_TABLE = "shared_source.magento_product_dimension_with_text_embedding"
# Color Group Queries
color_groups = {
"neutral": "%trắng%' OR LOWER(master_color) LIKE '%white%' OR LOWER(master_color) LIKE '%đen%' OR LOWER(master_color) LIKE '%black%' OR LOWER(master_color) LIKE '%xám%' OR LOWER(master_color) LIKE '%grey%' OR LOWER(master_color) LIKE '%be%' OR LOWER(master_color) LIKE '%beige%",
"light": "%vàng%' OR LOWER(master_color) LIKE '%hồng%' OR LOWER(master_color) LIKE '%xanh da trời%",
"dark": "%đỏ%' OR LOWER(master_color) LIKE '%cam%' OR LOWER(master_color) LIKE '%xanh lá cây%' OR LOWER(master_color) LIKE '%xanh jeans%' OR LOWER(master_color) LIKE '%xanh than%"
}
# Material Season Queries
mat_groups = {
"summer": "%cotton%' OR LOWER(description_text_full) LIKE '%linen%' OR LOWER(description_text_full) LIKE '%mát%' OR LOWER(description_text_full) LIKE '%%",
"winter": "%nỉ%' OR LOWER(description_text_full) LIKE '%len%' OR LOWER(description_text_full) LIKE '%phao%' OR LOWER(description_text_full) LIKE '%chần bông%' OR LOWER(description_text_full) LIKE '%đông%"
}
# Occasion Queries
# (Assuming Đi làm: sơ mi/quần âu/váy; Đi chơi: váy/áo phông/soóc)
occ_groups = {
"di_lam": "%sơ mi%' OR LOWER(product_line_vn) LIKE '%chân váy%' OR LOWER(product_line_vn) LIKE '%quần âu%",
"di_choi": "%váy%' OR LOWER(product_line_vn) LIKE '%áo phông%' OR LOWER(product_line_vn) LIKE '%quần soóc%",
"o_nha": "%bộ mặc nhà%' OR LOWER(product_line_vn) LIKE '%quần lót%' OR LOWER(product_line_vn) LIKE '%áo lót%"
}
results = {"color": {}, "occasion": {}, "material": {}}
# Helper inner function to run SQL
def fetch_top_sql(where_sql_extra: str, limit=5):
query = f"""
SELECT magento_ref_code, product_name, master_color, product_line_vn, product_image_url_thumbnail
FROM {SR_TABLE}
WHERE LOWER(gender_by_product) = '{gender.lower()}' AND ({where_sql_extra})
ORDER BY is_new_product DESC, quantity_sold DESC
LIMIT {limit}
"""
try:
res = sr_db.execute_query(query)
out = []
for r in res:
out.append({
"code": r["magento_ref_code"],
"name": r["product_name"] or "",
"color": r["master_color"] or "",
"product_line": r["product_line_vn"] or "",
"image": r["product_image_url_thumbnail"] or ""
})
return out
except Exception as e:
logger.error("[Stylist] Dynamic SQL failed: %s", e)
return []
# 1. Fetch Color
for k, cond in color_groups.items():
results["color"][k] = fetch_top_sql(f"({like_cond}) AND (LOWER(master_color) LIKE '{cond}')")
# 2. Fetch Occasion
for k, cond in occ_groups.items():
results["occasion"][k] = fetch_top_sql(f"LOWER(product_line_vn) LIKE '{cond}'")
# 3. Fetch Material
for k, cond in mat_groups.items():
results["material"][k] = fetch_top_sql(f"({like_cond}) AND (LOWER(description_text_full) LIKE '{cond}')")
return results
# ──────────────────────────────────────────
# Scoring Sub-functions
# ──────────────────────────────────────────
......@@ -209,7 +388,7 @@ class StylistEngine:
total = 0
# 1. Color score (0-30) - Adjusted to use new max from weights
color_score = self._color_score(src.get("color", ""), tgt.get("color", ""))
color_score = self._color_score(src.get("color", ""), tgt.get("color", ""), tgt.get("gender", ""))
total += int(color_score * weights["color"] / 30)
# 2. Style score (0-25)
......@@ -241,8 +420,9 @@ class StylistEngine:
return min(total, 100)
def _color_score(self, src_color: str, tgt_color: str) -> int:
"""Score color compatibility using group matrix (0-30), fallback to exact key matrix."""
def _color_score(self, src_color: str, tgt_color: str, gender: str = "") -> int:
"""Score color compatibility using group matrix (0-30), fallback to exact key matrix.
Applies logic boosting/penalizing based on demographic."""
rules = self.rules
color_keys = rules["color_keys"]
......@@ -254,18 +434,48 @@ class StylistEngine:
src_group = color_groups.get(src_key) if src_key else None
tgt_group = color_groups.get(tgt_key) if tgt_key else None
base_score = rules.get("default_color_score", 12)
# Primary logic: Framework Excel styling (Neutral vs Light vs Dark)
if src_group and tgt_group:
group_matrix = rules.get("color_group_matrix", {})
return group_matrix.get(src_group, {}).get(tgt_group, rules.get("default_color_score", 12))
base_score = group_matrix.get(src_group, {}).get(tgt_group, base_score)
# Fallback: specific color matching (matrix legacy)
if src_key and tgt_key:
elif src_key and tgt_key:
matrix = rules["color_matrix"]
row = matrix.get(src_key, {})
return row.get(tgt_key, rules.get("default_color_score", 12))
return rules.get("default_color_score", 12)
base_score = row.get(tgt_key, base_score)
# Demographic specific color boost
demo_logic = rules.get("demographic_color_logic", {})
# Determine demographic key based on gender
gl = gender.lower()
demo_key = "unisex_nguoi_lon"
if gl in ["nu", "nữ", "women", "female"]:
demo_key = "nu"
elif gl in ["nam", "men", "male"]:
demo_key = "nam"
elif any(k in gl for k in ["bé gái", "be_gai", "girl"]):
demo_key = "be_gai"
elif any(k in gl for k in ["bé trai", "be_trai", "boy"]):
demo_key = "be_trai"
elif any(k in gl for k in ["bé", "kid"]):
demo_key = "unisex_tre_em"
demo_rules = demo_logic.get(demo_key, {})
# Apply boosts
for b in demo_rules.get("boosts", []):
if tgt_group == b["group"]:
base_score += b["points"]
# Apply penalties
for p in demo_rules.get("penalties", []):
if tgt_group == p["group"]:
base_score += p["points"]
return min(base_score, 30)
def _detect_color_key(self, color_str: str, color_keys: dict) -> Optional[str]:
"""Map a master_color string to a canonical color key."""
......@@ -504,17 +714,17 @@ class StylistEngine:
"""Alias for _get_catalog (used by score-test API)."""
return self._get_catalog()
def _score_breakdown(self, src: dict, tgt: dict) -> dict:
def _score_breakdown(self, src: dict, tgt: dict, occ_target: str = None) -> dict:
"""Return per-criterion score breakdown for a source+target pair."""
rules = self.rules
weights = rules["score_weights"]
# Color
raw_color = self._color_score(src.get("color", ""), tgt.get("color", ""))
raw_color = self._color_score(src.get("color", ""), tgt.get("color", ""), tgt.get("gender", ""))
color_pts = int(raw_color * weights["color"] / 30)
# Style (use first occasion as context)
occ = (rules.get("occasions") or ["hang_ngay"])[0]
# Style (use first occasion as context or occ_target)
occ = occ_target or (rules.get("occasions") or ["hang_ngay"])[0]
raw_style = self._style_score(src.get("style_tags", []), tgt.get("style_tags", []), occ)
style_pts = int(raw_style * weights["style"] / 25)
......@@ -542,10 +752,61 @@ class StylistEngine:
sg = cgroups.get(sc, "?") if sc else "?"
tg = cgroups.get(tc, "?") if tc else "?"
# Excel Formulas determination
formulas = rules.get("formulas", {})
cong_thuc_name = ""
cong_thuc_tip = ""
if sg == "neutral" and tg == "neutral":
fm = formulas.get("neutral_neutral", {})
cong_thuc_name = fm.get("name", 'Công thức [An Toàn]')
cong_thuc_tip = fm.get("tip", "")
elif (sg == "neutral" and tg in ("light", "dark")) or (tg == "neutral" and sg in ("light", "dark")):
fm = formulas.get("neutral_light", {})
if (sg == "dark" or tg == "dark"):
fm = formulas.get("neutral_dark", fm)
cong_thuc_name = fm.get("name", 'Công thức [Điểm Nhấn]')
cong_thuc_tip = fm.get("tip", "")
elif (sg == "dark" and tg == "dark"):
cong_thuc_name = 'Công thức [Tone-Sur-Tone Trầm]'
cong_thuc_tip = "MẸO: Tổng thể sẽ bí ẩn và có chiều sâu, nên pick phụ kiện kim loại."
elif (sg == "light" and tg == "light"):
cong_thuc_name = 'Công thức [Tone-Sur-Tone Nhạt]'
cong_thuc_tip = "MẸO: Nên phối với giày độn đế Trắng hoặc phụ kiện sáng để không bị nhòa."
# Mùa / Chất liệu
tgt_mat_raw = " ".join(tgt.get("material_tags", [])) + " " + tgt.get("name_origin", "")
tgt_mat_raw = tgt_mat_raw.lower()
tgt_mat_season = "Không rõ mùa / dòng"
if any(w in tgt_mat_raw for w in ["cotton", "linen", "hè"]):
tgt_mat_season = "Hợp mùa hè (thoáng/mát)"
elif any(w in tgt_mat_raw for w in ["nỉ", "len", "chần bông", "dạ", "phao", "đông"]):
tgt_mat_season = "Hợp mùa đông (giữ nhiệt)"
elif any(w in tgt_mat_raw for w in ["gió", "kaki", "jean", "denim", "thun"]):
tgt_mat_season = "Linh hoạt cả Xuân/Thu"
tgt_materials = tgt.get("material_tags", [])
tgt_styles = tgt.get("style_tags", [])
# Color breakdown with Formula tip injected
color_reason_text = ""
group_lbl = {
"neutral": "Trắng/Đen/Trung tính",
"light": "Nhạt/Pastel",
"dark": "Đậm/Nổi"
}
if cong_thuc_name:
color_reason_text = f"🪧 {cong_thuc_name}: {group_lbl.get(sg, sg)} ➔ {group_lbl.get(tg, tg)}. {cong_thuc_tip}"
else:
color_reason_text = f"Màu {sc} đi với {tc} khá hài hòa."
color_reason_text += f" [Hệ {tgt.get('gender', 'Unisex')}]"
return {
"color": {"score": color_pts, "max": weights["color"], "reason": f"[{sg}] {sc or '?'} → [{tg}] {tc or '?'} (raw={raw_color})"},
"style": {"score": style_pts, "max": weights["style"], "reason": f"style compat raw={raw_style}"},
"occasion": {"score": occ_pts, "max": weights["occasion"], "reason": f"occasion overlap raw={raw_occ}"},
"role": {"score": role_pts, "max": weights["role"], "reason": f"role={role}, priority={role_priority.get(role,3)}"},
"material": {"score": material_pts, "max": weights.get("material", 10), "reason": f"material season align raw={raw_material}"},
"🎨 Mix Màu": {"score": color_pts, "max": weights["color"], "reason": color_reason_text},
"👔 Style": {"score": style_pts, "max": weights["style"], "reason": f"Tone đúng điệu {', '.join(tgt_styles[:2])}"},
"🛍️ Dịp Mặc": {"score": occ_pts, "max": weights["occasion"], "reason": f"Chuẩn bài {rules.get('occasion_labels', {}).get(occ, occ)}"},
"🧵 Dòng Hàng/Mùa": {"score": material_pts, "max": weights.get("material", 10), "reason": f"{tgt_mat_season} (Vải {', '.join(tgt_materials[:2]) if tgt_materials else 'chính hãng'})"},
"🎯 Nhóm SP": {"score": role_pts, "max": weights["role"], "reason": f"Dáng {tgt.get('product_line', 'Tiêu chuẩn')} tối ưu tỷ lệ"}
}
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