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

Auto commit: Update Canifa Stylist UI, API fixes, and Cuccu Note SQLite migration.

parent 4f52d746
...@@ -59,3 +59,8 @@ backend/schema_dump.json ...@@ -59,3 +59,8 @@ backend/schema_dump.json
# SQLite local mock DB (rebuilt from backend/database/postgres/ + starrocks/ SQL dumps) # SQLite local mock DB (rebuilt from backend/database/postgres/ + starrocks/ SQL dumps)
*.sqlite *.sqlite
*.sqlite-journal *.sqlite-journal
# SQLite
*.db
*.db-shm
*.db-wal
...@@ -10,7 +10,7 @@ from collections import defaultdict ...@@ -10,7 +10,7 @@ from collections import defaultdict
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from api.notes_route import _get_pool, _now from api.notes.notes_route import _get_pool, _now
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dashboard", tags=["Experiment Log"]) router = APIRouter(prefix="/api/dashboard", tags=["Experiment Log"])
......
...@@ -21,7 +21,7 @@ import logging ...@@ -21,7 +21,7 @@ import logging
import os import os
from typing import Optional from typing import Optional
from fastapi import APIRouter, BackgroundTasks from fastapi import APIRouter, BackgroundTasks, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
...@@ -279,10 +279,31 @@ async def update_fashion_matches(code: str, req: UpdateMatchesRequest): ...@@ -279,10 +279,31 @@ async def update_fashion_matches(code: str, req: UpdateMatchesRequest):
@router.post("/{code}/regen") @router.post("/{code}/regen")
async def regen_fashion_matches(code: str, background_tasks: BackgroundTasks): async def regen_fashion_matches(code: str):
background_tasks.add_task(_run_engine_background, code) import asyncio
logger.info("[FashionMatches] Regen triggered: %s", code) await asyncio.to_thread(_run_engine_background, code)
return {"ok": True, "message": f"Đang tính toán phối đồ cho {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()
codes = data.get("codes", [])
if not codes:
return {"ok": True, "message": "None"}
def _run_multiple():
from worker.stylist_engine import StylistEngine
engine = StylistEngine()
for c in codes:
try:
engine.run_for_code(c)
except:
pass
import asyncio
await asyncio.to_thread(_run_multiple)
return {"ok": True, "message": f"Đã xong {len(codes)} sp"}
@router.post("/batch") @router.post("/batch")
......
...@@ -528,13 +528,25 @@ async def product_desc_list( ...@@ -528,13 +528,25 @@ async def product_desc_list(
p["desc_status"] = -1 p["desc_status"] = -1
p["tags"] = []; p["has_size_guide"] = 0; p["updated_at"] = None p["tags"] = []; p["has_size_guide"] = 0; p["updated_at"] = None
# Check ai_matches # Check ai_matches
# 0 = chưa chạy bao giờ, 1 = có SP phối, 2 = engine đã chạy nhưng catalog màu chưa đủ
if magento_code in code_status_map: if magento_code in code_status_map:
row_full = UltraDescriptionDB.get_by_magento_code(magento_code) row_full = UltraDescriptionDB.get_by_magento_code(magento_code)
ai_m = (row_full or {}).get("ai_matches") or {} ai_m = (row_full or {}).get("ai_matches") # None = chưa chạy
if isinstance(ai_m, str): if ai_m is None:
try: ai_m = json.loads(ai_m) p["has_ai_matches"] = 0 # chưa chạy engine lần nào
except: ai_m = {} else:
p["has_ai_matches"] = 1 if ai_m else 0 if isinstance(ai_m, str):
try: ai_m = json.loads(ai_m)
except: ai_m = {}
# Check xem có occasion nào có items không
has_any_item = any(
isinstance(v, dict) and any(
isinstance(items, list) and len(items) > 0
for items in v.values()
)
for v in ai_m.values()
) if isinstance(ai_m, dict) else False
p["has_ai_matches"] = 1 if has_any_item else 2 # 2 = đã chạy, catalog rỗng
else: else:
p["has_ai_matches"] = 0 p["has_ai_matches"] = 0
......
...@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException ...@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from config import CHECKPOINT_POSTGRES_URL from config import CHECKPOINT_POSTGRES_URL
from api.notes_route import _get_pool, _now from api.notes.notes_route import _get_pool, _now
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/dashboard", tags=["Roadmap & Flow"]) router = APIRouter(prefix="/api/dashboard", tags=["Roadmap & Flow"])
......
import asyncio
from database.postgres_pool import pool_wrapper
async def f():
await pool_wrapper.init_all()
rows = await pool_wrapper.execute_query_async("SELECT DISTINCT anchor_category FROM dashboard_canifa.chatbot_fashion_rules")
print([r['anchor_category'] for r in rows])
await pool_wrapper.close_all()
asyncio.run(f())
...@@ -285,7 +285,7 @@ def check_negative_spike() -> bool: ...@@ -285,7 +285,7 @@ def check_negative_spike() -> bool:
""" """
from common.social.inbox_webhook import _load_messages # type: ignore from common.social.inbox_webhook import _load_messages # type: ignore
try: try:
from api.social_inbox_route import _load_messages as load_msgs from api.social_inbox.social_inbox_route import _load_messages as load_msgs
messages = load_msgs() messages = load_msgs()
except Exception: except Exception:
return False return False
......
import logging, os, sys
backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
TABLE = "dashboard_canifa.chatbot_fashion_rules"
RULES = [
# Bộ mặc nhà (nguyên set) -> Phối với phụ kiện hoặc outerwear
("all", "Bộ mặc nhà", "mac_nha", "accessory", "Tất", "Bộ mặc nhà + Tất: Giữ ấm bàn chân khi ngủ"),
("all", "Bộ mặc nhà", "mac_nha", "outerwear", "Áo khoác gió", "Bộ mặc nhà + Áo khoác gió: Mặc ngoài khi ra khỏi phòng"),
# Quần mặc nhà -> Phối với Áo mặc nhà hoặc Áo phông
("all", "Quần mặc nhà", "mac_nha", "top", "Áo mặc nhà", "Quần mặc nhà + Áo mặc nhà: Nguyên set thoải mái"),
("all", "Quần mặc nhà", "mac_nha", "top", "Áo phông", "Quần mặc nhà + Áo phông: Đơn giản, thoải mái"),
# Áo mặc nhà Bé Trai / Bé Gái / Nữ (Bổ sung thêm giới tính)
("be_trai", "Áo mặc nhà", "mac_nha", "bottom", "Quần mặc nhà", "Áo mặc nhà + Quần mặc nhà bé trai"),
("be_gai", "Áo mặc nhà", "mac_nha", "bottom", "Quần mặc nhà", "Áo mặc nhà + Quần mặc nhà bé gái"),
("nu", "Áo mặc nhà", "mac_nha", "bottom", "Quần mặc nhà", "Áo mặc nhà + Quần lụa/cotton lửng cho nữ"),
# Áo khoác chống nắng -> Phối chống nắng cơ bản lớp ngoài
("all", "Áo khoác chống nắng", "di_choi", "top", "Áo phông", "Khoác chống nắng ngoài Áo phông"),
("all", "Áo khoác chống nắng", "di_choi", "bottom", "Quần jean", "Khoác chống nắng + Quần jean năng động"),
# Tất -> Phụ kiện (nếu Tất làm món chính)
("all", "Tất", "hang_ngay", "bottom", "Quần soóc", "Tất + Quần soóc thao năng động"),
("all", "Tất", "the_thao", "bottom", "Quần thể thao", "Tất + Quần thể thao chuyên dụng"),
]
def run():
import os, sys
backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, backend_dir)
from common.pool_wrapper import get_pooled_connection_compat
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
inserted = 0
for gender, anchor, occ, role, target, reason in RULES:
cur.execute(f"""
INSERT INTO {TABLE} (gender_target, anchor_category, occasion_tag, match_role, target_category, ai_reason)
VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT DO NOTHING
""", (gender, anchor, occ, role, target, reason))
if cur.rowcount > 0:
inserted += 1
conn.commit()
cur.close()
print(f"[OK] migrate_005 done: +{inserted} rules seeded ({len(RULES)} total in batch)")
except Exception as e:
if conn: conn.rollback()
print(f"[ERROR] {e}")
finally:
if conn: conn.close()
if __name__ == "__main__":
run()
This diff is collapsed.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
from worker.stylist_engine import StylistEngine
e = StylistEngine()
res = e.compute_dynamic_rule_matches('6TS26A002-SK010')
print("=== Keys trả về từ engine ===")
for occ, roles in res.items():
tot = sum(len(v) for v in roles.values())
print(f' occ_key="{occ}" total={tot} roles={list(roles.keys())}')
print()
print("=== Check OCC_LABELS mapping (frontend expects) ===")
OCC_LABELS = {
'di_choi': 'Đi chơi / dạo phố',
'cong_so': 'Đi làm công sở',
'mac_nha': 'Ở nhà / mặc ngủ',
'du_lich': 'Du lịch',
'hang_ngay': 'Hàng ngày',
}
for k, v in OCC_LABELS.items():
n = sum(len(roles.values()) for roles in [res.get(k, {})])
items = sum(len(v2) for v2 in res.get(k, {}).values())
print(f' "{k}" → "{v}": {items} items')
from worker.stylist_engine import StylistEngine
e = StylistEngine()
catalog = e._get_catalog()
# Check Ao phong for kids
print("=== Áo phông kids ===")
for p in catalog:
g = (p.get('gender') or '').lower()
ag = (p.get('age_group') or '').lower()
kid_kw = ['boy', 'girl', 'bé', 'trẻ em']
if p.get('product_line') == 'Áo phông' and any(k in g+ag for k in kid_kw):
print(f" {p['code']} | gender={p.get('gender')} | age_group={p.get('age_group')}")
print()
print("=== Áo mặc nhà kids ===")
for p in catalog:
g = (p.get('gender') or '').lower()
ag = (p.get('age_group') or '').lower()
kid_kw = ['boy', 'girl', 'bé', 'trẻ em']
if p.get('product_line') == 'Áo mặc nhà' and any(k in g+ag for k in kid_kw):
print(f" {p['code']} | gender={p.get('gender')} | age_group={p.get('age_group')}")
# Also check what happens when engine computes for this code
print()
print("=== compute_dynamic_rule_matches for 2LA26S004-FA160 ===")
result = e.compute_dynamic_rule_matches('2LA26S004-FA160')
print(f"Total occasions: {len(result)}")
for occ, roles in result.items():
for role, items in roles.items():
print(f" {occ} / {role}: {len(items)} items")
for it in items[:2]:
print(f" - {it['code']} {it['name'][:30]}")
"""
Test gender + age_group filter logic in _pass_hard_filter
Rules:
- nữ <-> nữ ✅
- nam <-> nam ✅
- unisex <-> nữ ✅
- unisex <-> nam ✅
- unisex <-> unisex ✅
- nữ <-> nam ❌
- trẻ em <-> người lớn ❌ (any gender)
"""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from worker.stylist_engine import StylistEngine
engine = StylistEngine()
def p(gender="", age_group="", product_line="Áo phông", code="X"):
return {"code": code, "gender": gender, "age_group": age_group, "product_line": product_line}
cases = [
# (src_gender, src_age, tgt_gender, tgt_age, expected, label)
("nữ", "", "nữ", "", True, "nữ <-> nữ"),
("nam", "", "nam", "", True, "nam <-> nam"),
("unisex", "", "nữ", "", True, "unisex <-> nữ"),
("unisex", "", "nam", "", True, "unisex <-> nam"),
("nữ", "", "unisex", "", True, "nữ <-> unisex"),
("unisex", "", "unisex", "", True, "unisex <-> unisex"),
("nữ", "", "nam", "", False, "nữ <-> nam ❌"),
("nam", "", "nữ", "", False, "nam <-> nữ ❌"),
# unisex trẻ em phải bị chặn khi ghép với người lớn
("nữ", "", "unisex trẻ em","", False, "nữ <-> unisex trẻ em ❌"),
("nam", "", "unisex trẻ em","", False, "nam <-> unisex trẻ em ❌"),
# Trẻ em + trẻ em → ok
("unisex trẻ em","", "unisex trẻ em","", True, "trẻ em <-> trẻ em ✅"),
# age_group field path
("nữ", "người lớn","nữ", "trẻ em", False, "nữ người lớn <-> nữ trẻ em ❌"),
("nữ", "trẻ em", "nữ", "trẻ em", True, "nữ trẻ em <-> nữ trẻ em ✅"),
]
all_pass = True
for sg, sa, tg, ta, expected, label in cases:
src = p(gender=sg, age_group=sa, code="SRC")
tgt = p(gender=tg, age_group=ta, code="TGT")
result = engine._pass_hard_filter(src, tgt)
ok = result == expected
icon = "✅" if ok else "❌ FAIL"
if not ok:
all_pass = False
print(f" {icon} {label:45s} → got={result} expected={expected}")
print()
print("=" * 60)
print("ALL PASS" if all_pass else "SOME TESTS FAILED!")
from worker.stylist_engine import StylistEngine
from collections import Counter
e = StylistEngine()
catalog = e._get_catalog()
# Group product_lines by gender
lines_by_gender = {}
for p in catalog:
g = p.get('gender', 'unknown')
pl = p.get('product_line', '')
if not pl:
continue
if g not in lines_by_gender:
lines_by_gender[g] = Counter()
lines_by_gender[g][pl] += 1
print("=== WOMEN product_lines ===")
for pl, cnt in sorted(lines_by_gender.get('women', {}).items(), key=lambda x: -x[1]):
print(f" {cnt:3d}x {pl}")
print()
print("=== MEN product_lines ===")
for pl, cnt in sorted(lines_by_gender.get('men', {}).items(), key=lambda x: -x[1]):
print(f" {cnt:3d}x {pl}")
print()
print("=== UNISEX product_lines ===")
for pl, cnt in sorted(lines_by_gender.get('unisex', {}).items(), key=lambda x: -x[1]):
print(f" {cnt:3d}x {pl}")
print()
print("=== ALL unique genders in catalog ===")
print(set(p.get('gender','') for p in catalog))
import sqlite3
db = sqlite3.connect('database/canifa_ai_dump.sqlite')
db.row_factory = sqlite3.Row
cur = db.cursor()
print("=== pg__dashboard_canifa__chatbot_fashion_rules (mac nha/combo) ===")
cur.execute("""
SELECT anchor_category, target_category, match_role, occasion_tag
FROM pg__dashboard_canifa__chatbot_fashion_rules
WHERE anchor_category LIKE '%mac nha%' OR anchor_category LIKE '%combo%'
LIMIT 30
""")
rows = cur.fetchall()
print(f"Found: {len(rows)}")
for r in rows:
print(" ", dict(r))
print()
print("=== chatbot_fashion_rules (mac nha/combo) ===")
cur.execute("""
SELECT anchor_category, target_category, match_role, occasion_tag
FROM chatbot_fashion_rules
WHERE anchor_category LIKE '%mac nha%' OR anchor_category LIKE '%combo%'
LIMIT 30
""")
rows2 = cur.fetchall()
print(f"Found: {len(rows2)}")
for r in rows2:
print(" ", dict(r))
# Also check what the actual product_line field value is in StarRocks
print()
print("=== Products in StarRocks dump matching 2LA26S004-FA160 ===")
try:
cur.execute("SELECT * FROM sr__test_db__magento_product_dimension_with_text_embedding WHERE sku = '2LA26S004-FA160' LIMIT 3")
sr = cur.fetchall()
if sr:
for r in sr:
keys = r.keys()
print({k: r[k] for k in list(keys)[:15]})
else:
print("Not in StarRocks dump")
except Exception as e:
print("Error:", e)
from worker.stylist_engine import StylistEngine
def test_engine():
engine = StylistEngine()
print("Testing 6OT25S007...")
res1 = engine.compute_dynamic_rule_matches("6OT25S007")
print(f"Matches for 6OT25S007: {len(res1)}")
print("\nTesting 2LA26S003-SL388...")
res2 = engine.compute_dynamic_rule_matches("2LA26S003-SL388")
print(f"Matches for 2LA26S003-SL388: {len(res2)}")
if __name__ == "__main__":
test_engine()
import asyncio
from common.pool_wrapper import pool_wrapper
from worker.stylist_engine import StylistEngine
import sys
async def run():
await pool_wrapper.init_all()
engine = StylistEngine()
db_pool = pool_wrapper
print("Testing 6OT25S007...")
res1 = engine.generate_matches(db_pool, "6OT25S007")
print(f"Matches for 6OT25S007: {len(res1)}")
print("\nTesting 2LA26S003-SL388...")
res2 = engine.generate_matches(db_pool, "2LA26S003-SL388")
print(f"Matches for 2LA26S003-SL388: {len(res2)}")
await pool_wrapper.close_all()
asyncio.run(run())
"""
Validation script: Tìm tất cả SP đang phối sai tuổi / giới tính.
Output: Danh sách các cặp (source, target) bị lỗi.
"""
from worker.stylist_engine import StylistEngine
e = StylistEngine()
catalog = e._get_catalog()
catalog_map = {p['code']: p for p in catalog}
KID_KEYWORDS = ['boy', 'girl', 'bé', 'trẻ em']
def is_kid(product):
g = (product.get('gender') or '').lower()
ag = (product.get('age_group') or '').lower()
return any(k in g+ag for k in KID_KEYWORDS)
def is_unisex(g):
return 'unisex' in g.lower()
errors = []
ok_count = 0
total_pairs = 0
print("Computing matches for all products (this may take ~30s)...")
# Sample: run for first 50 products to validate fast; remove [:50] for full
sample = catalog # full catalog
for src in sample:
matches = e.compute_dynamic_rule_matches(src['code'])
src_kid = is_kid(src)
sg = (src.get('gender') or '').lower()
for occ, roles in matches.items():
for role, items in roles.items():
for it in items:
tgt = catalog_map.get(it['code'])
if not tgt:
continue
total_pairs += 1
tgt_kid = is_kid(tgt)
tg = (tgt.get('gender') or '').lower()
# Rule 1: Kids vs Adults
if src_kid != tgt_kid:
errors.append({
'type': 'AGE_MISMATCH',
'src': f"{src['code']} ({src.get('name','')[:30]}) gender={sg}",
'tgt': f"{it['code']} ({it.get('name','')[:30]}) gender={tg}",
'occ': occ, 'role': role
})
continue
# Rule 2: Gender mismatch (excluding unisex)
if sg and tg and not is_unisex(sg) and not is_unisex(tg) and sg != tg:
errors.append({
'type': 'GENDER_MISMATCH',
'src': f"{src['code']} ({src.get('name','')[:30]}) gender={sg}",
'tgt': f"{it['code']} ({it.get('name','')[:30]}) gender={tg}",
'occ': occ, 'role': role
})
continue
ok_count += 1
print(f"\n{'='*70}")
print(f"Total pairs checked : {total_pairs}")
print(f"OK pairs : {ok_count}")
print(f"ERRORS found : {len(errors)}")
print(f"{'='*70}")
if errors:
print("\n=== ERRORS (first 30) ===")
for i, err in enumerate(errors[:30]):
print(f"[{err['type']}] {err['src']}")
print(f" → {err['tgt']} ({err['occ']}/{err['role']})")
print()
else:
print("\n✅ ALL PAIRS CLEAN! Gender & Age match 100%")
...@@ -8,38 +8,38 @@ from fastapi import FastAPI ...@@ -8,38 +8,38 @@ from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from api.chatbot_route import router as chatbot_router from api.common.chatbot_route import router as chatbot_router
from api.check_history_route import router as check_history_router from api.history.check_history_route import router as check_history_router
from api.conservation_route import router as conservation_router from api.history.conservation_route import router as conservation_router
from api.mock_api_route import router as mock_router from api.common.mock_api_route import router as mock_router
from api.prompt_route import router as prompt_router from api.common.prompt_route import router as prompt_router
from api.stock_route import router as stock_router from api.product.stock_route import router as stock_router
from api.tool_prompt_route import router as tool_prompt_router from api.common.tool_prompt_route import router as tool_prompt_router
from api.n8n_api_route import router as n8n_router from api.common.n8n_api_route import router as n8n_router
from api.feedback_route import router as feedback_router from api.common.feedback_route import router as feedback_router
from api.text_to_sql_route import router as text_to_sql_router from api.api_sql.text_to_sql_route import router as text_to_sql_router
from api.dashboard_route import router as dashboard_router from api.common.dashboard_route import router as dashboard_router
from api.experiment_links_route import router as experiment_links_router from api.experiment_log.experiment_links_route import router as experiment_links_router
from api.product_route import router as product_router from api.product.product_route import router as product_router
from api.sql_chat_route import router as sql_chat_router from api.api_sql.sql_chat_route import router as sql_chat_router
from api.ai_store_search import router as ai_store_search_router from api.store_search.ai_store_search import router as ai_store_search_router
from api.ai_image_search import router as ai_image_search_router from api.image_search.ai_image_search import router as ai_image_search_router
from api.cache_route import router as cache_router from api.cache.cache_route import router as cache_router
from api.report_html_route import router as report_html_router from api.ai_report.report_html_route import router as report_html_router
from api.ai_sql_trace_route import router as ai_sql_trace_router from api.api_sql.ai_sql_trace_route import router as ai_sql_trace_router
from api.live_monitor_route import router as live_monitor_router from api.live_monitor.live_monitor_route import router as live_monitor_router
from api.prompt_optimizer_route import router as prompt_optimizer_router from api.prompt_optimizer.prompt_optimizer_route import router as prompt_optimizer_router
from api.user_simulator_route import router as user_simulator_router from api.user_simulator.user_simulator_route import router as user_simulator_router
from api.regression_test_route import router as regression_test_router from api.regression_test.regression_test_route import router as regression_test_router
from api.stress_test_route import router as stress_test_router from api.stress_test.stress_test_route import router as stress_test_router
from api.roadmap_flow_route import router as roadmap_flow_router from api.roadmap.roadmap_flow_route import router as roadmap_flow_router
from api.experiment_log_route import router as experiment_log_router from api.experiment_log.experiment_log_route import router as experiment_log_router
from api.auth_route import router as auth_router from api.common.auth_route import router as auth_router
from api.product_desc_route import router as product_desc_router from api.product_desc.product_desc_route import router as product_desc_router
from api.fashion_matches.router import router as fashion_matches_router from api.fashion_matches.router import router as fashion_matches_router
from api.bulk_ops_route import router as bulk_ops_router from api.product_desc.bulk_ops_route import router as bulk_ops_router
from api.user_insight_route import router as user_insight_router from api.api_sql.user_insight_route import router as user_insight_router
from api.reaction_simulator_route import router as reaction_simulator_router from api.reaction_simulator.reaction_simulator_route import router as reaction_simulator_router
from common.cache import redis_cache from common.cache import redis_cache
from common.event_bus import event_bus from common.event_bus import event_bus
from common.middleware import middleware_manager from common.middleware import middleware_manager
...@@ -195,45 +195,45 @@ app.include_router(auth_router) # Auth (login/me/logout) ...@@ -195,45 +195,45 @@ app.include_router(auth_router) # Auth (login/me/logout)
app.include_router(product_desc_router) # Ultra Description Manager app.include_router(product_desc_router) # Ultra Description Manager
app.include_router(fashion_matches_router) # Fashion Matches (AI Stylist Engine) app.include_router(fashion_matches_router) # Fashion Matches (AI Stylist Engine)
app.include_router(bulk_ops_router) # Bulk Search & Edit app.include_router(bulk_ops_router) # Bulk Search & Edit
from api.limit_route import router as limit_router from api.limit.limit_route import router as limit_router
app.include_router(limit_router) app.include_router(limit_router)
from api.ai_answer_sku import router as sku_search_router from api.sku_search.ai_answer_sku import router as sku_search_router
app.include_router(sku_search_router) # SKU Search Agent app.include_router(sku_search_router) # SKU Search Agent
from api.ai_tag_search import router as tag_search_router from api.tag_search.ai_tag_search import router as tag_search_router
app.include_router(tag_search_router) # Tag Search Agent app.include_router(tag_search_router) # Tag Search Agent
from api.lead_flow_route import router as lead_flow_router from api.lead_flow.lead_flow_route import router as lead_flow_router
app.include_router(lead_flow_router) # Lead Stage AI (Experiment) app.include_router(lead_flow_router) # Lead Stage AI (Experiment)
app.include_router(user_insight_router) # User Insight Dashboard app.include_router(user_insight_router) # User Insight Dashboard
app.include_router(reaction_simulator_router) # Reaction Simulator app.include_router(reaction_simulator_router) # Reaction Simulator
from api.canifa_product_api import router as canifa_product_router from api.product.canifa_product_api import router as canifa_product_router
app.include_router(canifa_product_router) # Canifa Product Proxy (GraphQL) app.include_router(canifa_product_router) # Canifa Product Proxy (GraphQL)
from api.ai_diagram_route import router as diagram_router from api.diagram_agent.ai_diagram_route import router as diagram_router
app.include_router(diagram_router) # AI Diagram Agent app.include_router(diagram_router) # AI Diagram Agent
from api.merge_history.merge_history_route import router as merge_history_router from api.merge_history.merge_history_route import router as merge_history_router
app.include_router(merge_history_router) # Mock merge history endpoints app.include_router(merge_history_router) # Mock merge history endpoints
from api.mock_auth_route import router as mock_auth_router from api.mock_fe.mock_auth_route import router as mock_auth_router
app.include_router(mock_auth_router) # Mock Auth (identity linking test) app.include_router(mock_auth_router) # Mock Auth (identity linking test)
from api.feedback_agent_route import router as feedback_agent_router from api.feedback_agent.feedback_agent_route import router as feedback_agent_router
app.include_router(feedback_agent_router) # Lõi Agent Rút Kinh Nghiệm (Langfuse -> Rules) app.include_router(feedback_agent_router) # Lõi Agent Rút Kinh Nghiệm (Langfuse -> Rules)
from api.social_inbox_route import router as social_inbox_router from api.social_inbox.social_inbox_route import router as social_inbox_router
app.include_router(social_inbox_router) # Social Inbox (Facebook/Instagram/TikTok → Learning Loop) app.include_router(social_inbox_router) # Social Inbox (Facebook/Instagram/TikTok → Learning Loop)
# ─── Phase 2: AI Content Pipeline ─────────────────────────────────────────── # ─── Phase 2: AI Content Pipeline ───────────────────────────────────────────
from api.notification_route import router as notification_router from api.common.notification_route import router as notification_router
app.include_router(notification_router) # In-app + Email + Webhook + Slack notifications app.include_router(notification_router) # In-app + Email + Webhook + Slack notifications
from api.content_approval_route import router as content_approval_router from api.content_approval.content_approval_route import router as content_approval_router
app.include_router(content_approval_router) # Content approval gate (draft → review → publish) app.include_router(content_approval_router) # Content approval gate (draft → review → publish)
from api.queue_route import router as queue_router from api.common.queue_route import router as queue_router
app.include_router(queue_router) # Post queue + Calendar scheduling app.include_router(queue_router) # Post queue + Calendar scheduling
from api.media_route import router as media_router from api.media_library.media_route import router as media_router
app.include_router(media_router) # Media library (upload/resize/serve) app.include_router(media_router) # Media library (upload/resize/serve)
from api.templates_route import router as templates_router from api.content_approval.templates_route import router as templates_router
app.include_router(templates_router) # Content templates + RSS feeds (ported from BrightBean) app.include_router(templates_router) # Content templates + RSS feeds (ported from BrightBean)
# ─── Start publish engine background loop ─────────────────────────────────── # ─── Start publish engine background loop ───────────────────────────────────
......
...@@ -4,9 +4,9 @@ ...@@ -4,9 +4,9 @@
════════════════════════════════════════════════ */ ════════════════════════════════════════════════ */
const OCC_LABELS = { const OCC_LABELS = {
di_lam_cong_so: "<i data-lucide='briefcase' class='icon-sm'></i> Đi làm công sở", cong_so: "<i data-lucide='briefcase' class='icon-sm'></i> Đi làm công sở",
di_choi_dao_pho: "<i data-lucide='shopping-bag' class='icon-sm'></i> Đi chơi / dạo phố", di_choi: "<i data-lucide='shopping-bag' class='icon-sm'></i> Đi chơi / dạo phố",
o_nha_mac_ngu: "<i data-lucide='home' class='icon-sm'></i> Ở nhà / mặc ngủ", mac_nha: "<i data-lucide='home' class='icon-sm'></i> Ở nhà / mặc ngủ",
du_lich: "<i data-lucide='plane' class='icon-sm'></i> Du lịch", du_lich: "<i data-lucide='plane' class='icon-sm'></i> Du lịch",
}; };
...@@ -67,6 +67,7 @@ async function loadList(page) { ...@@ -67,6 +67,7 @@ async function loadList(page) {
return; return;
} }
window.currentList = items;
listEl.innerHTML = items.map(p => renderListItem(p)).join(''); listEl.innerHTML = items.map(p => renderListItem(p)).join('');
} catch (e) { } catch (e) {
listEl.innerHTML = '<div class="empty-state" style="padding:20px;"><p style="color:var(--error);font-size:12px;">Lỗi tải danh sách</p></div>'; listEl.innerHTML = '<div class="empty-state" style="padding:20px;"><p style="color:var(--error);font-size:12px;">Lỗi tải danh sách</p></div>';
...@@ -75,8 +76,11 @@ async function loadList(page) { ...@@ -75,8 +76,11 @@ async function loadList(page) {
function renderListItem(p) { function renderListItem(p) {
const hasMatches = p.has_ai_matches; const hasMatches = p.has_ai_matches;
const badge = hasMatches // 0 = chưa chạy, 1 = có match, 2 = đã quét nhưng catalog rỗng
const badge = hasMatches === 1
? `<span class="badge badge-success" style="font-size:9px;">✓ Phối đồ</span>` ? `<span class="badge badge-success" style="font-size:9px;">✓ Phối đồ</span>`
: hasMatches === 2
? `<span class="badge badge-info" style="font-size:9px;opacity:.85;">~ Đã quét</span>`
: `<span class="badge badge-muted" style="font-size:9px;">Chưa có</span>`; : `<span class="badge badge-muted" style="font-size:9px;">Chưa có</span>`;
const activeClass = p.code === currentCode ? 'active' : ''; const activeClass = p.code === currentCode ? 'active' : '';
return ` return `
...@@ -97,7 +101,10 @@ function changePage(delta) { ...@@ -97,7 +101,10 @@ function changePage(delta) {
loadList(newPage); loadList(newPage);
} }
// ══ PRODUCT DETAIL ════════════════════════════ // ── PRODUCT LOAD & DETAIL VIEW ──
function closeDetail() {
document.body.classList.remove('show-detail');
}
async function loadProduct(code) { async function loadProduct(code) {
currentCode = code; currentCode = code;
...@@ -110,6 +117,7 @@ async function loadProduct(code) { ...@@ -110,6 +117,7 @@ async function loadProduct(code) {
// Show loading state // Show loading state
document.getElementById('welcomeState').style.display = 'none'; document.getElementById('welcomeState').style.display = 'none';
document.getElementById('detailContent').style.display = 'flex'; document.getElementById('detailContent').style.display = 'flex';
document.body.classList.add('show-detail');
try { try {
// 1. Product meta from ultra-desc API // 1. Product meta from ultra-desc API
...@@ -270,41 +278,88 @@ function renderMatchContent() { ...@@ -270,41 +278,88 @@ function renderMatchContent() {
</div>`; </div>`;
} else { } else {
// Nested Roles Render // Nested Roles Render
const roles = ['bottom', 'outerwear', 'accessory']; const roles = ['bottom', 'outerwear', 'accessory', 'top'];
container.innerHTML = roles.map(role => { container.innerHTML = roles.map(role => {
const items = occData[role] || []; const items = occData[role] || [];
const roleInfo = ROLE_LABELS[role] || { label: role, emoji: '📦' }; const roleInfo = ROLE_LABELS[role] || { label: role, emoji: '📦' };
const cards = items.map((item, idx) => renderMatchCard(item, activeGroupTab, role, idx)).join(''); // Render all items up to 20 to prevent DOM overload
const addBtn = `<div class="add-card" onclick="openAddModal('${activeGroupTab}','${role}')"> const MAX_ITEMS = 20;
const INIT_SHOW = 4; // Show 4 items initially
const renderItems = items.slice(0, MAX_ITEMS);
let visibleCards = '';
let hiddenCards = '';
renderItems.forEach((item, idx) => {
const cardHtml = renderMatchCard(item, activeGroupTab, role, idx);
if (idx < INIT_SHOW) {
visibleCards += cardHtml;
} else {
hiddenCards += cardHtml;
}
});
const addBtn = `<div class="add-card" onclick="openAddModal('${activeGroupTab}','${role}')">
<div class="add-card-icon">➕</div> <div class="add-card-icon">➕</div>
<div class="add-card-label">Thêm SP</div> <div class="add-card-label">Thêm SP</div>
</div>`; </div>`;
const roleKey = `${activeGroupTab}_${role}`;
// Show more button container placed OUTSIDE the body
const showMoreBtn = hiddenCards ?
`<div style="padding: 0 14px 14px;">
<button class="btn-show-more" id="showmore-${roleKey}" onclick="toggleShowMore('${roleKey}')">
Xem thêm ${renderItems.length - INIT_SHOW} sản phẩm ▾
</button>
</div>` : '';
return `<div class="role-section"> return `<div class="role-section">
<div class="role-header"> <div class="role-header">
<div class="role-title">${roleInfo.emoji} ${roleInfo.label} <span class="badge badge-info">${items.length}</span></div> <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('${activeGroupTab}','${role}')">+ Thêm</button> <button class="btn btn-ghost btn-sm" onclick="openAddModal('${activeGroupTab}','${role}')">+ Thêm</button>
</div> </div>
<div class="role-body">${cards}${addBtn}</div> <div class="role-body" id="role-body-${roleKey}">
${visibleCards}
<div class="hidden-cards" id="hidden-${roleKey}" style="display:none;">${hiddenCards}</div>
${addBtn}
</div>
${showMoreBtn}
</div>`; </div>`;
}).join(''); }).join('');
} }
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
} }
function toggleShowMore(roleKey) {
const hiddenEl = document.getElementById(`hidden-${roleKey}`);
const btn = document.getElementById(`showmore-${roleKey}`);
if (!hiddenEl || !btn) return;
const isHidden = hiddenEl.style.display === 'none';
const count = hiddenEl.querySelectorAll('.match-card').length;
if (isHidden) {
hiddenEl.style.display = 'contents';
btn.textContent = 'Thu gọn ▴';
} else {
hiddenEl.style.display = 'none';
btn.textContent = `Xem thêm ${count} sản phẩm ▾`;
}
if (window.lucide) lucide.createIcons();
}
function renderMatchCard(item, occ, role, idx) { function renderMatchCard(item, occ, role, idx) {
const score = item.score || 0; const score = item.score || 0;
const scoreCls = score >= 75 ? 'score-high' : score >= 55 ? 'score-mid' : 'score-low'; const scoreCls = score >= 75 ? 'score-high' : score >= 55 ? 'score-mid' : 'score-low';
const imgHtml = item.image const imgHtml = item.image
? `<img class="match-img" src="${esc(item.image)}" onerror="this.parentElement.innerHTML='<div class=match-img-placeholder>👗</div>'">` ? `<img class="match-img" style="height:110px; object-fit:cover; width:100%;" src="${esc(item.image)}" onerror="this.parentElement.innerHTML='<div class=match-img-placeholder style=\\'height:110px\\'>👗</div>'">`
: `<div class="match-img-placeholder">👗</div>`; : `<div class="match-img-placeholder" style="height:110px">👗</div>`;
return `<div class="match-card"> return `<div class="match-card" style="display:flex; flex-direction:column; justify-content:flex-start;">
${imgHtml} ${imgHtml}
<div class="match-info"> <div class="match-info" style="flex:1;">
<div class="match-name">${esc(item.name || '')}</div> <div class="match-name">${esc(item.name || '---')}</div>
<div class="match-code">${esc(item.code || '')}</div> <div class="match-code">${esc(item.code || '')}</div>
<div class="match-reason">${esc(item.reason || '')}</div> <div class="match-reason" title="${esc(item.reason || '')}">${esc(item.reason || '')}</div>
</div> </div>
<div class="match-footer"> <div class="match-footer" style="margin-top:auto;">
<span class="match-score ${scoreCls}">${score}đ</span> <span class="match-score ${scoreCls}">${score}đ</span>
<button class="btn-remove" onclick="removeItem('${occ}','${role}',${idx})">✕</button> <button class="btn-remove" onclick="removeItem('${occ}','${role}',${idx})">✕</button>
</div> </div>
...@@ -411,6 +466,45 @@ async function regenOne() { ...@@ -411,6 +466,45 @@ async function regenOne() {
finally { btn.textContent = '🤖 AI Regen'; btn.disabled = false; } finally { btn.textContent = '🤖 AI Regen'; btn.disabled = false; }
} }
window.triggerMagicFix = async function() {
const btn = document.getElementById('btnMagicFix');
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" class="icon-sm" style="animation: spin 1s linear infinite;"></i> Đang quét...`;
lucide.createIcons();
if (!window.currentList) window.currentList = [];
let targets = currentList.filter(p => p.has_ai_matches !== 1);
if (targets.length === 0) {
showToast('Tất cả sản phẩm trên trang này đã chuẩn (Xanh)!', 'success');
btn.innerHTML = `<i data-lucide="wand-2" class="icon-sm"></i> Fix Lỗi`;
btn.disabled = false;
lucide.createIcons();
return;
}
try {
const codes = targets.map(t => t.code);
const resp = await fetch(`/api/fashion-matches/batch-regen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codes })
});
const js = await resp.json();
if (js.ok) {
showToast(`✨ Đã Fix xong cụm ${targets.length} lỗi trên trang!`, 'success');
}
} catch(e) {
showToast('Có lỗi xảy ra', 'error');
}
btn.innerHTML = `<i data-lucide="wand-2" class="icon-sm"></i> Fix Lỗi`;
btn.disabled = false;
lucide.createIcons();
// Reload current list page to reflect updated DB states visually
loadList(currentPage);
}
async function triggerBatch() { async function triggerBatch() {
const btn = document.getElementById('btnBatchRegen'); const btn = document.getElementById('btnBatchRegen');
if (!confirm('Chạy AI Stylist Engine cho toàn bộ sản phẩm?\nCó thể mất vài phút.')) return; if (!confirm('Chạy AI Stylist Engine cho toàn bộ sản phẩm?\nCó thể mất vài phút.')) return;
...@@ -419,8 +513,8 @@ async function triggerBatch() { ...@@ -419,8 +513,8 @@ async function triggerBatch() {
const res = await fetch('/api/fashion-matches/batch', { method: 'POST' }); const res = await fetch('/api/fashion-matches/batch', { method: 'POST' });
const j = await res.json(); const j = await res.json();
if (j.ok) { showToast('🤖 Batch đang chạy...', 'success'); startBatchPolling(); } if (j.ok) { showToast('🤖 Batch đang chạy...', 'success'); startBatchPolling(); }
else { showToast(`❌ ${j.error}`, 'error'); btn.textContent = '🤖 Batch AI'; btn.disabled = false; } else { showToast(`❌ ${j.error}`, 'error'); btn.textContent = '🤖 Batch'; btn.disabled = false; }
} catch { showToast('❌ Lỗi', 'error'); btn.textContent = '🤖 Batch AI'; btn.disabled = false; } } catch { showToast('❌ Lỗi', 'error'); btn.textContent = '🤖 Batch'; btn.disabled = false; }
} }
function startBatchPolling() { function startBatchPolling() {
......
This diff is collapsed.
<!DOCTYPE html> <!DOCTYPE html>
<html lang="vi"> <html lang="vi">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/static/common/components.css"> <link rel="stylesheet" href="/static/common/components.css">
<script src="/static/common/frame-detect.js"></script> <script src="/static/common/frame-detect.js"></script>
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="/static/fashion-matches/style.css?v=3"> <link rel="stylesheet" href="/static/fashion-matches/style.css?v=4">
<style> <style>
.lucide { vertical-align: middle; } .lucide { vertical-align: middle; }
.icon-sm { width: 14px; height: 14px; } .icon-sm { width: 14px; height: 14px; }
...@@ -29,6 +29,7 @@ ...@@ -29,6 +29,7 @@
<div style="font-weight:700;font-size:15px;color:var(--foreground);">Sản phẩm</div> <div style="font-weight:700;font-size:15px;color:var(--foreground);">Sản phẩm</div>
<div style="display:flex; gap:6px;"> <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 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="btnMagicFix" class="btn btn-sm" style="background:#10b981;color:white;" onclick="triggerMagicFix()"><i data-lucide="wand-2" class="icon-sm"></i> Fix Lỗi</button>
<button id="btnBatchRegen" class="btn btn-primary btn-sm" onclick="triggerBatch()"><i data-lucide="play" class="icon-sm"></i> Batch</button> <button id="btnBatchRegen" class="btn btn-primary btn-sm" onclick="triggerBatch()"><i data-lucide="play" class="icon-sm"></i> Batch</button>
</div> </div>
</div> </div>
...@@ -96,6 +97,7 @@ ...@@ -96,6 +97,7 @@
<!-- Product header --> <!-- Product header -->
<div class="fm-detail-header"> <div class="fm-detail-header">
<button class="btn btn-ghost btn-sm" onclick="closeDetail()" style="margin-right:12px; padding:6px; height:100%;"><i data-lucide="arrow-left" class="icon-lg"></i></button>
<img id="prodImage" src="" alt="" class="prod-thumb" onerror="this.style.display='none'"> <img id="prodImage" src="" alt="" class="prod-thumb" onerror="this.style.display='none'">
<div class="fm-detail-info"> <div class="fm-detail-info">
<div class="fm-prod-name" id="prodName"></div> <div class="fm-prod-name" id="prodName"></div>
......
...@@ -235,3 +235,24 @@ ...@@ -235,3 +235,24 @@
.fm-toast.show { opacity: 1; transform: translateY(0); } .fm-toast.show { opacity: 1; transform: translateY(0); }
.fm-toast.success { border-color: var(--success); } .fm-toast.success { border-color: var(--success); }
.fm-toast.error { border-color: var(--error); } .fm-toast.error { border-color: var(--error); }
/* -- Show More Button -- */
.btn-show-more {
display: flex; align-items: center; justify-content: center;
width: 100%; margin: 8px 0 16px; padding: 10px 16px;
border-radius: 8px; border: 1px dashed var(--border);
background: linear-gradient(180deg, var(--card) 0%, rgba(244, 244, 245, 0.5) 100%);
color: var(--foreground); font-size: 13px; font-weight: 500;
cursor: pointer; transition: all .2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
}
.btn-show-more:hover {
border-color: var(--primary); color: var(--primary); border-style: solid;
background: linear-gradient(180deg, var(--card) 0%, rgba(59, 130, 246, 0.05) 100%);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
transform: translateY(-1px);
}
.btn-show-more:active {
transform: translateY(0); box-shadow: 0 1px 2px rgba(59, 130, 246, 0.1);
}
.hidden-cards { display: flex; flex-wrap: wrap; gap: 12px; width: 100%; margin-top: 12px; }
...@@ -8,7 +8,7 @@ from typing import List, Optional ...@@ -8,7 +8,7 @@ from typing import List, Optional
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from common.cache import redis_cache from common.cache import redis_cache
from api.bulk_ops_route import get_pooled_connection_compat, _render_description_text, _FIELD_LABELS, _call_codex from api.product_desc.bulk_ops_route import get_pooled_connection_compat, _render_description_text, _FIELD_LABELS, _call_codex
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%y-%m-%d %H:%M:%S") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%y-%m-%d %H:%M:%S")
logger = logging.getLogger("ai_ops_worker") logger = logging.getLogger("ai_ops_worker")
......
...@@ -4,7 +4,7 @@ import logging ...@@ -4,7 +4,7 @@ import logging
import sys import sys
from common.cache import redis_cache from common.cache import redis_cache
from api.product_desc_route import generate_description, GenerateRequest from api.product_desc.product_desc_route import generate_description, GenerateRequest
logging.basicConfig(level=logging.INFO, format="20%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%y-%m-%d %H:%M:%S") logging.basicConfig(level=logging.INFO, format="20%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%y-%m-%d %H:%M:%S")
logger = logging.getLogger("batch_worker") logger = logging.getLogger("batch_worker")
......
...@@ -86,10 +86,10 @@ ...@@ -86,10 +86,10 @@
"accessory": 4 "accessory": 4
}, },
"role_max_items": { "role_max_items": {
"top": 3, "top": 20,
"bottom": 3, "bottom": 20,
"outerwear": 2, "outerwear": 20,
"accessory": 2 "accessory": 20
}, },
"_comment_product_line_to_role": "Ánh xạ tên product_line_vn → role trong outfit", "_comment_product_line_to_role": "Ánh xạ tên product_line_vn → role trong outfit",
"product_line_to_role": { "product_line_to_role": {
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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