Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
A
ai company
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
ai company
Commits
496de116
Commit
496de116
authored
Apr 22, 2026
by
Vũ Hoàng Anh
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(fashion-matches): add simulator inspector and tag audit dashboard
parent
27f5c793
Changes
5
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
1338 additions
and
142 deletions
+1338
-142
router.py
backend/api/fashion_matches/router.py
+315
-63
simulator.py
backend/api/fashion_matches/simulator.py
+192
-64
index.html
backend/static/fashion-matches/index.html
+3
-0
live-simulator.html
backend/static/fashion-matches/live-simulator.html
+401
-15
tag-audit.html
backend/static/fashion-matches/tag-audit.html
+427
-0
No files found.
backend/api/fashion_matches/router.py
View file @
496de116
...
...
@@ -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,19 +261,21 @@ 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
)
# Return empty ai_matches if not found (or gracefully handle) to avoid UI breaking
if
data
is
None
:
data
=
{}
...
...
@@ -281,19 +294,22 @@ 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
()
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
:
...
...
@@ -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
,
"hex"
:
color_hex_map
.
get
(
key
,
"#CCCCCC"
),
"keywords"
:
variants
,
})
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
(
...
...
@@ -423,7 +453,7 @@ async def outfit_suggest(req: OutfitSuggestRequest):
src_color
=
src_product
.
get
(
"color"
,
""
)
src_key
=
_detect_color_key
(
src_color
,
color_keys
)
src_group
=
rules
.
get
(
"color_groups"
,
{})
.
get
(
src_key
)
if
src_key
else
None
desc_data
=
src_product
.
get
(
"description_data"
,
{})
src_name
=
src_product
.
get
(
"name"
,
""
)
src_line
=
src_product
.
get
(
"product_line"
,
""
)
...
...
@@ -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
({
**
item
,
"color_key"
:
item_color_key
,
"color_group"
:
rules
.
get
(
"color_groups"
,
{})
.
get
(
item_color_key
,
"?"
)
if
item_color_key
else
"?"
,
"color_synergy"
:
color_info
,
})
enriched
.
append
(
{
**
item
,
"color_key"
:
item_color_key
,
"color_group"
:
rules
.
get
(
"color_groups"
,
{})
.
get
(
item_color_key
,
"?"
)
if
item_color_key
else
"?"
,
"color_synergy"
:
color_info
,
}
)
slots
[
role
]
=
enriched
if
slots
:
outfit_by_occasion
[
occ
]
=
{
...
...
@@ -462,7 +496,7 @@ async def outfit_suggest(req: OutfitSuggestRequest):
}
color_strategy
=
_build_color_strategy
(
src_key
,
src_group
,
rules
)
# Inject our brand new SQL-based super classification matches
classifications
=
engine
.
compute_super_classifications_sql
(
req
.
code
)
...
...
@@ -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"
,
}
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"
}
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"]} → {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"]} → {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 → 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}}
...
...
backend/api/fashion_matches/simulator.py
View file @
496de116
import
asyncio
import
json
import
logging
from
typing
import
Optional
from
fastapi
import
APIRouter
,
Query
from
fastapi.responses
import
StreamingResponse
,
JSON
Response
from
fastapi.responses
import
JSONResponse
,
Streaming
Response
from
worker.stylist_engine
import
StylistEngine
...
...
@@ -12,32 +11,114 @@ 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"""
try
:
engine
=
StylistEngine
()
catalog
=
engine
.
_get_catalog
()
q_lower
=
q
.
lower
()
.
strip
()
results
=
[]
for
p
in
catalog
:
code
=
p
.
get
(
"code"
,
""
)
.
lower
()
ref
=
p
.
get
(
"internal_ref_code"
,
""
)
.
lower
()
name
=
p
.
get
(
"name"
,
""
)
.
lower
()
# Simple match logic
if
q_lower
in
code
or
q_lower
in
ref
or
q_lower
in
name
:
results
.
append
({
"code"
:
p
.
get
(
"code"
),
"name"
:
p
.
get
(
"name"
),
"image"
:
p
.
get
(
"image"
,
""
),
"color"
:
p
.
get
(
"color"
,
""
)
})
results
.
append
(
{
"code"
:
p
.
get
(
"code"
),
"name"
:
p
.
get
(
"name"
),
"image"
:
p
.
get
(
"image"
,
""
),
"color"
:
p
.
get
(
"color"
,
""
),
}
)
if
len
(
results
)
>=
10
:
break
return
{
"ok"
:
True
,
"data"
:
results
}
except
Exception
as
e
:
logger
.
error
(
"[Simulator] Search error:
%
s"
,
e
)
...
...
@@ -47,17 +128,16 @@ async def search_product(q: str = Query(..., description="Mã SP hoặc Tên SP"
@
router
.
get
(
"/stream"
)
async
def
stream_flow
(
code
:
str
=
Query
(
...
,
description
=
"Product code to simulate"
)):
"""SSE Endpoint cho Realtime Live Simulator"""
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
await
asyncio
.
sleep
(
0.8
)
# Dramatic delay
engine
=
StylistEngine
()
catalog
=
engine
.
_get_catalog
()
...
...
@@ -68,18 +148,22 @@ 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
({
"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')})"
,
"payload"
:
{
"code"
:
source
.
get
(
"code"
),
"name"
:
source
.
get
(
"name"
),
"image"
:
source
.
get
(
"image"
),
"color"
:
source
.
get
(
"color"
),
"category"
:
source
.
get
(
"product_line"
)
}
},
ensure_ascii
=
False
)
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')})"
,
"payload"
:
{
"code"
:
source
.
get
(
"code"
),
"name"
:
source
.
get
(
"name"
),
"image"
:
source
.
get
(
"image"
),
"color"
:
source
.
get
(
"color"
),
"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
)
...
...
@@ -88,59 +172,103 @@ async def stream_flow(code: str = Query(..., description="Product code to simula
gender
=
source
.
get
(
"gender"
,
""
)
db_rules
=
engine
.
_fetch_rules_with_reason
(
anchor_cat
,
gender
)
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."
msg
=
json
.
dumps
({
"step"
:
3
,
"node"
:
"fetch_rules"
,
"status"
:
status_rules
,
"payload"
:
{
"rules_count"
:
rules_count
}
},
ensure_ascii
=
False
)
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
(
{
"step"
:
3
,
"node"
:
"fetch_rules"
,
"status"
:
status_rules
,
"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
({
"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
)
yield
f
"data: {msg}
\n\n
"
# (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..."
,
"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
"
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
({
"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
)
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."
,
"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
({
"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
)
msg
=
json
.
dumps
(
{
"step"
:
6
,
"node"
:
"complete"
,
"status"
:
"✅ HOÀN TẤT! Đẩy khung Outfit JSON ra giao diện."
,
"payload"
:
{
"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"
)
backend/static/fashion-matches/index.html
View file @
496de116
...
...
@@ -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>
...
...
backend/static/fashion-matches/live-simulator.html
View file @
496de116
...
...
@@ -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
:
64
0px
;
max-width
:
120
0px
;
margin
:
0
auto
;
padding
:
40px
16px
80px
;
}
.sim-grid
{
display
:
grid
;
grid-template-columns
:
minmax
(
0
,
1.2
fr
)
minmax
(
300px
,
0.8
fr
);
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
:
1
fr
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
1
fr
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
:
1
fr
;
}
.inspector
{
position
:
static
;
}
}
</style>
</head>
<body>
...
...
@@ -337,17 +538,43 @@
<div
class=
"log-line"
>
Waiting for product selection...
</div>
</div>
<!-- FLOW NODES -->
<div
id=
"flowNodes"
>
<!-- Nodes will be injected here -->
</div>
<div
class=
"sim-grid"
>
<div>
<!-- FLOW NODES -->
<div
id=
"flowNodes"
>
<!-- Nodes will be injected here -->
</div>
<!-- FINAL OUTPUT -->
<div
class=
"mf-final"
id=
"finalResult"
>
<div
class=
"mf-final-label"
>
OUTCOME (AI MATCHES)
</div>
<div
class=
"outfit-grid"
id=
"outfitGrid"
>
<!-- Outfits injected here -->
<!-- FINAL OUTPUT -->
<div
class=
"mf-final"
id=
"finalResult"
>
<div
class=
"mf-final-label"
>
OUTCOME (AI MATCHES)
</div>
<div
class=
"outfit-grid"
id=
"outfitGrid"
>
<!-- 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
)
=>
{
logTerminal
(
"Connection closed or error"
,
"error"
);
eventSource
.
onerror
=
()
=>
{
if
(
!
streamCompleted
)
{
logTerminal
(
"Connection closed or error"
,
"error"
);
}
eventSource
.
close
();
};
}
...
...
backend/static/fashion-matches/tag-audit.html
0 → 100644
View file @
496de116
<!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
:
1
fr
130px
130px
auto
;
gap
:
10px
;
margin-bottom
:
18px
;
}
.card-grid
{
display
:
grid
;
grid-template-columns
:
repeat
(
6
,
minmax
(
120px
,
1
fr
));
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
:
1
fr
1
fr
;
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
:
1
fr
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
:
1
fr
1
fr
;
}
.card-grid
{
grid-template-columns
:
repeat
(
3
,
minmax
(
120px
,
1
fr
));
}
.grid-2
{
grid-template-columns
:
1
fr
;
}
}
@media
(
max-width
:
640px
)
{
.card-grid
{
grid-template-columns
:
repeat
(
2
,
minmax
(
120px
,
1
fr
));
}
}
</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
(
'&'
,
'&'
)
.
replaceAll
(
'<'
,
'<'
)
.
replaceAll
(
'>'
,
'>'
)
.
replaceAll
(
'"'
,
'"'
)
.
replaceAll
(
"'"
,
'''
);
}
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>
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment