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

feat(outfit): improve outfit recommendations with limited items per role and add pairing logic UI

- Reduce role_max_items from 20 to 2-3 to avoid duplicate items
- Simplify deduplicate logic to take top N by score
- Add outfit_pairing_data.json with 59 products and 250 pairings
- Add outfit-logic UI visualizer for fashion rules
- Fix main_router.py indentation for pairing_router
parent bd6f75f7
from fastapi import APIRouter from fastapi import APIRouter
from api.ai_report.report_html_route import router as report_html_router from api.ai_report.report_html_route import router as report_html_router
from api.api_sql.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
...@@ -30,6 +30,7 @@ from api.experiment_log.experiment_log_route import router as experiment_log_rou ...@@ -30,6 +30,7 @@ from api.experiment_log.experiment_log_route import router as experiment_log_rou
from api.fashion_matches.outfit_matches_route import router as outfit_matches_router from api.fashion_matches.outfit_matches_route import router as outfit_matches_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.fashion_matches.simulator import router as fashion_matches_simulator_router from api.fashion_matches.simulator import router as fashion_matches_simulator_router
from api.outfit_match_rules.pairing_api import router as pairing_router
from api.feedback_agent.feedback_agent_route import router as feedback_agent_router from api.feedback_agent.feedback_agent_route import router as feedback_agent_router
# History & Auth # History & Auth
from api.history.check_history_route import router as check_history_router from api.history.check_history_route import router as check_history_router
...@@ -94,6 +95,7 @@ api_router.include_router(tags_direct_router) ...@@ -94,6 +95,7 @@ api_router.include_router(tags_direct_router)
api_router.include_router(fashion_matches_router) api_router.include_router(fashion_matches_router)
api_router.include_router(fashion_matches_simulator_router) api_router.include_router(fashion_matches_simulator_router)
api_router.include_router(outfit_matches_router) api_router.include_router(outfit_matches_router)
api_router.include_router(pairing_router)
api_router.include_router(ai_store_search_router) api_router.include_router(ai_store_search_router)
api_router.include_router(ai_image_search_router) api_router.include_router(ai_image_search_router)
......
This diff is collapsed.
This diff is collapsed.
<h2 class="sr-only">Bảng logic phối đồ Canifa – 59 sản phẩm theo giới tính</h2>
<style>
.tg{display:inline-block;font-size:11px;padding:2px 7px;border-radius:4px;margin:1px 2px;white-space:nowrap;font-weight:500;line-height:1.6}
.t-t{background:#E1F5EE;color:#085041}.t-b{background:#E6F1FB;color:#0C447C}
.t-o{background:#EEEDFE;color:#3C3489}.t-a{background:#FAEEDA;color:#633806}
.t-u{background:#FBEAF0;color:#72243E}.t-h{background:#F1EFE8;color:#444441}
.gb{display:inline-block;font-size:10px;padding:1px 5px;border-radius:3px;margin:1px;white-space:nowrap;font-weight:500}
.gm{background:#E6F1FB;color:#0C447C}.gw{background:#FBEAF0;color:#72243E}
.gbo{background:#E1F5EE;color:#085041}.gg{background:#FAEEDA;color:#633806}
.gu{background:#EEEDFE;color:#3C3489}.go{background:#F1EFE8;color:#444441}
.fb{padding:5px 12px;border-radius:var(--border-radius-md);border:0.5px solid var(--color-border-secondary);background:transparent;cursor:pointer;font-size:12px;color:var(--color-text-secondary);white-space:nowrap}
.fb.on{background:var(--color-background-info);color:var(--color-text-info);border-color:transparent}
#tbl td,#tbl th{padding:7px 10px;vertical-align:top;font-size:13px;border-bottom:0.5px solid var(--color-border-tertiary)}
#tbl th{font-size:11px;font-weight:500;color:var(--color-text-secondary);text-transform:uppercase;letter-spacing:.03em;border-bottom:0.5px solid var(--color-border-primary)}
#tbl tr:hover td{background:var(--color-background-secondary)}
</style>
<div style="padding:.75rem 0">
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px;align-items:center">
<span style="font-size:12px;color:var(--color-text-secondary);margin-right:4px">Loại tag:</span>
<span class="tg t-t">Top (áo trong)</span><span class="tg t-b">Bottom (quần/váy)</span>
<span class="tg t-o">Khoác ngoài</span><span class="tg t-a">Phụ kiện</span>
<span class="tg t-u">Đồ lót</span><span class="tg t-h">Nhà/khác</span>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">
<button class="fb on" onclick="sg(this,'all')">Tất cả</button>
<button class="fb" onclick="sg(this,'men')">Nam (men)</button>
<button class="fb" onclick="sg(this,'women')">Nữ (women)</button>
<button class="fb" onclick="sg(this,'boy')">Bé trai (boy)</button>
<button class="fb" onclick="sg(this,'girl')">Bé gái (girl)</button>
<button class="fb" onclick="sg(this,'unisex')">Unisex</button>
</div>
<input type="text" id="q" placeholder="Tìm sản phẩm..." oninput="rd()" style="width:100%;box-sizing:border-box;margin-bottom:8px">
<div id="st" style="font-size:12px;color:var(--color-text-secondary);margin-bottom:8px"></div>
<div style="overflow-x:auto;border:0.5px solid var(--color-border-tertiary);border-radius:var(--border-radius-lg)">
<table id="tbl" style="width:100%;border-collapse:collapse">
<thead><tr>
<th style="min-width:130px">Sản phẩm</th>
<th style="min-width:130px">Giới tính</th>
<th>Phối với</th>
</tr></thead>
<tbody id="tb"></tbody>
</table>
</div></div>
<script>
let PG = {};
let D = [];
let TI = {t:{c:'t-t',l:'top'},b:{c:'t-b',l:'bottom'},o:{c:'t-o',l:'khoác'},a:{c:'t-a',l:'phụ kiện'},u:{c:'t-u',l:'đồ lót'},h:{c:'t-h',l:'nhà/khác'}};
let GCL = {men:'gm',women:'gw',boy:'gbo',girl:'gg',unisex:'gu',others:'go'};
let cg = 'all';
// Fetch data from API
async function loadData() {
try {
const res = await fetch('/api/outfit-match-rules/pairings');
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to load');
// Get product genders
const gendersRes = await fetch('/api/outfit-match-rules/products');
const gendersData = await gendersRes.json();
PG = gendersData.product_genders || {};
// Transform pairings to D format
D = [];
const pairingsByAnchor = {};
data.pairings.forEach(p => {
if (!pairingsByAnchor[p.anchor]) {
pairingsByAnchor[p.anchor] = [];
}
pairingsByAnchor[p.anchor].push([p.target, p.type]);
});
// Convert to array format matching original D structure
Object.keys(pairingsByAnchor).sort().forEach(anchor => {
D.push({ n: anchor, p: pairingsByAnchor[anchor] });
});
rd();
} catch(e) {
console.error('Error loading data:', e);
document.getElementById('tb').innerHTML = '<tr><td colspan="3" style="text-align:center;color:red;padding:2rem">Lỗi tải dữ liệu</td></tr>';
}
}
function cs(a,t,g){
if(g==='all')return true;
const aG=PG[a]||[];
if(!aG.includes(g))return false;
const tG=PG[t]||['others'];
if(g==='unisex')return tG.some(x=>['men','women','unisex','others'].includes(x));
return tG.includes(g)||tG.includes('unisex')||tG.includes('others');
}
function rd(){
const q=document.getElementById('q').value.toLowerCase();
let rows='',shown=0;
D.forEach(item=>{
const aG=PG[item.n]||['others'];
if(cg!=='all'&&!aG.includes(cg))return;
if(q&&!item.n.toLowerCase().includes(q))return;
const pairs=item.p.filter(([t])=>cs(item.n,t,cg));
if(!pairs.length&&cg!=='all')return;
shown++;
const gb=aG.map(g=>`<span class="gb ${GCL[g]||'go'}">${g}</span>`).join('');
const pt=pairs.map(([t,tp])=>{const i=TI[tp]||TI.h;return`<span class="tg ${i.c}">${t}</span>`;}).join('');
rows+=`<tr><td style="font-weight:500;color:var(--color-text-primary);white-space:nowrap">${item.n}</td><td>${gb}</td><td>${pt}</td></tr>`;
});
document.getElementById('tb').innerHTML=rows||'<tr><td colspan="3" style="text-align:center;color:var(--color-text-secondary);padding:2rem">Không tìm thấy</td></tr>';
document.getElementById('st').textContent=`Hiển thị ${shown} sản phẩm`;
}
function sg(btn,g){
cg=g;
document.querySelectorAll('.fb').forEach(b=>b.classList.remove('on'));
btn.classList.add('on');
rd();
}
rd();
</script>
...@@ -86,10 +86,11 @@ ...@@ -86,10 +86,11 @@
"accessory": 4 "accessory": 4
}, },
"role_max_items": { "role_max_items": {
"top": 20, "top": 3,
"bottom": 20, "bottom": 3,
"outerwear": 20, "outerwear": 2,
"accessory": 20 "accessory": 2,
"underwear": 2
}, },
"_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": {
......
...@@ -286,7 +286,7 @@ class StylistEngine: ...@@ -286,7 +286,7 @@ class StylistEngine:
target_gender = SQLITE_GENDER_MAP.get(gender_key, "unisex") target_gender = SQLITE_GENDER_MAP.get(gender_key, "unisex")
cur.execute( cur.execute(
f"""SELECT id, occasion_tag as occasion, match_role, target_category, ai_reason f"""SELECT id, occasion as occasion, match_role, target_category, ai_reason
FROM {TABLE_OUTFIT_RULES} FROM {TABLE_OUTFIT_RULES}
WHERE UPPER(anchor_category) = UPPER(?) WHERE UPPER(anchor_category) = UPPER(?)
AND (gender_target = ? OR gender_target = 'unisex' OR gender_target = 'all')""", AND (gender_target = ? OR gender_target = 'unisex' OR gender_target = 'all')""",
...@@ -517,7 +517,7 @@ class StylistEngine: ...@@ -517,7 +517,7 @@ class StylistEngine:
# 3. Occasion score (0-20) - Reactivated to use NLP tags # 3. Occasion score (0-20) - Reactivated to use NLP tags
if "occasion" in weights: if "occasion" in weights:
occ_score = self._occasion_score(src.get("occasion_tags", []), tgt.get("occasion_tags", []), occasion) occ_score = self._occasion_score(src.get("occasion", []), tgt.get("occasion", []), occasion)
total += int(occ_score * weights["occasion"] / 20) total += int(occ_score * weights["occasion"] / 20)
# 4. Role bonus — Bottom gets priority over accessories (0-15) # 4. Role bonus — Bottom gets priority over accessories (0-15)
...@@ -747,25 +747,8 @@ class StylistEngine: ...@@ -747,25 +747,8 @@ class StylistEngine:
def _deduplicate(self, items: list[dict], max_n: int) -> list[dict]: def _deduplicate(self, items: list[dict], max_n: int) -> list[dict]:
"""Remove near-duplicates logic (Enforce product diversity + color diversity).""" """Take top N items by score (already sorted). Simple and effective."""
seen_lines: set[str] = set() return items[:max_n]
seen_colors: set[str] = set()
result = []
for item in items:
pl = item.get("product_line", item["code"])
# Canonicalize color slightly for filtering
color_val = self._detect_color_key(item.get("color", ""), self.rules["color_keys"]) or item.get("color", "")
# Prioritize inserting if we haven't seen this product line OR color yet
if pl not in seen_lines or color_val not in seen_colors or len(result) < max_n // 2:
result.append(item)
seen_lines.add(pl)
if color_val:
seen_colors.add(color_val)
if len(result) >= max_n:
break
return result
# ────────────────────────────────────────── # ──────────────────────────────────────────
# Data Loading # Data Loading
...@@ -811,7 +794,7 @@ class StylistEngine: ...@@ -811,7 +794,7 @@ class StylistEngine:
"age_group": row["age_group"] or "", "age_group": row["age_group"] or "",
"image": row["image"] or "", "image": row["image"] or "",
"style_tags": [], "style_tags": [],
"occasion_tags": [], "occasion": [],
"material_tags": [], "material_tags": [],
"season_tags": [], "season_tags": [],
}) })
...@@ -874,7 +857,7 @@ class StylistEngine: ...@@ -874,7 +857,7 @@ class StylistEngine:
item["role"], # match_role item["role"], # match_role
item["score"], item["score"],
item["reason"], item["reason"],
item["occasion"], # occasion (NOT occasion_tag) item["occasion"],
)) ))
if rows: if rows:
...@@ -915,7 +898,7 @@ class StylistEngine: ...@@ -915,7 +898,7 @@ class StylistEngine:
occ_pts = 0 occ_pts = 0
raw_occ = 0 raw_occ = 0
if "occasion" in weights: if "occasion" in weights:
raw_occ = self._occasion_score(src.get("occasion_tags", []), tgt.get("occasion_tags", []), occ_target) raw_occ = self._occasion_score(src.get("occasion", []), tgt.get("occasion", []), occ_target)
occ_pts = int(raw_occ * weights["occasion"] / 20) occ_pts = int(raw_occ * weights["occasion"] / 20)
# Role # Role
......
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