Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
chatbot-canifa-feedback
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
1
Merge Requests
1
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
chatbot-canifa-feedback
Commits
36f23f95
Commit
36f23f95
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
a6ec88fc
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 @
36f23f95
...
@@ -19,9 +19,8 @@ Endpoints:
...
@@ -19,9 +19,8 @@ Endpoints:
import
json
import
json
import
logging
import
logging
import
os
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
fastapi.responses
import
JSONResponse
from
pydantic
import
BaseModel
from
pydantic
import
BaseModel
...
@@ -45,7 +44,7 @@ class UpdateRulesRequest(BaseModel):
...
@@ -45,7 +44,7 @@ class UpdateRulesRequest(BaseModel):
class
OutfitSuggestRequest
(
BaseModel
):
class
OutfitSuggestRequest
(
BaseModel
):
code
:
str
code
:
str
occasion
:
Optional
[
str
]
=
None
occasion
:
str
|
None
=
None
class
ColorLogicRequest
(
BaseModel
):
class
ColorLogicRequest
(
BaseModel
):
...
@@ -54,7 +53,7 @@ class ColorLogicRequest(BaseModel):
...
@@ -54,7 +53,7 @@ class ColorLogicRequest(BaseModel):
# ─── Helpers ─────────────────────────────────────────────────
# ─── Helpers ─────────────────────────────────────────────────
def
_get_ai_matches
(
code
:
str
)
->
Optional
[
dict
]
:
def
_get_ai_matches
(
code
:
str
)
->
dict
|
None
:
conn
=
None
conn
=
None
try
:
try
:
conn
=
get_pooled_connection_compat
()
conn
=
get_pooled_connection_compat
()
...
@@ -104,9 +103,10 @@ def _save_ai_matches(code: str, ai_matches: dict) -> bool:
...
@@ -104,9 +103,10 @@ def _save_ai_matches(code: str, ai_matches: dict) -> bool:
conn
.
close
()
conn
.
close
()
def
_run_engine_background
(
code
:
Optional
[
str
]
=
None
):
def
_run_engine_background
(
code
:
str
|
None
=
None
):
try
:
try
:
from
worker.stylist_engine
import
StylistEngine
from
worker.stylist_engine
import
StylistEngine
engine
=
StylistEngine
()
engine
=
StylistEngine
()
if
code
:
if
code
:
engine
.
run_for_code
(
code
)
engine
.
run_for_code
(
code
)
...
@@ -117,11 +117,11 @@ def _run_engine_background(code: Optional[str] = None):
...
@@ -117,11 +117,11 @@ def _run_engine_background(code: Optional[str] = None):
def
_load_rules
()
->
dict
:
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
)
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
()
c
=
color_str
.
lower
()
for
key
,
variants
in
color_keys
.
items
():
for
key
,
variants
in
color_keys
.
items
():
if
any
(
v
in
c
for
v
in
variants
):
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]:
...
@@ -129,7 +129,7 @@ def _detect_color_key(color_str: str, color_keys: dict) -> Optional[str]:
return
None
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"
,
{})
color_groups
=
rules
.
get
(
"color_groups"
,
{})
group_matrix
=
rules
.
get
(
"color_group_matrix"
,
{})
group_matrix
=
rules
.
get
(
"color_group_matrix"
,
{})
color_matrix
=
rules
.
get
(
"color_matrix"
,
{})
color_matrix
=
rules
.
get
(
"color_matrix"
,
{})
...
@@ -142,9 +142,12 @@ def _color_group_explain(src_key: Optional[str], tgt_key: Optional[str], rules:
...
@@ -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
)
score
=
group_matrix
.
get
(
src_group
,
{})
.
get
(
tgt_group
,
default_score
)
advice
=
_color_advice
(
src_group
,
tgt_group
,
src_key
,
tgt_key
)
advice
=
_color_advice
(
src_group
,
tgt_group
,
src_key
,
tgt_key
)
return
{
return
{
"src_key"
:
src_key
,
"src_group"
:
src_group
,
"src_key"
:
src_key
,
"tgt_key"
:
tgt_key
,
"tgt_group"
:
tgt_group
,
"src_group"
:
src_group
,
"score"
:
score
,
"max"
:
30
,
"tgt_key"
:
tgt_key
,
"tgt_group"
:
tgt_group
,
"score"
:
score
,
"max"
:
30
,
"method"
:
"group_matrix"
,
"method"
:
"group_matrix"
,
"advice"
:
advice
,
"advice"
:
advice
,
}
}
...
@@ -152,23 +155,29 @@ def _color_group_explain(src_key: Optional[str], tgt_key: Optional[str], rules:
...
@@ -152,23 +155,29 @@ def _color_group_explain(src_key: Optional[str], tgt_key: Optional[str], rules:
if
src_key
and
tgt_key
:
if
src_key
and
tgt_key
:
score
=
color_matrix
.
get
(
src_key
,
{})
.
get
(
tgt_key
,
default_score
)
score
=
color_matrix
.
get
(
src_key
,
{})
.
get
(
tgt_key
,
default_score
)
return
{
return
{
"src_key"
:
src_key
,
"src_group"
:
src_group
or
"?"
,
"src_key"
:
src_key
,
"tgt_key"
:
tgt_key
,
"tgt_group"
:
tgt_group
or
"?"
,
"src_group"
:
src_group
or
"?"
,
"score"
:
score
,
"max"
:
30
,
"tgt_key"
:
tgt_key
,
"tgt_group"
:
tgt_group
or
"?"
,
"score"
:
score
,
"max"
:
30
,
"method"
:
"color_matrix"
,
"method"
:
"color_matrix"
,
"advice"
:
f
"Phối {src_key} + {tgt_key} (legacy matrix)"
,
"advice"
:
f
"Phối {src_key} + {tgt_key} (legacy matrix)"
,
}
}
return
{
return
{
"src_key"
:
src_key
or
"?"
,
"src_group"
:
src_group
or
"?"
,
"src_key"
:
src_key
or
"?"
,
"tgt_key"
:
tgt_key
or
"?"
,
"tgt_group"
:
tgt_group
or
"?"
,
"src_group"
:
src_group
or
"?"
,
"score"
:
default_score
,
"max"
:
30
,
"tgt_key"
:
tgt_key
or
"?"
,
"tgt_group"
:
tgt_group
or
"?"
,
"score"
:
default_score
,
"max"
:
30
,
"method"
:
"default"
,
"method"
:
"default"
,
"advice"
:
"Không xác định được màu — dùng điểm mặc định"
,
"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
)
combo
=
(
src_group
,
tgt_group
)
if
combo
==
(
"neutral"
,
"neutral"
):
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."
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
...
@@ -181,11 +190,13 @@ def _color_advice(src_group: str, tgt_group: str, src_key: Optional[str], tgt_ke
if
combo
==
(
"light"
,
"light"
):
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."
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"
):
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}"
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"
,
{})
group_matrix
=
rules
.
get
(
"color_group_matrix"
,
{})
color_groups_map
=
rules
.
get
(
"color_groups"
,
{})
color_groups_map
=
rules
.
get
(
"color_groups"
,
{})
...
@@ -250,19 +261,21 @@ def _build_color_strategy(src_key: Optional[str], src_group: Optional[str], rule
...
@@ -250,19 +261,21 @@ def _build_color_strategy(src_key: Optional[str], src_group: Optional[str], rule
return
{
"summary"
:
summary
,
"strategies"
:
strategies
}
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
]
return
[
k
for
k
,
g
in
color_groups_map
.
items
()
if
g
==
group
and
k
!=
exclude
]
# ─── Endpoints ───────────────────────────────────────────────
# ─── Endpoints ───────────────────────────────────────────────
@
router
.
get
(
"/{code}"
)
@
router
.
get
(
"/{code}"
)
async
def
get_fashion_matches
(
code
:
str
):
async
def
get_fashion_matches
(
code
:
str
):
from
worker.stylist_engine
import
StylistEngine
from
worker.stylist_engine
import
StylistEngine
engine
=
StylistEngine
()
engine
=
StylistEngine
()
data
=
engine
.
compute_dynamic_rule_matches
(
code
)
data
=
engine
.
compute_dynamic_rule_matches
(
code
)
classifications
=
engine
.
compute_super_classifications_sql
(
code
)
classifications
=
engine
.
compute_super_classifications_sql
(
code
)
# Return empty ai_matches if not found (or gracefully handle) to avoid UI breaking
# Return empty ai_matches if not found (or gracefully handle) to avoid UI breaking
if
data
is
None
:
if
data
is
None
:
data
=
{}
data
=
{}
...
@@ -281,19 +294,22 @@ async def update_fashion_matches(code: str, req: UpdateMatchesRequest):
...
@@ -281,19 +294,22 @@ async def update_fashion_matches(code: str, req: UpdateMatchesRequest):
@
router
.
post
(
"/{code}/regen"
)
@
router
.
post
(
"/{code}/regen"
)
async
def
regen_fashion_matches
(
code
:
str
):
async
def
regen_fashion_matches
(
code
:
str
):
import
asyncio
import
asyncio
await
asyncio
.
to_thread
(
_run_engine_background
,
code
)
await
asyncio
.
to_thread
(
_run_engine_background
,
code
)
logger
.
info
(
"[FashionMatches] Regen finished:
%
s"
,
code
)
logger
.
info
(
"[FashionMatches] Regen finished:
%
s"
,
code
)
return
{
"ok"
:
True
,
"message"
:
f
"Đã tính toán phối đồ cho {code}"
}
return
{
"ok"
:
True
,
"message"
:
f
"Đã tính toán phối đồ cho {code}"
}
@
router
.
post
(
"/batch-regen"
)
@
router
.
post
(
"/batch-regen"
)
async
def
batch_regen_fashion_matches
(
request
:
Request
):
async
def
batch_regen_fashion_matches
(
request
:
Request
):
data
=
await
request
.
json
()
data
=
await
request
.
json
()
codes
=
data
.
get
(
"codes"
,
[])
codes
=
data
.
get
(
"codes"
,
[])
if
not
codes
:
if
not
codes
:
return
{
"ok"
:
True
,
"message"
:
"None"
}
return
{
"ok"
:
True
,
"message"
:
"None"
}
def
_run_multiple
():
def
_run_multiple
():
from
worker.stylist_engine
import
StylistEngine
from
worker.stylist_engine
import
StylistEngine
engine
=
StylistEngine
()
engine
=
StylistEngine
()
for
c
in
codes
:
for
c
in
codes
:
try
:
try
:
...
@@ -302,6 +318,7 @@ async def batch_regen_fashion_matches(request: Request):
...
@@ -302,6 +318,7 @@ async def batch_regen_fashion_matches(request: Request):
pass
pass
import
asyncio
import
asyncio
await
asyncio
.
to_thread
(
_run_multiple
)
await
asyncio
.
to_thread
(
_run_multiple
)
return
{
"ok"
:
True
,
"message"
:
f
"Đã xong {len(codes)} sp"
}
return
{
"ok"
:
True
,
"message"
:
f
"Đã xong {len(codes)} sp"
}
...
@@ -309,6 +326,7 @@ async def batch_regen_fashion_matches(request: Request):
...
@@ -309,6 +326,7 @@ async def batch_regen_fashion_matches(request: Request):
@
router
.
post
(
"/batch"
)
@
router
.
post
(
"/batch"
)
async
def
batch_fashion_matches
(
background_tasks
:
BackgroundTasks
):
async
def
batch_fashion_matches
(
background_tasks
:
BackgroundTasks
):
from
worker.stylist_engine
import
BATCH_STATE
from
worker.stylist_engine
import
BATCH_STATE
if
BATCH_STATE
.
get
(
"is_running"
):
if
BATCH_STATE
.
get
(
"is_running"
):
return
JSONResponse
({
"ok"
:
False
,
"error"
:
"Batch đang chạy, vui lòng chờ"
},
status_code
=
409
)
return
JSONResponse
({
"ok"
:
False
,
"error"
:
"Batch đang chạy, vui lòng chờ"
},
status_code
=
409
)
background_tasks
.
add_task
(
_run_engine_background
,
None
)
background_tasks
.
add_task
(
_run_engine_background
,
None
)
...
@@ -318,6 +336,7 @@ async def batch_fashion_matches(background_tasks: BackgroundTasks):
...
@@ -318,6 +336,7 @@ async def batch_fashion_matches(background_tasks: BackgroundTasks):
@
router
.
get
(
"/batch/status"
)
@
router
.
get
(
"/batch/status"
)
async
def
batch_status
():
async
def
batch_status
():
from
worker.stylist_engine
import
BATCH_STATE
from
worker.stylist_engine
import
BATCH_STATE
state
=
dict
(
BATCH_STATE
)
state
=
dict
(
BATCH_STATE
)
pct
=
0
pct
=
0
if
state
.
get
(
"total"
,
0
)
>
0
:
if
state
.
get
(
"total"
,
0
)
>
0
:
...
@@ -350,33 +369,41 @@ async def get_rules_meta():
...
@@ -350,33 +369,41 @@ async def get_rules_meta():
try
:
try
:
rules
=
_load_rules
()
rules
=
_load_rules
()
color_hex_map
=
{
color_hex_map
=
{
"Trắng"
:
"#F5F5F5"
,
"Đen"
:
"#1A1A1A"
,
"Be"
:
"#D4B896"
,
"Xám"
:
"#9E9E9E"
,
"Trắng"
:
"#F5F5F5"
,
"Nâu"
:
"#795548"
,
"Vàng"
:
"#FDD835"
,
"Hồng"
:
"#F48FB1"
,
"Xanh lam"
:
"#64B5F6"
,
"Đen"
:
"#1A1A1A"
,
"Tím"
:
"#BA68C8"
,
"Đỏ"
:
"#EF5350"
,
"Cam"
:
"#FFA726"
,
"Xanh lá"
:
"#66BB6A"
,
"Be"
:
"#D4B896"
,
"Xanh navy"
:
"#1A237E"
,
"Xanh Jeans"
:
"#5C6BC0"
,
"Xanh than"
:
"#00897B"
,
"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
=
[]
color_meta
=
[]
for
key
,
variants
in
rules
.
get
(
"color_keys"
,
{})
.
items
():
for
key
,
variants
in
rules
.
get
(
"color_keys"
,
{})
.
items
():
group
=
rules
.
get
(
"color_groups"
,
{})
.
get
(
key
,
"unknown"
)
group
=
rules
.
get
(
"color_groups"
,
{})
.
get
(
key
,
"unknown"
)
color_meta
.
append
({
color_meta
.
append
(
"key"
:
key
,
"group"
:
group
,
{
"hex"
:
color_hex_map
.
get
(
key
,
"#CCCCCC"
),
"key"
:
key
,
"keywords"
:
variants
,
"group"
:
group
,
})
"hex"
:
color_hex_map
.
get
(
key
,
"#CCCCCC"
),
"keywords"
:
variants
,
}
)
return
{
return
{
"ok"
:
True
,
"ok"
:
True
,
"meta"
:
{
"meta"
:
{
"colors"
:
color_meta
,
"colors"
:
color_meta
,
"color_group_matrix"
:
rules
.
get
(
"color_group_matrix"
,
{}),
"color_group_matrix"
:
rules
.
get
(
"color_group_matrix"
,
{}),
"styles"
:
list
(
rules
.
get
(
"style_compat"
,
{})
.
keys
()),
"styles"
:
list
(
rules
.
get
(
"style_compat"
,
{})
.
keys
()),
"occasions"
:
[
"occasions"
:
[{
"key"
:
k
,
"label"
:
v
}
for
k
,
v
in
rules
.
get
(
"occasion_labels"
,
{})
.
items
()],
{
"key"
:
k
,
"label"
:
v
}
"roles"
:
[{
"key"
:
k
,
"label"
:
v
}
for
k
,
v
in
rules
.
get
(
"role_labels"
,
{})
.
items
()],
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"
,
{}),
"score_weights"
:
rules
.
get
(
"score_weights"
,
{}),
"version"
:
rules
.
get
(
"version"
,
"?"
),
"version"
:
rules
.
get
(
"version"
,
"?"
),
},
},
...
@@ -405,11 +432,14 @@ async def outfit_suggest(req: OutfitSuggestRequest):
...
@@ -405,11 +432,14 @@ async def outfit_suggest(req: OutfitSuggestRequest):
color_keys
=
rules
.
get
(
"color_keys"
,
{})
color_keys
=
rules
.
get
(
"color_keys"
,
{})
from
worker.stylist_engine
import
StylistEngine
from
worker.stylist_engine
import
StylistEngine
engine
=
StylistEngine
()
engine
=
StylistEngine
()
catalog
=
engine
.
_load_catalog
()
catalog
=
engine
.
_load_catalog
()
# Find source product in 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
:
if
not
src_product
:
return
JSONResponse
(
return
JSONResponse
(
...
@@ -423,7 +453,7 @@ async def outfit_suggest(req: OutfitSuggestRequest):
...
@@ -423,7 +453,7 @@ async def outfit_suggest(req: OutfitSuggestRequest):
src_color
=
src_product
.
get
(
"color"
,
""
)
src_color
=
src_product
.
get
(
"color"
,
""
)
src_key
=
_detect_color_key
(
src_color
,
color_keys
)
src_key
=
_detect_color_key
(
src_color
,
color_keys
)
src_group
=
rules
.
get
(
"color_groups"
,
{})
.
get
(
src_key
)
if
src_key
else
None
src_group
=
rules
.
get
(
"color_groups"
,
{})
.
get
(
src_key
)
if
src_key
else
None
desc_data
=
src_product
.
get
(
"description_data"
,
{})
desc_data
=
src_product
.
get
(
"description_data"
,
{})
src_name
=
src_product
.
get
(
"name"
,
""
)
src_name
=
src_product
.
get
(
"name"
,
""
)
src_line
=
src_product
.
get
(
"product_line"
,
""
)
src_line
=
src_product
.
get
(
"product_line"
,
""
)
...
@@ -448,12 +478,16 @@ async def outfit_suggest(req: OutfitSuggestRequest):
...
@@ -448,12 +478,16 @@ async def outfit_suggest(req: OutfitSuggestRequest):
for
item
in
items
:
for
item
in
items
:
item_color_key
=
_detect_color_key
(
item
.
get
(
"color"
,
""
),
color_keys
)
item_color_key
=
_detect_color_key
(
item
.
get
(
"color"
,
""
),
color_keys
)
color_info
=
_color_group_explain
(
src_key
,
item_color_key
,
rules
)
color_info
=
_color_group_explain
(
src_key
,
item_color_key
,
rules
)
enriched
.
append
({
enriched
.
append
(
**
item
,
{
"color_key"
:
item_color_key
,
**
item
,
"color_group"
:
rules
.
get
(
"color_groups"
,
{})
.
get
(
item_color_key
,
"?"
)
if
item_color_key
else
"?"
,
"color_key"
:
item_color_key
,
"color_synergy"
:
color_info
,
"color_group"
:
rules
.
get
(
"color_groups"
,
{})
.
get
(
item_color_key
,
"?"
)
})
if
item_color_key
else
"?"
,
"color_synergy"
:
color_info
,
}
)
slots
[
role
]
=
enriched
slots
[
role
]
=
enriched
if
slots
:
if
slots
:
outfit_by_occasion
[
occ
]
=
{
outfit_by_occasion
[
occ
]
=
{
...
@@ -462,7 +496,7 @@ async def outfit_suggest(req: OutfitSuggestRequest):
...
@@ -462,7 +496,7 @@ async def outfit_suggest(req: OutfitSuggestRequest):
}
}
color_strategy
=
_build_color_strategy
(
src_key
,
src_group
,
rules
)
color_strategy
=
_build_color_strategy
(
src_key
,
src_group
,
rules
)
# Inject our brand new SQL-based super classification matches
# Inject our brand new SQL-based super classification matches
classifications
=
engine
.
compute_super_classifications_sql
(
req
.
code
)
classifications
=
engine
.
compute_super_classifications_sql
(
req
.
code
)
...
@@ -483,7 +517,7 @@ async def outfit_suggest(req: OutfitSuggestRequest):
...
@@ -483,7 +517,7 @@ async def outfit_suggest(req: OutfitSuggestRequest):
},
},
"color_strategy"
:
color_strategy
,
"color_strategy"
:
color_strategy
,
"outfit_by_occasion"
:
outfit_by_occasion
,
"outfit_by_occasion"
:
outfit_by_occasion
,
"classifications"
:
classifications
"classifications"
:
classifications
,
}
}
except
Exception
as
e
:
except
Exception
as
e
:
...
@@ -500,6 +534,7 @@ class ScoreTestRequest(BaseModel):
...
@@ -500,6 +534,7 @@ class ScoreTestRequest(BaseModel):
async
def
score_test
(
req
:
ScoreTestRequest
):
async
def
score_test
(
req
:
ScoreTestRequest
):
try
:
try
:
from
worker.stylist_engine
import
StylistEngine
from
worker.stylist_engine
import
StylistEngine
engine
=
StylistEngine
()
engine
=
StylistEngine
()
catalog
=
engine
.
_load_catalog
()
catalog
=
engine
.
_load_catalog
()
...
@@ -533,11 +568,188 @@ async def score_test(req: ScoreTestRequest):
...
@@ -533,11 +568,188 @@ async def score_test(req: ScoreTestRequest):
return
JSONResponse
({
"ok"
:
False
,
"error"
:
str
(
e
)},
status_code
=
500
)
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 ---
# --- Rules Framework HTML View ---
@
router
.
get
(
"/rules/view"
)
@
router
.
get
(
"/rules/view"
)
async
def
rules_view
():
async
def
rules_view
():
from
fastapi.responses
import
HTMLResponse
from
collections
import
defaultdict
from
collections
import
defaultdict
from
fastapi.responses
import
HTMLResponse
rows
=
[]
rows
=
[]
conn
=
None
conn
=
None
try
:
try
:
...
@@ -557,29 +769,69 @@ async def rules_view():
...
@@ -557,29 +769,69 @@ async def rules_view():
except
Exception
as
e
:
except
Exception
as
e
:
logger
.
error
(
"[RulesView] DB error:
%
s"
,
e
)
logger
.
error
(
"[RulesView] DB error:
%
s"
,
e
)
finally
:
finally
:
if
conn
:
conn
.
close
()
if
conn
:
conn
.
close
()
grouped
=
defaultdict
(
lambda
:
defaultdict
(
list
))
grouped
=
defaultdict
(
lambda
:
defaultdict
(
list
))
for
gender
,
anchor
,
occ
,
role
,
target
,
reason
in
rows
:
for
gender
,
anchor
,
occ
,
role
,
target
,
reason
in
rows
:
grouped
[
gender
][
occ
]
.
append
({
"anchor"
:
anchor
,
"role"
:
role
,
"target"
:
target
,
"reason"
:
reason
or
""
})
grouped
[
gender
][
occ
]
.
append
({
"anchor"
:
anchor
,
"role"
:
role
,
"target"
:
target
,
"reason"
:
reason
or
""
})
GENDER_META
=
{
GENDER_META
=
{
"nu"
:
{
"label"
:
"NGA Nu"
,
"color"
:
"#ec4899"
,
"bg"
:
"#fdf2f8"
,
"group"
:
"Neutral+Light+Dark"
,
"colors"
:
"Trang, Hong, Be, Tim, Xanh lam, Den"
},
"nu"
:
{
"nam"
:
{
"label"
:
"NAM Nam"
,
"color"
:
"#3b82f6"
,
"bg"
:
"#eff6ff"
,
"group"
:
"Neutral (+5 boost)"
,
"colors"
:
"Be, Xam, Den, Trang, Nau, Xanh than"
},
"label"
:
"NGA Nu"
,
"unisex"
:
{
"label"
:
"UNI Unisex"
,
"color"
:
"#8b5cf6"
,
"bg"
:
"#f5f3ff"
,
"group"
:
"Neutral+Dark"
,
"colors"
:
"Den, Trang, Xam, Do, Xanh navy, Xanh Jeans"
},
"color"
:
"#ec4899"
,
"be_gai"
:
{
"label"
:
"BG Be Gai"
,
"color"
:
"#f43f5e"
,
"bg"
:
"#fff1f2"
,
"group"
:
"Light Pastel (+5 boost)"
,
"colors"
:
"Hong, Tim, Vang nhat, Xanh lam, Trang"
},
"bg"
:
"#fdf2f8"
,
"be_trai"
:
{
"label"
:
"BT Be Trai"
,
"color"
:
"#f59e0b"
,
"bg"
:
"#fffbeb"
,
"group"
:
"Dark (+5 boost)"
,
"colors"
:
"Vang, Cam, Xanh Jeans, Xanh navy, Do"
},
"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
=
""
sections
=
""
for
gk
,
meta
in
GENDER_META
.
items
():
for
gk
,
meta
in
GENDER_META
.
items
():
occ_data
=
grouped
.
get
(
gk
,
{})
occ_data
=
grouped
.
get
(
gk
,
{})
if
not
occ_data
:
continue
if
not
occ_data
:
continue
rows_html
=
""
rows_html
=
""
for
occ
,
rlist
in
occ_data
.
items
():
for
occ
,
rlist
in
occ_data
.
items
():
items
=
""
.
join
(
items
=
""
.
join
(
f
'<span class="ri" title="{r["reason"]}">{r["anchor"]} → {r["target"]} <em>({ROLE_ICONS.get(r["role"],r["role"])})</em></span>'
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
)
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>'
)
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>'
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>
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}}
*{{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 @
36f23f95
import
asyncio
import
asyncio
import
json
import
json
import
logging
import
logging
from
typing
import
Optional
from
fastapi
import
APIRouter
,
Query
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
from
worker.stylist_engine
import
StylistEngine
...
@@ -12,32 +11,114 @@ logger = logging.getLogger(__name__)
...
@@ -12,32 +11,114 @@ logger = logging.getLogger(__name__)
router
=
APIRouter
(
prefix
=
"/api/fashion-matches/simulator"
,
tags
=
[
"Fashion Matches Simulator"
])
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"
)
@
router
.
get
(
"/search"
)
async
def
search_product
(
q
:
str
=
Query
(
...
,
description
=
"Mã SP hoặc Tên SP"
,
min_length
=
2
)):
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"""
"""API hỗ trợ tìm kiếm nhanh Product Code cho Simulator"""
try
:
try
:
engine
=
StylistEngine
()
engine
=
StylistEngine
()
catalog
=
engine
.
_get_catalog
()
catalog
=
engine
.
_get_catalog
()
q_lower
=
q
.
lower
()
.
strip
()
q_lower
=
q
.
lower
()
.
strip
()
results
=
[]
results
=
[]
for
p
in
catalog
:
for
p
in
catalog
:
code
=
p
.
get
(
"code"
,
""
)
.
lower
()
code
=
p
.
get
(
"code"
,
""
)
.
lower
()
ref
=
p
.
get
(
"internal_ref_code"
,
""
)
.
lower
()
ref
=
p
.
get
(
"internal_ref_code"
,
""
)
.
lower
()
name
=
p
.
get
(
"name"
,
""
)
.
lower
()
name
=
p
.
get
(
"name"
,
""
)
.
lower
()
# Simple match logic
# Simple match logic
if
q_lower
in
code
or
q_lower
in
ref
or
q_lower
in
name
:
if
q_lower
in
code
or
q_lower
in
ref
or
q_lower
in
name
:
results
.
append
({
results
.
append
(
"code"
:
p
.
get
(
"code"
),
{
"name"
:
p
.
get
(
"name"
),
"code"
:
p
.
get
(
"code"
),
"image"
:
p
.
get
(
"image"
,
""
),
"name"
:
p
.
get
(
"name"
),
"color"
:
p
.
get
(
"color"
,
""
)
"image"
:
p
.
get
(
"image"
,
""
),
})
"color"
:
p
.
get
(
"color"
,
""
),
}
)
if
len
(
results
)
>=
10
:
if
len
(
results
)
>=
10
:
break
break
return
{
"ok"
:
True
,
"data"
:
results
}
return
{
"ok"
:
True
,
"data"
:
results
}
except
Exception
as
e
:
except
Exception
as
e
:
logger
.
error
(
"[Simulator] Search error:
%
s"
,
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"
...
@@ -47,17 +128,16 @@ async def search_product(q: str = Query(..., description="Mã SP hoặc Tên SP"
@
router
.
get
(
"/stream"
)
@
router
.
get
(
"/stream"
)
async
def
stream_flow
(
code
:
str
=
Query
(
...
,
description
=
"Product code to simulate"
)):
async
def
stream_flow
(
code
:
str
=
Query
(
...
,
description
=
"Product code to simulate"
)):
"""SSE Endpoint cho Realtime Live Simulator"""
"""SSE Endpoint cho Realtime Live Simulator"""
async
def
event_generator
():
async
def
event_generator
():
try
:
try
:
# --- BƯỚC 1: INIT ---
# --- BƯỚC 1: INIT ---
msg
=
json
.
dumps
({
msg
=
json
.
dumps
(
"step"
:
1
,
{
"step"
:
1
,
"node"
:
"init"
,
"status"
:
f
"🔧 Khởi chạy Cỗ máy StylistEngine. Mã SP đưa vào: {code}..."
},
"node"
:
"init"
,
ensure_ascii
=
False
,
"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
"
yield
f
"data: {msg}
\n\n
"
await
asyncio
.
sleep
(
0.8
)
# Dramatic delay
await
asyncio
.
sleep
(
0.8
)
# Dramatic delay
engine
=
StylistEngine
()
engine
=
StylistEngine
()
catalog
=
engine
.
_get_catalog
()
catalog
=
engine
.
_get_catalog
()
...
@@ -68,18 +148,22 @@ async def stream_flow(code: str = Query(..., description="Product code to simula
...
@@ -68,18 +148,22 @@ async def stream_flow(code: str = Query(..., description="Product code to simula
return
return
# --- BƯỚC 2: PHÂN TÍCH SP GỐC ---
# --- BƯỚC 2: PHÂN TÍCH SP GỐC ---
msg
=
json
.
dumps
({
msg
=
json
.
dumps
(
"step"
:
2
,
{
"node"
:
"fetch_product"
,
"step"
:
2
,
"status"
:
f
"🔍 Bóc tách SP gốc: {source.get('name')} (Màu: {source.get('color')} / Giới tính: {source.get('gender')})"
,
"node"
:
"fetch_product"
,
"payload"
:
{
"status"
:
f
"🔍 Bóc tách SP gốc: {source.get('name')} (Màu: {source.get('color')} / Giới tính: {source.get('gender')})"
,
"code"
:
source
.
get
(
"code"
),
"payload"
:
{
"name"
:
source
.
get
(
"name"
),
"code"
:
source
.
get
(
"code"
),
"image"
:
source
.
get
(
"image"
),
"name"
:
source
.
get
(
"name"
),
"color"
:
source
.
get
(
"color"
),
"image"
:
source
.
get
(
"image"
),
"category"
:
source
.
get
(
"product_line"
)
"color"
:
source
.
get
(
"color"
),
}
"category"
:
source
.
get
(
"product_line"
),
},
ensure_ascii
=
False
)
"catalog_total"
:
max
(
len
(
catalog
)
-
1
,
0
),
},
},
ensure_ascii
=
False
,
)
yield
f
"data: {msg}
\n\n
"
yield
f
"data: {msg}
\n\n
"
await
asyncio
.
sleep
(
1.0
)
await
asyncio
.
sleep
(
1.0
)
...
@@ -88,59 +172,103 @@ async def stream_flow(code: str = Query(..., description="Product code to simula
...
@@ -88,59 +172,103 @@ async def stream_flow(code: str = Query(..., description="Product code to simula
gender
=
source
.
get
(
"gender"
,
""
)
gender
=
source
.
get
(
"gender"
,
""
)
db_rules
=
engine
.
_fetch_rules_with_reason
(
anchor_cat
,
gender
)
db_rules
=
engine
.
_fetch_rules_with_reason
(
anchor_cat
,
gender
)
rules_count
=
len
(
db_rules
)
rules_count
=
len
(
db_rules
)
if
rules_count
==
0
:
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
:
else
:
status_rules
=
f
"📂 Khớp thành công {rules_count} luật (Rules) phối đồ theo Dịp (Occasion) & Role từ Database."
status_rules
=
(
f
"📂 Khớp thành công {rules_count} luật (Rules) phối đồ theo Dịp (Occasion) & Role từ Database."
msg
=
json
.
dumps
({
)
"step"
:
3
,
"node"
:
"fetch_rules"
,
msg
=
json
.
dumps
(
"status"
:
status_rules
,
{
"payload"
:
{
"rules_count"
:
rules_count
}
"step"
:
3
,
},
ensure_ascii
=
False
)
"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
"
yield
f
"data: {msg}
\n\n
"
await
asyncio
.
sleep
(
1.2
)
await
asyncio
.
sleep
(
1.2
)
# --- BƯỚC 4: CHẤM ĐIỂM ---
# --- 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)
# (Run the heavy lifting here)
ai_matches
=
engine
.
compute_dynamic_rule_matches
(
code
)
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
)
await
asyncio
.
sleep
(
1.5
)
# --- BƯỚC 5: HẬU KỲ VÀ PHẨN LOẠI MỞ RỘNG (DEDUPLICATE / SQL CLASSIFICATIONS) ---
# --- BƯỚC 5: HẬU KỲ VÀ PHẨN LOẠI MỞ RỘNG (DEDUPLICATE / SQL CLASSIFICATIONS) ---
msg
=
json
.
dumps
({
top_role_matches
=
_build_top_by_role
(
scored_candidates
,
top_k
=
3
)
"step"
:
5
,
final_count
=
sum
(
len
(
v
)
for
v
in
top_role_matches
.
values
())
"node"
:
"dedup"
,
msg
=
json
.
dumps
(
"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
)
"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
"
yield
f
"data: {msg}
\n\n
"
classifications
=
engine
.
compute_super_classifications_sql
(
code
)
classifications
=
engine
.
compute_super_classifications_sql
(
code
)
await
asyncio
.
sleep
(
0.8
)
await
asyncio
.
sleep
(
0.8
)
# --- BƯỚC 6: FINISH ---
# --- BƯỚC 6: FINISH ---
msg
=
json
.
dumps
({
msg
=
json
.
dumps
(
"step"
:
6
,
{
"node"
:
"complete"
,
"step"
:
6
,
"status"
:
"✅ HOÀN TẤT! Đẩy khung Outfit JSON ra giao diện."
,
"node"
:
"complete"
,
"payload"
:
{
"status"
:
"✅ HOÀN TẤT! Đẩy khung Outfit JSON ra giao diện."
,
"ai_matches"
:
ai_matches
,
"payload"
:
{
"classifications"
:
classifications
"ai_matches"
:
top_role_matches
,
}
"raw_ai_matches"
:
ai_matches
,
},
ensure_ascii
=
False
)
"classifications"
:
classifications
,
},
},
ensure_ascii
=
False
,
)
yield
f
"data: {msg}
\n\n
"
yield
f
"data: {msg}
\n\n
"
except
Exception
as
e
:
except
Exception
as
e
:
logger
.
error
(
"[Simulator] Generator error:
%
s"
,
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
"
yield
f
"data: {msg}
\n\n
"
return
StreamingResponse
(
event_generator
(),
media_type
=
"text/event-stream"
)
return
StreamingResponse
(
event_generator
(),
media_type
=
"text/event-stream"
)
backend/static/fashion-matches/index.html
View file @
36f23f95
...
@@ -40,6 +40,9 @@
...
@@ -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)"
>
<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
<i
data-lucide=
"monitor-play"
class=
"icon-md"
style=
"margin-right:8px; color:#10b981"
></i>
Live Simulator
</button>
</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>
</div>
</div>
...
...
backend/static/fashion-matches/live-simulator.html
View file @
36f23f95
...
@@ -5,7 +5,7 @@
...
@@ -5,7 +5,7 @@
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<meta
name=
"viewport"
content=
"width=device-width, initial-scale=1.0"
>
<title>
Live Simulator — Canifa AI Stylist
</title>
<title>
Live Simulator — Canifa AI Stylist
</title>
<link
rel=
"stylesheet"
href=
"/static/common/theme.css"
>
<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>
<style>
/* ═════════════════════════════════════════════
/* ═════════════════════════════════════════════
SYNCHRONIZED LIGHT THEME UI (Memos Style)
SYNCHRONIZED LIGHT THEME UI (Memos Style)
...
@@ -15,17 +15,28 @@
...
@@ -15,17 +15,28 @@
body
{
body
{
background
:
var
(
--background
);
background
:
var
(
--background
);
min-height
:
100vh
;
min-height
:
100vh
;
font-family
:
'
Inter
'
,
sans-serif
;
font-family
:
'
Manrope
'
,
sans-serif
;
color
:
var
(
--foreground
);
color
:
var
(
--foreground
);
-webkit-font-smoothing
:
antialiased
;
-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
{
.mf-wrap
{
max-width
:
64
0px
;
max-width
:
120
0px
;
margin
:
0
auto
;
margin
:
0
auto
;
padding
:
40px
16px
80px
;
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 ── */
/* ── Header ── */
.mf-header
{
.mf-header
{
margin-bottom
:
32px
;
margin-bottom
:
32px
;
...
@@ -48,6 +59,7 @@
...
@@ -48,6 +59,7 @@
color
:
var
(
--foreground
);
color
:
var
(
--foreground
);
line-height
:
1.2
;
line-height
:
1.2
;
letter-spacing
:
-0.5px
;
letter-spacing
:
-0.5px
;
font-family
:
'Space Grotesk'
,
sans-serif
;
}
}
.mf-title
span
{
.mf-title
span
{
background
:
linear-gradient
(
90deg
,
var
(
--primary
),
var
(
--info
));
background
:
linear-gradient
(
90deg
,
var
(
--primary
),
var
(
--info
));
...
@@ -312,6 +324,195 @@
...
@@ -312,6 +324,195 @@
.product-mini-code
{
.product-mini-code
{
font-size
:
10px
;
color
:
var
(
--muted-fg
);
margin-top
:
2px
;
font-family
:
'DM Mono'
,
monospace
;
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>
</style>
</head>
</head>
<body>
<body>
...
@@ -337,17 +538,43 @@
...
@@ -337,17 +538,43 @@
<div
class=
"log-line"
>
Waiting for product selection...
</div>
<div
class=
"log-line"
>
Waiting for product selection...
</div>
</div>
</div>
<!-- FLOW NODES -->
<div
class=
"sim-grid"
>
<div
id=
"flowNodes"
>
<div>
<!-- Nodes will be injected here -->
<!-- FLOW NODES -->
</div>
<div
id=
"flowNodes"
>
<!-- Nodes will be injected here -->
</div>
<!-- FINAL OUTPUT -->
<!-- FINAL OUTPUT -->
<div
class=
"mf-final"
id=
"finalResult"
>
<div
class=
"mf-final"
id=
"finalResult"
>
<div
class=
"mf-final-label"
>
OUTCOME (AI MATCHES)
</div>
<div
class=
"mf-final-label"
>
OUTCOME (AI MATCHES)
</div>
<div
class=
"outfit-grid"
id=
"outfitGrid"
>
<div
class=
"outfit-grid"
id=
"outfitGrid"
>
<!-- Outfits injected here -->
<!-- Outfits injected here -->
</div>
</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>
</div>
</div>
...
@@ -400,6 +627,99 @@ let searchTimeout = null;
...
@@ -400,6 +627,99 @@ let searchTimeout = null;
const
searchInput
=
document
.
getElementById
(
'searchInput'
);
const
searchInput
=
document
.
getElementById
(
'searchInput'
);
const
searchResults
=
document
.
getElementById
(
'searchResults'
);
const
searchResults
=
document
.
getElementById
(
'searchResults'
);
let
eventSource
=
null
;
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
)
=>
{
searchInput
.
addEventListener
(
'input'
,
(
e
)
=>
{
const
q
=
e
.
target
.
value
.
trim
();
const
q
=
e
.
target
.
value
.
trim
();
...
@@ -491,6 +811,17 @@ function resetUI() {
...
@@ -491,6 +811,17 @@ function resetUI() {
term
.
innerHTML
=
''
;
term
.
innerHTML
=
''
;
document
.
getElementById
(
'finalResult'
).
classList
.
remove
(
'show'
);
document
.
getElementById
(
'finalResult'
).
classList
.
remove
(
'show'
);
document
.
getElementById
(
'outfitGrid'
).
innerHTML
=
''
;
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
// Start Stream
...
@@ -508,7 +839,13 @@ function startSimulation(code) {
...
@@ -508,7 +839,13 @@ function startSimulation(code) {
eventSource
=
new
EventSource
(
`/api/fashion-matches/simulator/stream?code=
${
encodeURIComponent
(
code
)}
`
);
eventSource
=
new
EventSource
(
`/api/fashion-matches/simulator/stream?code=
${
encodeURIComponent
(
code
)}
`
);
eventSource
.
onmessage
=
(
event
)
=>
{
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
)
{
if
(
data
.
error
)
{
logTerminal
(
data
.
status
,
'error'
);
logTerminal
(
data
.
status
,
'error'
);
...
@@ -541,15 +878,64 @@ function startSimulation(code) {
...
@@ -541,15 +878,64 @@ function startSimulation(code) {
if
(
data
.
step
===
6
)
logType
=
'success'
;
if
(
data
.
step
===
6
)
logType
=
'success'
;
logTerminal
(
data
.
status
,
logType
);
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
// Render payload if step 6
if
(
data
.
step
===
6
&&
data
.
payload
&&
data
.
payload
.
ai_matches
)
{
if
(
data
.
step
===
6
&&
data
.
payload
&&
data
.
payload
.
ai_matches
)
{
renderFinalResults
(
data
.
payload
.
ai_matches
);
renderFinalResults
(
data
.
payload
.
ai_matches
);
streamCompleted
=
true
;
eventSource
.
close
();
eventSource
.
close
();
}
}
};
};
eventSource
.
onerror
=
(
err
)
=>
{
eventSource
.
onerror
=
()
=>
{
logTerminal
(
"Connection closed or error"
,
"error"
);
if
(
!
streamCompleted
)
{
logTerminal
(
"Connection closed or error"
,
"error"
);
}
eventSource
.
close
();
eventSource
.
close
();
};
};
}
}
...
...
backend/static/fashion-matches/tag-audit.html
0 → 100644
View file @
36f23f95
<!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