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

feat(fashion-matches): add simulator inspector and tag audit dashboard

parent a6ec88fc
......@@ -19,9 +19,8 @@ Endpoints:
import json
import logging
import os
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Request
from fastapi import APIRouter, BackgroundTasks, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
......@@ -45,7 +44,7 @@ class UpdateRulesRequest(BaseModel):
class OutfitSuggestRequest(BaseModel):
code: str
occasion: Optional[str] = None
occasion: str | None = None
class ColorLogicRequest(BaseModel):
......@@ -54,7 +53,7 @@ class ColorLogicRequest(BaseModel):
# ─── Helpers ─────────────────────────────────────────────────
def _get_ai_matches(code: str) -> Optional[dict]:
def _get_ai_matches(code: str) -> dict | None:
conn = None
try:
conn = get_pooled_connection_compat()
......@@ -104,9 +103,10 @@ def _save_ai_matches(code: str, ai_matches: dict) -> bool:
conn.close()
def _run_engine_background(code: Optional[str] = None):
def _run_engine_background(code: str | None = None):
try:
from worker.stylist_engine import StylistEngine
engine = StylistEngine()
if code:
engine.run_for_code(code)
......@@ -117,11 +117,11 @@ def _run_engine_background(code: Optional[str] = None):
def _load_rules() -> dict:
with open(RULES_PATH, "r", encoding="utf-8") as f:
with open(RULES_PATH, encoding="utf-8") as f:
return json.load(f)
def _detect_color_key(color_str: str, color_keys: dict) -> Optional[str]:
def _detect_color_key(color_str: str, color_keys: dict) -> str | None:
c = color_str.lower()
for key, variants in color_keys.items():
if any(v in c for v in variants):
......@@ -129,7 +129,7 @@ def _detect_color_key(color_str: str, color_keys: dict) -> Optional[str]:
return None
def _color_group_explain(src_key: Optional[str], tgt_key: Optional[str], rules: dict) -> dict:
def _color_group_explain(src_key: str | None, tgt_key: str | None, rules: dict) -> dict:
color_groups = rules.get("color_groups", {})
group_matrix = rules.get("color_group_matrix", {})
color_matrix = rules.get("color_matrix", {})
......@@ -142,9 +142,12 @@ def _color_group_explain(src_key: Optional[str], tgt_key: Optional[str], rules:
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,
"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,
}
......@@ -152,23 +155,29 @@ def _color_group_explain(src_key: Optional[str], tgt_key: Optional[str], rules:
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,
"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,
"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:
def _color_advice(src_group: str, tgt_group: str, src_key: str | None, tgt_key: str | None) -> 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."
......@@ -181,11 +190,13 @@ def _color_advice(src_group: str, tgt_group: str, src_key: Optional[str], tgt_ke
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"⚠️ 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:
def _build_color_strategy(src_key: str | None, src_group: str | None, rules: dict) -> dict:
group_matrix = rules.get("color_group_matrix", {})
color_groups_map = rules.get("color_groups", {})
......@@ -250,15 +261,17 @@ def _build_color_strategy(src_key: Optional[str], src_group: Optional[str], rule
return {"summary": summary, "strategies": strategies}
def _get_colors_by_group(group: str, color_groups_map: dict, exclude: Optional[str] = None) -> list:
def _get_colors_by_group(group: str, color_groups_map: dict, exclude: str | None = 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)
......@@ -281,10 +294,12 @@ async def update_fashion_matches(code: str, req: UpdateMatchesRequest):
@router.post("/{code}/regen")
async def regen_fashion_matches(code: str):
import asyncio
await asyncio.to_thread(_run_engine_background, code)
logger.info("[FashionMatches] Regen finished: %s", code)
return {"ok": True, "message": f"Đã tính toán phối đồ cho {code}"}
@router.post("/batch-regen")
async def batch_regen_fashion_matches(request: Request):
data = await request.json()
......@@ -294,6 +309,7 @@ async def batch_regen_fashion_matches(request: Request):
def _run_multiple():
from worker.stylist_engine import StylistEngine
engine = StylistEngine()
for c in codes:
try:
......@@ -302,6 +318,7 @@ async def batch_regen_fashion_matches(request: Request):
pass
import asyncio
await asyncio.to_thread(_run_multiple)
return {"ok": True, "message": f"Đã xong {len(codes)} sp"}
......@@ -309,6 +326,7 @@ async def batch_regen_fashion_matches(request: Request):
@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)
......@@ -318,6 +336,7 @@ async def batch_fashion_matches(background_tasks: BackgroundTasks):
@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:
......@@ -350,33 +369,41 @@ 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",
"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,
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()
],
"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", "?"),
},
......@@ -405,11 +432,14 @@ async def outfit_suggest(req: OutfitSuggestRequest):
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)
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(
......@@ -448,12 +478,16 @@ async def outfit_suggest(req: OutfitSuggestRequest):
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({
enriched.append(
{
**item,
"color_key": item_color_key,
"color_group": rules.get("color_groups", {}).get(item_color_key, "?") if item_color_key else "?",
"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] = {
......@@ -483,7 +517,7 @@ async def outfit_suggest(req: OutfitSuggestRequest):
},
"color_strategy": color_strategy,
"outfit_by_occasion": outfit_by_occasion,
"classifications": classifications
"classifications": classifications,
}
except Exception as e:
......@@ -500,6 +534,7 @@ class ScoreTestRequest(BaseModel):
async def score_test(req: ScoreTestRequest):
try:
from worker.stylist_engine import StylistEngine
engine = StylistEngine()
catalog = engine._load_catalog()
......@@ -533,11 +568,188 @@ async def score_test(req: ScoreTestRequest):
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@router.get("/audit/tag-coverage")
async def audit_tag_coverage(limit: int = Query(300, ge=20, le=2000), q: str = ""):
"""Audit product tags vs rules coverage for data QA."""
try:
from worker.stylist_engine import StylistEngine
engine = StylistEngine()
catalog = engine._get_catalog()
q_lower = (q or "").strip().lower()
if q_lower:
catalog = [
p
for p in catalog
if q_lower in (p.get("code") or "").lower()
or q_lower in (p.get("name") or "").lower()
or q_lower in (p.get("product_line") or "").lower()
]
sample_catalog = catalog[:limit]
# Cache rule lookup per anchor category + normalized gender to avoid repeated DB calls.
rule_cache: dict[tuple[str, str], dict] = {}
def resolve_rule_status(anchor_cat: str, gender_raw: str) -> dict:
gender_key = engine._normalize_gender(gender_raw)
cache_key = (anchor_cat or "", gender_key)
if cache_key in rule_cache:
return rule_cache[cache_key]
db_rules = engine._fetch_rules_with_reason(anchor_cat, gender_raw)
fallback = engine._get_fallback_mappings(anchor_cat)
status = {
"db_rules_count": len(db_rules),
"fallback_occasion_count": len(fallback),
"status": "none",
}
if status["db_rules_count"] > 0:
status["status"] = "db"
elif status["fallback_occasion_count"] > 0:
status["status"] = "fallback"
rule_cache[cache_key] = status
return status
product_line_stats: dict[str, dict] = {}
missing_by_sku: list[dict] = []
for item in sample_catalog:
code = item.get("code") or ""
name = item.get("name") or ""
product_line = item.get("product_line") or ""
gender = item.get("gender") or ""
role = engine._get_role(item)
rule_status = resolve_rule_status(product_line, gender)
stat = product_line_stats.setdefault(
product_line,
{
"product_line": product_line,
"total_skus": 0,
"db_covered": 0,
"fallback_covered": 0,
"rule_missing": 0,
"role_missing": 0,
},
)
stat["total_skus"] += 1
if rule_status["status"] == "db":
stat["db_covered"] += 1
elif rule_status["status"] == "fallback":
stat["fallback_covered"] += 1
else:
stat["rule_missing"] += 1
if not role:
stat["role_missing"] += 1
missing_tags = []
if not product_line:
missing_tags.append("product_line")
if not role:
missing_tags.append("role_mapping")
if not (item.get("color") or "").strip():
missing_tags.append("color")
if not (item.get("gender") or "").strip():
missing_tags.append("gender")
if not item.get("material_tags"):
missing_tags.append("material_tags")
if not item.get("occasion_tags"):
missing_tags.append("occasion_tags")
if not item.get("season_tags"):
missing_tags.append("season_tags")
if rule_status["status"] == "none":
missing_tags.append("rule_coverage")
if missing_tags:
missing_by_sku.append(
{
"code": code,
"name": name,
"product_line": product_line,
"gender": gender,
"rule_status": rule_status["status"],
"missing_tags": missing_tags,
}
)
product_lines = sorted(
product_line_stats.values(), key=lambda x: (-x["rule_missing"], -x["role_missing"], x["product_line"])
)
product_line_unmatched = [p for p in product_lines if p["rule_missing"] > 0 or p["role_missing"] > 0]
checklist = [
{
"key": "product_line_to_role",
"label": "Bổ sung mapping product_line_to_role cho product_line thiếu role",
"affected_count": sum(1 for p in product_lines if p["role_missing"] > 0),
},
{
"key": "chatbot_fashion_rules",
"label": "Bổ sung rule DB cho anchor_category chưa phủ",
"affected_count": sum(1 for p in product_lines if p["rule_missing"] > 0),
},
{
"key": "description_data.material",
"label": "Bổ sung vat_lieu để tạo material_tags",
"affected_count": sum(1 for x in missing_by_sku if "material_tags" in x["missing_tags"]),
},
{
"key": "description_data.occasion",
"label": "Bổ sung dip_mac để tạo occasion_tags",
"affected_count": sum(1 for x in missing_by_sku if "occasion_tags" in x["missing_tags"]),
},
{
"key": "description_data.season",
"label": "Bổ sung mua để tạo season_tags",
"affected_count": sum(1 for x in missing_by_sku if "season_tags" in x["missing_tags"]),
},
]
summary = {
"sample_size": len(sample_catalog),
"total_after_filter": len(catalog),
"product_line_count": len(product_lines),
"product_line_unmatched_count": len(product_line_unmatched),
"sku_with_missing_tags": len(missing_by_sku),
"db_rule_covered_skus": sum(
1
for x in sample_catalog
if resolve_rule_status(x.get("product_line") or "", x.get("gender") or "")["status"] == "db"
),
"fallback_rule_skus": sum(
1
for x in sample_catalog
if resolve_rule_status(x.get("product_line") or "", x.get("gender") or "")["status"] == "fallback"
),
"no_rule_skus": sum(
1
for x in sample_catalog
if resolve_rule_status(x.get("product_line") or "", x.get("gender") or "")["status"] == "none"
),
}
return {
"ok": True,
"summary": summary,
"product_line_unmatched": product_line_unmatched,
"sku_missing_tags": missing_by_sku,
"checklist": checklist,
}
except Exception as e:
logger.error("[TagAudit] error: %s", e)
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
# --- Rules Framework HTML View ---
@router.get("/rules/view")
async def rules_view():
from fastapi.responses import HTMLResponse
from collections import defaultdict
from fastapi.responses import HTMLResponse
rows = []
conn = None
try:
......@@ -557,29 +769,69 @@ async def rules_view():
except Exception as e:
logger.error("[RulesView] DB error: %s", e)
finally:
if conn: conn.close()
if conn:
conn.close()
grouped = defaultdict(lambda: defaultdict(list))
for gender, anchor, occ, role, target, reason in rows:
grouped[gender][occ].append({"anchor": anchor, "role": role, "target": target, "reason": reason or ""})
GENDER_META = {
"nu": {"label": "NGA Nu", "color": "#ec4899", "bg": "#fdf2f8", "group": "Neutral+Light+Dark", "colors": "Trang, Hong, Be, Tim, Xanh lam, Den"},
"nam": {"label": "NAM Nam", "color": "#3b82f6", "bg": "#eff6ff", "group": "Neutral (+5 boost)", "colors": "Be, Xam, Den, Trang, Nau, Xanh than"},
"unisex": {"label": "UNI Unisex","color": "#8b5cf6","bg": "#f5f3ff","group": "Neutral+Dark", "colors": "Den, Trang, Xam, Do, Xanh navy, Xanh Jeans"},
"be_gai": {"label": "BG Be Gai","color": "#f43f5e","bg": "#fff1f2","group": "Light Pastel (+5 boost)","colors": "Hong, Tim, Vang nhat, Xanh lam, Trang"},
"be_trai": {"label": "BT Be Trai","color": "#f59e0b","bg": "#fffbeb","group": "Dark (+5 boost)", "colors": "Vang, Cam, Xanh Jeans, Xanh navy, Do"},
"nu": {
"label": "NGA Nu",
"color": "#ec4899",
"bg": "#fdf2f8",
"group": "Neutral+Light+Dark",
"colors": "Trang, Hong, Be, Tim, Xanh lam, Den",
},
"nam": {
"label": "NAM Nam",
"color": "#3b82f6",
"bg": "#eff6ff",
"group": "Neutral (+5 boost)",
"colors": "Be, Xam, Den, Trang, Nau, Xanh than",
},
"unisex": {
"label": "UNI Unisex",
"color": "#8b5cf6",
"bg": "#f5f3ff",
"group": "Neutral+Dark",
"colors": "Den, Trang, Xam, Do, Xanh navy, Xanh Jeans",
},
"be_gai": {
"label": "BG Be Gai",
"color": "#f43f5e",
"bg": "#fff1f2",
"group": "Light Pastel (+5 boost)",
"colors": "Hong, Tim, Vang nhat, Xanh lam, Trang",
},
"be_trai": {
"label": "BT Be Trai",
"color": "#f59e0b",
"bg": "#fffbeb",
"group": "Dark (+5 boost)",
"colors": "Vang, Cam, Xanh Jeans, Xanh navy, Do",
},
}
OCC_LABELS = {"hang_ngay":"Hang ngay","di_lam":"Di lam","di_choi":"Di choi","du_lich":"Du lich","the_thao":"The thao","mac_nha":"Mac nha"}
ROLE_ICONS = {"top":"TOP","bottom":"BTM","outerwear":"OTR","accessory":"ACC"}
OCC_LABELS = {
"hang_ngay": "Hang ngay",
"di_lam": "Di lam",
"di_choi": "Di choi",
"du_lich": "Du lich",
"the_thao": "The thao",
"mac_nha": "Mac nha",
}
ROLE_ICONS = {"top": "TOP", "bottom": "BTM", "outerwear": "OTR", "accessory": "ACC"}
sections = ""
for gk, meta in GENDER_META.items():
occ_data = grouped.get(gk, {})
if not occ_data: continue
if not occ_data:
continue
rows_html = ""
for occ, rlist in occ_data.items():
items = "".join(
f'<span class="ri" title="{r["reason"]}">{r["anchor"]} &rarr; {r["target"]} <em>({ROLE_ICONS.get(r["role"],r["role"])})</em></span>'
for r in rlist)
rows_html += f'<tr><td class="oc"><b>{OCC_LABELS.get(occ,occ)}</b></td><td class="rc">{items}</td></tr>'
f'<span class="ri" title="{r["reason"]}">{r["anchor"]} &rarr; {r["target"]} <em>({ROLE_ICONS.get(r["role"], r["role"])})</em></span>'
for r in rlist
)
rows_html += f'<tr><td class="oc"><b>{OCC_LABELS.get(occ, occ)}</b></td><td class="rc">{items}</td></tr>'
sections += f'<div class="gs" style="--c:{meta["color"]};--bg:{meta["bg"]}"><div class="gh"><span class="gtitle">{meta["label"]}</span><span class="gchip">{meta["group"]}</span><span class="gcol">{meta["colors"]}</span></div><table class="rt"><thead><tr><th>Dip mac</th><th>TOP Anchor &rarr; TARGET (role) theo product_line DB</th></tr></thead><tbody>{rows_html}</tbody></table></div>'
html = f"""<!DOCTYPE html><html lang="vi"><head><meta charset="UTF-8"><title>Framework Phoi Do</title><style>
*{{box-sizing:border-box;margin:0;padding:0}}body{{font-family:-apple-system,sans-serif;background:#f8fafc;color:#1e293b;padding:20px}}
......
import asyncio
import json
import logging
from typing import Optional
from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse, JSONResponse
from fastapi.responses import JSONResponse, StreamingResponse
from worker.stylist_engine import StylistEngine
......@@ -12,6 +11,86 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/fashion-matches/simulator", tags=["Fashion Matches Simulator"])
def _flatten_candidates(ai_matches: dict) -> list[dict]:
"""Normalize ai_matches into a flat candidate list for simulator UI."""
flat: list[dict] = []
if not isinstance(ai_matches, dict):
return flat
for occ_or_role, role_or_items in ai_matches.items():
if isinstance(role_or_items, list):
for item in role_or_items:
if isinstance(item, dict):
flat.append(
{
"occasion": "all",
"role": occ_or_role,
"code": item.get("code") or item.get("sku") or "",
"name": item.get("name") or "",
"image_url": item.get("image") or item.get("image_url") or "",
"score": float(item.get("score") or 0),
"color": item.get("color") or "",
}
)
continue
if not isinstance(role_or_items, dict):
continue
for role, items in role_or_items.items():
if not isinstance(items, list):
continue
for item in items:
if not isinstance(item, dict):
continue
flat.append(
{
"occasion": occ_or_role,
"role": role,
"code": item.get("code") or item.get("sku") or "",
"name": item.get("name") or "",
"image_url": item.get("image") or item.get("image_url") or "",
"score": float(item.get("score") or 0),
"color": item.get("color") or "",
}
)
return sorted(flat, key=lambda x: -x["score"])
def _build_top_by_role(candidates: list[dict], top_k: int = 3) -> dict[str, list[dict]]:
"""Create a compact role-based top list for simulator final render."""
role_map: dict[str, list[dict]] = {}
for item in candidates:
role = item.get("role") or "Khác"
role_map.setdefault(role, [])
# Deduplicate by product code inside each role.
existing_codes = {p.get("sku") for p in role_map[role]}
code = item.get("code")
if code in existing_codes:
continue
role_map[role].append(
{
"sku": code,
"code": code,
"name": item.get("name") or "",
"image_url": item.get("image_url") or "",
"score": float(item.get("score") or 0),
"color": item.get("color") or "",
"occasion": item.get("occasion") or "",
}
)
for role in list(role_map.keys()):
role_map[role] = role_map[role][:top_k]
if not role_map[role]:
del role_map[role]
return role_map
@router.get("/search")
async def search_product(q: str = Query(..., description="Mã SP hoặc Tên SP", min_length=2)):
"""API hỗ trợ tìm kiếm nhanh Product Code cho Simulator"""
......@@ -29,12 +108,14 @@ async def search_product(q: str = Query(..., description="Mã SP hoặc Tên SP"
# Simple match logic
if q_lower in code or q_lower in ref or q_lower in name:
results.append({
results.append(
{
"code": p.get("code"),
"name": p.get("name"),
"image": p.get("image", ""),
"color": p.get("color", "")
})
"color": p.get("color", ""),
}
)
if len(results) >= 10:
break
......@@ -51,11 +132,10 @@ async def stream_flow(code: str = Query(..., description="Product code to simula
async def event_generator():
try:
# --- BƯỚC 1: INIT ---
msg = json.dumps({
"step": 1,
"node": "init",
"status": f"🔧 Khởi chạy Cỗ máy StylistEngine. Mã SP đưa vào: {code}..."
}, ensure_ascii=False)
msg = json.dumps(
{"step": 1, "node": "init", "status": f"🔧 Khởi chạy Cỗ máy StylistEngine. Mã SP đưa vào: {code}..."},
ensure_ascii=False,
)
yield f"data: {msg}\n\n"
await asyncio.sleep(0.8) # Dramatic delay
......@@ -68,7 +148,8 @@ async def stream_flow(code: str = Query(..., description="Product code to simula
return
# --- BƯỚC 2: PHÂN TÍCH SP GỐC ---
msg = json.dumps({
msg = json.dumps(
{
"step": 2,
"node": "fetch_product",
"status": f"🔍 Bóc tách SP gốc: {source.get('name')} (Màu: {source.get('color')} / Giới tính: {source.get('gender')})",
......@@ -77,9 +158,12 @@ async def stream_flow(code: str = Query(..., description="Product code to simula
"name": source.get("name"),
"image": source.get("image"),
"color": source.get("color"),
"category": source.get("product_line")
}
}, ensure_ascii=False)
"category": source.get("product_line"),
"catalog_total": max(len(catalog) - 1, 0),
},
},
ensure_ascii=False,
)
yield f"data: {msg}\n\n"
await asyncio.sleep(1.0)
......@@ -90,57 +174,101 @@ async def stream_flow(code: str = Query(..., description="Product code to simula
rules_count = len(db_rules)
if rules_count == 0:
status_rules = f"⚠️ Không có luật DB từ [chatbot_fashion_rules] cho '{anchor_cat}'. Rơi vào Fallback Rules."
status_rules = (
f"⚠️ Không có luật DB từ [chatbot_fashion_rules] cho '{anchor_cat}'. Rơi vào Fallback Rules."
)
else:
status_rules = f"📂 Khớp thành công {rules_count} luật (Rules) phối đồ theo Dịp (Occasion) & Role từ Database."
status_rules = (
f"📂 Khớp thành công {rules_count} luật (Rules) phối đồ theo Dịp (Occasion) & Role từ Database."
)
msg = json.dumps({
msg = json.dumps(
{
"step": 3,
"node": "fetch_rules",
"status": status_rules,
"payload": {"rules_count": rules_count}
}, ensure_ascii=False)
"payload": {
"rules_count": rules_count,
"stage_metrics": {
"stage": "rules",
"input_count": max(len(catalog) - 1, 0),
"output_count": rules_count,
},
},
},
ensure_ascii=False,
)
yield f"data: {msg}\n\n"
await asyncio.sleep(1.2)
# --- BƯỚC 4: CHẤM ĐIỂM ---
msg = json.dumps({
# (Run the heavy lifting here)
ai_matches = engine.compute_dynamic_rule_matches(code)
scored_candidates = _flatten_candidates(ai_matches)
msg = json.dumps(
{
"step": 4,
"node": "scoring",
"status": "🧮 Khởi động Scoring Engine: Tính điểm Color Synergy (30đ), Material (10đ), Occasion (20đ) cho toàn bộ Catalog..."
}, ensure_ascii=False)
"status": "🧮 Khởi động Scoring Engine: Tính điểm Color Synergy (30đ), Material (10đ), Occasion (20đ) cho toàn bộ Catalog...",
"payload": {
"stage_metrics": {
"stage": "scoring",
"input_count": max(len(catalog) - 1, 0),
"output_count": len(scored_candidates),
},
"preview_products": scored_candidates[:12],
},
},
ensure_ascii=False,
)
yield f"data: {msg}\n\n"
# (Run the heavy lifting here)
ai_matches = engine.compute_dynamic_rule_matches(code)
await asyncio.sleep(1.5)
# --- BƯỚC 5: HẬU KỲ VÀ PHẨN LOẠI MỞ RỘNG (DEDUPLICATE / SQL CLASSIFICATIONS) ---
msg = json.dumps({
top_role_matches = _build_top_by_role(scored_candidates, top_k=3)
final_count = sum(len(v) for v in top_role_matches.values())
msg = json.dumps(
{
"step": 5,
"node": "dedup",
"status": "📋 Sàng lọc (Deduplication): Loại bỏ kết quả trùng, lấy Top 3 cho mỗi Role (Top/Bottom/Khoác)... Đồng thời nạp Data Phân Loại mở rộng SQL."
}, ensure_ascii=False)
"status": "📋 Sàng lọc (Deduplication): Loại bỏ kết quả trùng, lấy Top 3 cho mỗi Role (Top/Bottom/Khoác)... Đồng thời nạp Data Phân Loại mở rộng SQL.",
"payload": {
"stage_metrics": {
"stage": "dedup",
"input_count": len(scored_candidates),
"output_count": final_count,
},
"preview_products": scored_candidates[:24],
"role_matches": top_role_matches,
},
},
ensure_ascii=False,
)
yield f"data: {msg}\n\n"
classifications = engine.compute_super_classifications_sql(code)
await asyncio.sleep(0.8)
# --- BƯỚC 6: FINISH ---
msg = json.dumps({
msg = json.dumps(
{
"step": 6,
"node": "complete",
"status": "✅ HOÀN TẤT! Đẩy khung Outfit JSON ra giao diện.",
"payload": {
"ai_matches": ai_matches,
"classifications": classifications
}
}, ensure_ascii=False)
"ai_matches": top_role_matches,
"raw_ai_matches": ai_matches,
"classifications": classifications,
},
},
ensure_ascii=False,
)
yield f"data: {msg}\n\n"
except Exception as e:
logger.error("[Simulator] Generator error: %s", e)
msg = json.dumps({"error": True, "status": f"❌ Lỗi Engine: {str(e)}"}, ensure_ascii=False)
msg = json.dumps({"error": True, "status": f"❌ Lỗi Engine: {e!s}"}, ensure_ascii=False)
yield f"data: {msg}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
......@@ -40,6 +40,9 @@
<button class="btn btn-ghost btn-sm" style="justify-content:flex-start; font-size:13px; padding:8px 12px; color:var(--foreground);" onclick="let a=document.createElement('a'); a.setAttribute('data-page','fashion-matches/live-simulator.html'); window.parent.navigateTo(a)">
<i data-lucide="monitor-play" class="icon-md" style="margin-right:8px; color:#10b981"></i> Live Simulator
</button>
<button class="btn btn-ghost btn-sm" style="justify-content:flex-start; font-size:13px; padding:8px 12px; color:var(--foreground);" onclick="let a=document.createElement('a'); a.setAttribute('data-page','fashion-matches/tag-audit.html'); window.parent.navigateTo(a)">
<i data-lucide="clipboard-check" class="icon-md" style="margin-right:8px; color:#0ea5e9"></i> Tag Audit Checker
</button>
</div>
</div>
......
......@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Simulator — Canifa AI Stylist</title>
<link rel="stylesheet" href="/static/common/theme.css">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<style>
/* ═════════════════════════════════════════════
SYNCHRONIZED LIGHT THEME UI (Memos Style)
......@@ -15,17 +15,28 @@
body {
background: var(--background);
min-height: 100vh;
font-family: 'Inter', sans-serif;
font-family: 'Manrope', sans-serif;
color: var(--foreground);
-webkit-font-smoothing: antialiased;
background:
radial-gradient(circle at 10% 5%, rgba(59, 89, 152, 0.08), transparent 28%),
radial-gradient(circle at 95% 15%, rgba(14, 165, 233, 0.08), transparent 22%),
var(--background);
}
.mf-wrap {
max-width: 640px;
max-width: 1200px;
margin: 0 auto;
padding: 40px 16px 80px;
}
.sim-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr);
gap: 18px;
align-items: start;
}
/* ── Header ── */
.mf-header {
margin-bottom: 32px;
......@@ -48,6 +59,7 @@
color: var(--foreground);
line-height: 1.2;
letter-spacing: -0.5px;
font-family: 'Space Grotesk', sans-serif;
}
.mf-title span {
background: linear-gradient(90deg, var(--primary), var(--info));
......@@ -312,6 +324,195 @@
.product-mini-code {
font-size: 10px; color: var(--muted-fg); margin-top: 2px; font-family: 'DM Mono', monospace;
}
.inspector {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(244, 248, 255, 0.95));
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
padding: 16px;
position: sticky;
top: 16px;
backdrop-filter: blur(4px);
}
.inspector-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.inspector-title {
font-size: 13px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--primary);
font-family: 'DM Mono', monospace;
font-weight: 600;
}
.inspector-badge {
font-size: 10px;
color: var(--secondary-fg);
background: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 8px;
font-family: 'DM Mono', monospace;
}
.source-card {
display: flex;
gap: 10px;
padding: 10px;
border-radius: var(--radius);
background: #fff;
border: 1px solid var(--border);
margin-bottom: 12px;
}
.source-thumb {
width: 56px;
height: 70px;
border-radius: var(--radius-sm);
object-fit: cover;
background: var(--muted);
border: 1px solid var(--border);
}
.source-name {
font-size: 13px;
font-weight: 700;
color: var(--foreground);
line-height: 1.35;
margin-bottom: 4px;
}
.source-meta {
font-size: 11px;
color: var(--muted-fg);
line-height: 1.4;
}
.metrics-grid {
display: grid;
gap: 8px;
margin-bottom: 14px;
}
.metric-card {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
transition: all 0.2s;
}
.metric-card.active {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
transform: translateX(2px);
}
.metric-stage {
font-size: 11px;
color: var(--secondary-fg);
font-family: 'DM Mono', monospace;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 2px;
}
.metric-text {
font-size: 12px;
font-weight: 600;
color: var(--foreground);
}
.metric-count {
font-size: 12px;
color: var(--primary);
font-family: 'DM Mono', monospace;
font-weight: 600;
white-space: nowrap;
}
.inspector-subtitle {
font-size: 11px;
color: var(--muted-fg);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
font-family: 'DM Mono', monospace;
}
.suggest-list {
display: grid;
gap: 8px;
max-height: 420px;
overflow-y: auto;
padding-right: 2px;
}
.suggest-item {
display: grid;
grid-template-columns: 48px 1fr auto;
gap: 8px;
align-items: center;
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px;
}
.suggest-item img {
width: 48px;
height: 58px;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--muted);
border: 1px solid var(--border);
}
.suggest-name {
font-size: 12px;
font-weight: 600;
color: var(--foreground);
line-height: 1.3;
margin-bottom: 2px;
max-height: 32px;
overflow: hidden;
}
.suggest-meta {
font-size: 10px;
color: var(--muted-fg);
font-family: 'DM Mono', monospace;
}
.suggest-score {
font-size: 11px;
color: var(--primary);
font-family: 'DM Mono', monospace;
font-weight: 600;
text-align: right;
min-width: 58px;
}
@media (max-width: 980px) {
.sim-grid {
grid-template-columns: 1fr;
}
.inspector {
position: static;
}
}
</style>
</head>
<body>
......@@ -337,6 +538,8 @@
<div class="log-line">Waiting for product selection...</div>
</div>
<div class="sim-grid">
<div>
<!-- FLOW NODES -->
<div id="flowNodes">
<!-- Nodes will be injected here -->
......@@ -349,6 +552,30 @@
<!-- Outfits injected here -->
</div>
</div>
</div>
<aside class="inspector">
<div class="inspector-head">
<div class="inspector-title">Live Filter Inspector</div>
<div class="inspector-badge" id="activeStageBadge">IDLE</div>
</div>
<div class="source-card" id="sourceCard">
<img class="source-thumb" id="sourceThumb" alt="source product">
<div>
<div class="source-name" id="sourceName">Chưa chọn sản phẩm</div>
<div class="source-meta" id="sourceMeta">Nhập mã hoặc tên sản phẩm để mô phỏng pipeline.</div>
</div>
</div>
<div class="metrics-grid" id="metricsGrid"></div>
<div class="inspector-subtitle">Sản phẩm đang hiển thị theo tầng</div>
<div class="suggest-list" id="suggestList">
<div class="log-line">Danh sách gợi ý sẽ xuất hiện sau khi qua Scoring.</div>
</div>
</aside>
</div>
</div>
......@@ -400,6 +627,99 @@ let searchTimeout = null;
const searchInput = document.getElementById('searchInput');
const searchResults = document.getElementById('searchResults');
let eventSource = null;
let streamCompleted = false;
const STAGE_DEFS = [
{ key: 'pool', label: 'Input Pool', summary: 'Catalog hợp lệ trước khi áp rule' },
{ key: 'rules', label: 'Rule Match', summary: 'Số luật DB/Fallback áp vào source' },
{ key: 'scoring', label: 'Scoring Pass', summary: 'Sản phẩm vượt min_score' },
{ key: 'dedup', label: 'Dedup Final', summary: 'Top 3 cho từng role sau lọc trùng' }
];
const stageState = {
active: 'idle',
metrics: {
pool: { input: null, output: null },
rules: { input: null, output: null },
scoring: { input: null, output: null },
dedup: { input: null, output: null }
},
source: null,
suggestions: []
};
function placeholderImageData() {
return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9IiNlMmU4ZjAiPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48L3N2Zz4=';
}
function renderInspector() {
const badge = document.getElementById('activeStageBadge');
badge.textContent = (stageState.active || 'idle').toUpperCase();
const sourceName = document.getElementById('sourceName');
const sourceMeta = document.getElementById('sourceMeta');
const sourceThumb = document.getElementById('sourceThumb');
if (stageState.source) {
sourceName.textContent = stageState.source.name || stageState.source.code || 'Nguồn phối đồ';
sourceMeta.textContent = `${stageState.source.code || ''}${stageState.source.color || ''}${stageState.source.category || ''}`;
sourceThumb.src = stageState.source.image || placeholderImageData();
} else {
sourceName.textContent = 'Chưa chọn sản phẩm';
sourceMeta.textContent = 'Nhập mã hoặc tên sản phẩm để mô phỏng pipeline.';
sourceThumb.src = placeholderImageData();
}
const metricsGrid = document.getElementById('metricsGrid');
metricsGrid.innerHTML = STAGE_DEFS.map((stage) => {
const m = stageState.metrics[stage.key] || { input: null, output: null };
const inputText = m.input === null ? '...' : m.input;
const outputText = m.output === null ? '...' : m.output;
const activeClass = stageState.active === stage.key ? 'active' : '';
return `
<div class="metric-card ${activeClass}">
<div>
<div class="metric-stage">${stage.label}</div>
<div class="metric-text">${stage.summary}</div>
</div>
<div class="metric-count">${inputText}${outputText}</div>
</div>
`;
}).join('');
const suggestList = document.getElementById('suggestList');
if (!stageState.suggestions.length) {
suggestList.innerHTML = '<div class="log-line">Danh sách gợi ý sẽ xuất hiện sau khi qua Scoring.</div>';
return;
}
suggestList.innerHTML = stageState.suggestions.map((p) => {
const code = p.code || p.sku || '';
const role = p.role || p.occasion || 'candidate';
return `
<div class="suggest-item">
<img src="${p.image_url || p.image || ''}" onerror="this.src='${placeholderImageData()}'">
<div>
<div class="suggest-name">${p.name || 'Không có tên'}</div>
<div class="suggest-meta">${code}${role}</div>
</div>
<div class="suggest-score">${Number(p.score || 0).toFixed(1)} đ</div>
</div>
`;
}).join('');
}
function normalizeRoleMatches(roleMatches) {
const normalized = [];
if (!roleMatches || typeof roleMatches !== 'object') return normalized;
Object.entries(roleMatches).forEach(([role, items]) => {
if (!Array.isArray(items)) return;
items.forEach((item) => {
normalized.push({ ...item, role, code: item.code || item.sku || '' });
});
});
return normalized;
}
renderInspector();
searchInput.addEventListener('input', (e) => {
const q = e.target.value.trim();
......@@ -491,6 +811,17 @@ function resetUI() {
term.innerHTML = '';
document.getElementById('finalResult').classList.remove('show');
document.getElementById('outfitGrid').innerHTML = '';
streamCompleted = false;
stageState.active = 'idle';
stageState.metrics = {
pool: { input: null, output: null },
rules: { input: null, output: null },
scoring: { input: null, output: null },
dedup: { input: null, output: null }
};
stageState.source = null;
stageState.suggestions = [];
renderInspector();
}
// Start Stream
......@@ -508,7 +839,13 @@ function startSimulation(code) {
eventSource = new EventSource(`/api/fashion-matches/simulator/stream?code=${encodeURIComponent(code)}`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
let data = null;
try {
data = JSON.parse(event.data);
} catch (err) {
logTerminal('❌ Payload stream không hợp lệ từ server.', 'error');
return;
}
if (data.error) {
logTerminal(data.status, 'error');
......@@ -541,15 +878,64 @@ function startSimulation(code) {
if (data.step === 6) logType = 'success';
logTerminal(data.status, logType);
const payload = data.payload || {};
if (data.node === 'fetch_product') {
stageState.active = 'pool';
stageState.source = {
code: payload.code,
name: payload.name,
image: payload.image,
color: payload.color,
category: payload.category,
};
const poolCount = Number(payload.catalog_total || 0);
stageState.metrics.pool = { input: poolCount, output: poolCount };
}
if (data.node === 'fetch_rules') {
stageState.active = 'rules';
const stageMetrics = payload.stage_metrics || {};
stageState.metrics.rules = {
input: Number(stageMetrics.input_count || stageState.metrics.pool.output || 0),
output: Number(stageMetrics.output_count || payload.rules_count || 0)
};
}
if (data.node === 'scoring') {
stageState.active = 'scoring';
const stageMetrics = payload.stage_metrics || {};
stageState.metrics.scoring = {
input: Number(stageMetrics.input_count || stageState.metrics.rules.input || 0),
output: Number(stageMetrics.output_count || 0)
};
stageState.suggestions = Array.isArray(payload.preview_products) ? payload.preview_products.slice(0, 12) : [];
}
if (data.node === 'dedup') {
stageState.active = 'dedup';
const stageMetrics = payload.stage_metrics || {};
stageState.metrics.dedup = {
input: Number(stageMetrics.input_count || stageState.metrics.scoring.output || 0),
output: Number(stageMetrics.output_count || 0)
};
const roleList = normalizeRoleMatches(payload.role_matches);
stageState.suggestions = roleList.length ? roleList : stageState.suggestions;
}
renderInspector();
// Render payload if step 6
if (data.step === 6 && data.payload && data.payload.ai_matches) {
renderFinalResults(data.payload.ai_matches);
streamCompleted = true;
eventSource.close();
}
};
eventSource.onerror = (err) => {
eventSource.onerror = () => {
if (!streamCompleted) {
logTerminal("Connection closed or error", "error");
}
eventSource.close();
};
}
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fashion Tags Audit</title>
<link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css">
<script src="https://unpkg.com/lucide@latest"></script>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Manrope", "Segoe UI", sans-serif;
color: var(--foreground);
background:
radial-gradient(circle at 12% 5%, rgba(16, 185, 129, 0.08), transparent 32%),
radial-gradient(circle at 90% 10%, rgba(59, 130, 246, 0.08), transparent 28%),
var(--background);
min-height: 100vh;
}
.wrap {
max-width: 1300px;
margin: 0 auto;
padding: 24px 16px 56px;
}
.head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 26px;
font-weight: 800;
letter-spacing: -0.4px;
margin: 0;
}
.subtitle {
margin: 6px 0 0;
color: var(--muted-fg);
font-size: 13px;
}
.toolbar {
display: grid;
grid-template-columns: 1fr 130px 130px auto;
gap: 10px;
margin-bottom: 18px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(6, minmax(120px, 1fr));
gap: 10px;
margin-bottom: 18px;
}
.stat-card {
border: 1px solid var(--border);
background: var(--card);
border-radius: 12px;
padding: 12px;
box-shadow: var(--shadow-sm);
}
.stat-label {
font-size: 11px;
text-transform: uppercase;
color: var(--muted-fg);
letter-spacing: 1px;
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 800;
color: var(--primary);
line-height: 1;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.panel-head {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
background: rgba(0, 0, 0, 0.02);
font-weight: 700;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.badge {
font-size: 11px;
font-weight: 700;
color: var(--primary);
background: var(--primary-light);
border: 1px solid rgba(59, 89, 152, 0.3);
border-radius: 999px;
padding: 2px 8px;
}
.table-wrap {
max-height: 430px;
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th, td {
text-align: left;
padding: 9px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
position: sticky;
top: 0;
background: var(--muted);
color: var(--muted-fg);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.7px;
z-index: 1;
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
border: 1px solid var(--border);
background: var(--muted);
color: var(--secondary-fg);
font-size: 11px;
border-radius: 999px;
padding: 2px 8px;
white-space: nowrap;
}
.chip.warn {
border-color: rgba(245, 158, 11, 0.4);
color: #b45309;
background: #fffbeb;
}
.chip.error {
border-color: rgba(239, 68, 68, 0.35);
color: #991b1b;
background: #fef2f2;
}
.checklist {
display: grid;
gap: 8px;
padding: 12px;
}
.check-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
background: var(--background);
}
.check-label {
font-size: 13px;
font-weight: 600;
color: var(--foreground);
}
.check-count {
font-size: 12px;
color: var(--primary);
font-weight: 700;
}
.muted {
color: var(--muted-fg);
font-size: 12px;
}
@media (max-width: 1024px) {
.toolbar {
grid-template-columns: 1fr 1fr;
}
.card-grid {
grid-template-columns: repeat(3, minmax(120px, 1fr));
}
.grid-2 {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.card-grid {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
}
</style>
</head>
<body>
<div class="wrap">
<div class="head">
<div>
<h1 class="title">Fashion Tags Audit</h1>
<p class="subtitle">Kiểm tra độ phủ rules và tag theo SKU để team data bổ sung đúng format.</p>
</div>
<button class="btn btn-outline" onclick="window.location.reload()"><i data-lucide="refresh-cw" style="width:14px;height:14px"></i> Refresh</button>
</div>
<div class="toolbar">
<input id="qInput" class="select" placeholder="Tìm theo SKU, tên, product_line...">
<input id="limitInput" class="select" type="number" value="300" min="20" max="2000">
<button class="btn btn-outline" onclick="runAudit()"><i data-lucide="search" style="width:14px;height:14px"></i> Chạy audit</button>
<button class="btn btn-primary" onclick="copyChecklist()"><i data-lucide="clipboard-list" style="width:14px;height:14px"></i> Copy checklist</button>
</div>
<div class="card-grid" id="summaryCards"></div>
<div class="grid-2">
<section class="panel">
<div class="panel-head">
<span>Product Line Không Match Rule</span>
<span class="badge" id="lineCountBadge">0</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Product Line</th>
<th>SKU</th>
<th>DB</th>
<th>Fallback</th>
<th>No Rule</th>
<th>Role Missing</th>
</tr>
</thead>
<tbody id="lineTableBody"></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-head">
<span>SKU Thiếu Tag</span>
<span class="badge" id="skuCountBadge">0</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>SKU</th>
<th>Product Line</th>
<th>Rule</th>
<th>Thiếu Tag</th>
</tr>
</thead>
<tbody id="skuTableBody"></tbody>
</table>
</div>
</section>
</div>
<section class="panel">
<div class="panel-head">
<span>Checklist Bổ Sung Dữ Liệu</span>
</div>
<div class="checklist" id="checklistBox"></div>
</section>
</div>
<script>
const state = {
summary: null,
lines: [],
skus: [],
checklist: []
};
function esc(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function renderSummary() {
const summary = state.summary || {};
const cards = [
['Sample SKU', summary.sample_size || 0],
['Total Filter', summary.total_after_filter || 0],
['Product Line', summary.product_line_count || 0],
['Unmatched Line', summary.product_line_unmatched_count || 0],
['SKU Missing Tags', summary.sku_with_missing_tags || 0],
['No Rule SKU', summary.no_rule_skus || 0],
];
document.getElementById('summaryCards').innerHTML = cards.map(([label, value]) => `
<div class="stat-card">
<div class="stat-label">${esc(label)}</div>
<div class="stat-value">${esc(value)}</div>
</div>
`).join('');
}
function renderLines() {
document.getElementById('lineCountBadge').textContent = state.lines.length;
const html = state.lines.map((row) => `
<tr>
<td>${esc(row.product_line || '(trống)')}</td>
<td>${esc(row.total_skus)}</td>
<td>${esc(row.db_covered)}</td>
<td>${esc(row.fallback_covered)}</td>
<td><span class="chip error">${esc(row.rule_missing)}</span></td>
<td><span class="chip warn">${esc(row.role_missing)}</span></td>
</tr>
`).join('');
document.getElementById('lineTableBody').innerHTML = html || '<tr><td colspan="6" class="muted">Không có dữ liệu.</td></tr>';
}
function renderSkus() {
document.getElementById('skuCountBadge').textContent = state.skus.length;
const html = state.skus.map((row) => {
const tags = (row.missing_tags || []).map((t) => `<span class="chip warn">${esc(t)}</span>`).join('');
const statusClass = row.rule_status === 'none' ? 'error' : 'warn';
return `
<tr>
<td>
<div style="font-weight:700">${esc(row.code)}</div>
<div class="muted">${esc(row.name)}</div>
</td>
<td>${esc(row.product_line || '(trống)')}</td>
<td><span class="chip ${statusClass}">${esc(row.rule_status || 'unknown')}</span></td>
<td><div class="chip-list">${tags}</div></td>
</tr>
`;
}).join('');
document.getElementById('skuTableBody').innerHTML = html || '<tr><td colspan="4" class="muted">Không có SKU thiếu tag trong mẫu hiện tại.</td></tr>';
}
function renderChecklist() {
const html = state.checklist.map((it) => `
<div class="check-item">
<div class="check-label">${esc(it.label)}</div>
<div class="check-count">${esc(it.affected_count)} mục</div>
</div>
`).join('');
document.getElementById('checklistBox').innerHTML = html || '<div class="muted">Không có checklist.</div>';
}
async function runAudit() {
const q = document.getElementById('qInput').value.trim();
const limit = Number(document.getElementById('limitInput').value || 300);
const url = `/api/fashion-matches/audit/tag-coverage?limit=${encodeURIComponent(limit)}&q=${encodeURIComponent(q)}`;
try {
const res = await fetch(url);
const data = await res.json();
if (!data.ok) {
throw new Error(data.error || 'Audit failed');
}
state.summary = data.summary || {};
state.lines = data.product_line_unmatched || [];
state.skus = data.sku_missing_tags || [];
state.checklist = data.checklist || [];
renderSummary();
renderLines();
renderSkus();
renderChecklist();
} catch (err) {
alert(`Không tải được dữ liệu audit: ${err.message}`);
}
}
function copyChecklist() {
const lines = (state.checklist || []).map((it) => `- ${it.label}: ${it.affected_count}`);
if (!lines.length) {
alert('Chưa có dữ liệu checklist để copy.');
return;
}
navigator.clipboard.writeText(lines.join('\n'));
alert('Đã copy checklist.');
}
runAudit();
if (window.lucide) window.lucide.createIcons();
</script>
</body>
</html>
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