強化 PChome 變體錯配防線
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s

This commit is contained in:
OoO
2026-05-25 20:53:47 +08:00
parent 630d4c73bc
commit 50daad84a7
5 changed files with 314 additions and 12 deletions

View File

@@ -4,6 +4,8 @@
================================================================================
【已完成】
- V10.479 依 production audit 再補二階風險Cetaphil 修護乳 vs 潔膚露改 hard veto私密防護慕絲二款可選 vs 單一香型、雪芙蘭滋養霜 vs 單側清爽型改走 `variant_selection_review`,避免仍殘留在 accepted queue。
- V10.478 補 PChome 高分錯配 / catalog 變體防線:精油/香氛類若兩側明確香味不同(如檸檬草 vs 茶樹)直接 vetoNOW 椰子油膏 vs 乳木果油、港香蘭漢本 vs 艾魔菈爽身粉改為商品線硬擋;多色/多香/數字區間 catalog 對單一款式(粉餅盒、眉筆、眼線膠筆、車用擴香蕊等)只進 `variant_selection_review`,不自動進 accepted queue。
- V10.477 補 PChome 高分錯配防線SPF 數值不同(如 SPF25 vs SPF50直接 vetoMAKE UP FOR EVER 定妝噴霧 vs 活氧水不同線直接 veto多款任選對單一款私密潔浴露、身體去角質、乳液、染眉膏等與單側色號改送 `variant_selection_review`,避免高分候選誤入 accepted queue。
- V10.476 補 PChome 商品比對「商業條件差」防線:即期品、效期/保存期限、盒損、福利品等條件若只出現在單側matcher 會加 `commercial_condition_gap` + `variant_selection_review`,保留高分但不讓 rescore 自動進 accepted queue。這可避免 3W CLINIC 粉底液、KAMERIA 足膜、Sisley 全能乳液這類同名但商品狀態不同的候選被當成一般正品價差。
- V10.475 補 PChome rescore 操作與高分錯配防線:`scripts/audit_competitor_match_attempt_rescore.py` 預設不再只掃 `strong_exact_spec_match`,避免漏掉 `focused_exact_*` 等新版 matcher 理由matcher 新增暖燈 S/M/L 尺寸差、NITORI 香氛噴霧器型號差的 hard veto並把彩妝色號單邊出現的高分候選送進 `variant_selection_review`,避免 LA MER 氣墊等色號型商品被誤入 accepted queue。測試`tests/test_marketplace_product_matcher.py`、`tests/test_competitor_match_attempts_persistence.py`、`tests/test_competitor_match_attempt_rescore_audit.py` 通過。

View File

@@ -350,7 +350,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.477"
SYSTEM_VERSION = "V10.479"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -72,6 +72,8 @@
- 2026-05-25 15:20 CST 起新增三個正式觀察到的高分負例防線PRAY 守夜人暖燈 L vs S、NITORI 香氛噴霧器 5510 vs YX168、LA MER 氣墊粉霜通用 listing vs `11 Rosy Ivory` 色號。前兩者 hard veto後者保留高分但不進 accepted queue。
- 2026-05-25 16:15 CST 起新增商業條件差負例KAMERIA 足膜、3W CLINIC 粉底液、Sisley 全能乳液等若一側標示即期/效期/盒損,仍可顯示高相似度,但只進 identity review不自動入 accepted queue。
- 2026-05-25 19:20 CST 起新增高分負例Jealousness SPF25 vs SPF50、MAKE UP FOR EVER 超光肌控油定妝噴霧 vs 超光肌活氧水、rom&nd 染眉膏通用 listing vs `03 摩登米`、Lactacyd 多款潔浴露 vs 單一亮肌柔滑、我的心機多款身體去角質 vs 單一香型。前兩者 hard veto其餘進 identity review。
- 2026-05-25 20:05 CST 起,新增高分錯配 / catalog 變體防線AUS LIFE 檸檬草 vs 茶樹滾珠精油、NOW 椰子油膏 vs 乳木果油、港香蘭漢本 vs 艾魔菈爽身粉改為 hard veto多色 / 多香 / 數字區間 catalog 對單一款式KATE 粉餅盒、植村秀眉筆、PERIPERA 01~07 眼線膠筆、Jo Malone 車用擴香蕊芯等)只進 `variant_selection_review`
- 2026-05-25 20:35 CST 起,依 production audit 續補二階風險:同規格但一側為潔膚露、一側為修護乳/乳液直接 `cleanser_lotion_line_conflict` hard veto私密防護慕絲多款可選 vs 單一香型、滋養霜單側清爽型只進 `variant_selection_review`
## 3. 12 Agent 決策信封整合

View File

@@ -475,6 +475,7 @@ FOCUSED_IDENTITY_REVIEW_ONLY_REASONS = {
"johnsons_baby_lotion_variant_catalog",
"im_meme_fixx_cool_setting_spray",
"so_natural_fixx_setting_spray_catalog",
"kate_powder_case_catalog",
"kate_monster_lipstick_catalog",
"opi_gel_polish_series_catalog",
"romand_juicy_lip_tint_2_catalog",
@@ -527,6 +528,8 @@ VARIANT_SENSITIVE_KEYWORDS = {
"私密清潔凝露",
"私密潔淨凝露",
"私密淨白清潔凝露",
"私密防護慕絲",
"慕絲",
"定妝噴霧",
"妝前防護乳",
"妝前乳",
@@ -543,8 +546,13 @@ VARIANT_SENSITIVE_KEYWORDS = {
"腮紅液",
"打亮液",
"蜜粉餅",
"粉餅盒",
"粉底棒",
"遮瑕棒",
"遮瑕蜜",
"護手霜",
"滋養霜",
"修護乳",
"修容打亮棒",
"防曬",
"防曬乳",
@@ -556,6 +564,8 @@ VARIANT_SENSITIVE_KEYWORDS = {
VARIANT_OPTION_COLOR_WORDS = {
"茉莉花",
"梔子花",
"白茶蘭花",
"白茶",
"白麝香",
"黑麝香",
"清新花園",
@@ -563,8 +573,18 @@ VARIANT_OPTION_COLOR_WORDS = {
"青檸羅勒",
"炭木香",
"無花果",
"鼠尾草",
"海鹽",
"檸檬草",
"茶樹",
"櫻花",
"繡球花",
"魔髮奇緣",
"清甜柚香",
"杏仁牛奶",
"杏仁",
"薄荷",
"橙花",
"完熟白桃",
"琥珀橙",
"干邑棕",
@@ -603,6 +623,12 @@ VARIANT_OPTION_COLOR_WORDS = {
"自然色",
"明亮色",
"透明色",
"清爽型",
"滋潤型",
"橡棕",
"暗灰",
"灰棕",
"淺玫粉",
"極光之藍",
"月光銀影",
}
@@ -1976,6 +2002,15 @@ def score_marketplace_match(
aroma_scent_variant_conflict = _has_aroma_scent_variant_conflict(left, right)
if aroma_scent_variant_conflict:
reasons.append("aroma_scent_variant_conflict")
ingredient_line_conflict = _has_core_ingredient_line_conflict(left, right)
if ingredient_line_conflict:
reasons.append("core_ingredient_line_conflict")
branded_powder_line_conflict = _has_branded_powder_line_conflict(left, right)
if branded_powder_line_conflict:
reasons.append("branded_powder_line_conflict")
cleanser_lotion_line_conflict = _has_cleanser_lotion_line_conflict(left, right)
if cleanser_lotion_line_conflict:
reasons.append("cleanser_lotion_line_conflict")
wax_lamp_size_letter_conflict = _has_wax_lamp_size_letter_conflict(left, right)
if wax_lamp_size_letter_conflict:
reasons.append("size_letter_variant_conflict")
@@ -2043,6 +2078,12 @@ def score_marketplace_match(
hard_veto = True
if aroma_scent_variant_conflict:
hard_veto = True
if ingredient_line_conflict:
hard_veto = True
if branded_powder_line_conflict:
hard_veto = True
if cleanser_lotion_line_conflict:
hard_veto = True
if wax_lamp_size_letter_conflict:
hard_veto = True
if nitori_diffuser_model_conflict:
@@ -3051,7 +3092,26 @@ def _has_hoi_candle_line_conflict(left: ProductIdentity, right: ProductIdentity)
def _has_aroma_scent_variant_conflict(left: ProductIdentity, right: ProductIdentity) -> bool:
pair_text = f"{left.searchable_name} {right.searchable_name}"
if not any(term in pair_text for term in ("香氛固體凝膠", "香氛凝膠", "空氣芳香劑", "車用香氛")):
if any(term in pair_text for term in ("護手霜", "融蠟燈", "蠟燭暖燈")):
return False
if not any(
term in pair_text
for term in (
"香氛固體凝膠",
"香氛凝膠",
"空氣芳香劑",
"車用香氛",
"車用擴香",
"擴香蕊",
"擴香罐",
"香薰蠟燭",
"香氛蠟燭",
"蠟燭",
"滾珠精油",
"香氛精油",
"植物精油",
)
):
return False
if _is_multi_variant_catalog_listing(left) or _is_multi_variant_catalog_listing(right):
return False
@@ -3070,8 +3130,19 @@ def _has_aroma_scent_variant_conflict(left: ProductIdentity, right: ProductIdent
"青檸羅勒",
"炭木香",
"無花果",
"白茶蘭花",
"白茶",
"檸檬草",
"茶樹",
"鼠尾草",
"海鹽",
"橙花",
"薄荷",
"杏仁",
"薰衣草",
"茉莉",
"櫻花",
"繡球花",
"玫瑰",
"雪松",
"檀香",
@@ -3085,6 +3156,52 @@ def _has_aroma_scent_variant_conflict(left: ProductIdentity, right: ProductIdent
return False
def _has_core_ingredient_line_conflict(left: ProductIdentity, right: ProductIdentity) -> bool:
pair_text = f"{left.searchable_name} {right.searchable_name}"
if not any(term in pair_text for term in ("油膏", "護膚油", "身體油", "精油", "", "乳霜")):
return False
ingredient_groups = {
"coconut_oil": ("椰子油", "coconut"),
"shea_butter": ("乳木果油", "shea"),
}
left_groups = {
group
for group, terms in ingredient_groups.items()
if any(term in left.searchable_name for term in terms)
}
right_groups = {
group
for group, terms in ingredient_groups.items()
if any(term in right.searchable_name for term in terms)
}
return bool(left_groups and right_groups and not (left_groups & right_groups))
def _has_branded_powder_line_conflict(left: ProductIdentity, right: ProductIdentity) -> bool:
if not ({"港香蘭"} & (left.brand_tokens & right.brand_tokens)):
return False
if "爽身粉" not in left.searchable_name or "爽身粉" not in right.searchable_name:
return False
named_lines = ("漢本", "艾魔菈")
left_lines = {line for line in named_lines if line in left.searchable_name}
right_lines = {line for line in named_lines if line in right.searchable_name}
return bool(left_lines and right_lines and not (left_lines & right_lines))
def _has_cleanser_lotion_line_conflict(left: ProductIdentity, right: ProductIdentity) -> bool:
if not (left.brand_tokens & right.brand_tokens):
return False
if not _has_overlapping_base_spec(left, right):
return False
cleanser_terms = ("潔膚露", "潔膚", "潔淨露", "潔面", "洗面乳", "cleanser")
lotion_terms = ("修護乳", "乳液", "身體乳", "潤膚乳", "lotion")
left_cleanser = any(term in left.searchable_name for term in cleanser_terms)
right_cleanser = any(term in right.searchable_name for term in cleanser_terms)
left_lotion = any(term in left.searchable_name for term in lotion_terms)
right_lotion = any(term in right.searchable_name for term in lotion_terms)
return bool((left_cleanser and right_lotion) or (right_cleanser and left_lotion))
def _standalone_size_letter_tokens(identity: ProductIdentity) -> set[str]:
text = identity.searchable_name
return {
@@ -3174,17 +3291,38 @@ def _has_catalog_specific_variant_selection_gap(left: ProductIdentity, right: Pr
"美體主張",
"私密潔浴露",
"私密潔浴",
"私密防護慕絲",
"私密慕絲",
"慕絲",
"嬰兒潤膚乳",
"定妝噴霧",
"染眉膏",
"眼線膠筆",
"粉餅盒",
"遮瑕蜜",
"護手霜",
"護唇膏",
"護唇棒",
"唇釉",
"唇膏",
"蜜粉",
"防曬素顏霜",
"車用香氛",
"車用擴香",
"車用擴香蕊",
"香氛擴香罐",
"擴香罐",
"擴香蕊",
"水性指甲油",
"指甲油",
"融蠟小夜燈",
"融蠟燈",
"滋養霜",
)
):
return False
left_catalog = _is_multi_variant_catalog_listing(left)
right_catalog = _is_multi_variant_catalog_listing(right)
left_catalog = _is_catalog_or_delimited_variant_listing(left)
right_catalog = _is_catalog_or_delimited_variant_listing(right)
return left_catalog != right_catalog
@@ -3560,6 +3698,12 @@ def _has_focused_low_score_exact_identity_line(left: ProductIdentity, right: Pro
and "全天候超完美定妝噴霧" in right_text
):
return "so_natural_fixx_setting_spray_catalog"
if (
{"kate", "凱婷"} & (left.brand_tokens & right.brand_tokens)
and "粉餅盒" in left_text
and "粉餅盒" in right_text
):
return "kate_powder_case_catalog"
if (
{"kate", "凱婷"} & (left.brand_tokens & right.brand_tokens)
and "怪獸級持色唇膏" in left_text
@@ -3749,6 +3893,42 @@ def _is_multi_variant_catalog_listing(identity: ProductIdentity) -> bool:
return any(phrase in text for phrase in MULTI_VARIANT_LISTING_PHRASES)
def _normalize_variant_option(value: str) -> set[str]:
compact = re.sub(r"[^a-z0-9]", "", (value or "").lower())
if not compact:
return set()
return {compact}
def _variant_option_compare_key(option: str) -> str:
if option.isdigit():
return option.lstrip("0") or "0"
return option
def _variant_options_overlap(left_options: set[str], right_options: set[str]) -> bool:
if left_options & right_options:
return True
left_keys = {_variant_option_compare_key(option) for option in left_options}
right_keys = {_variant_option_compare_key(option) for option in right_options}
return bool(left_keys & right_keys)
def _is_catalog_or_delimited_variant_listing(identity: ProductIdentity) -> bool:
if _is_multi_variant_catalog_listing(identity):
return True
text = identity.searchable_name
if re.search(r"(?<![a-z0-9])([a-z]?\d{1,3}[a-z]?)\s*(?:~||至|-)\s*([a-z]?\d{1,3}[a-z]?)(?![a-z0-9])", text, re.I):
return True
options = _explicit_variant_option_tokens(identity)
if len(options) < 2:
return bool(
re.search(r"[//、,.&]", text)
and any(term in text for term in ("粉餅盒", "眼線膠筆", "眉筆", "唇膏", "唇釉", "遮瑕蜜", "車用擴香", "車用香氛"))
)
return bool(re.search(r"[//、,.&]", text))
def _has_catalog_variant_listing_alignment(left: ProductIdentity, right: ProductIdentity) -> bool:
if not (_is_multi_variant_catalog_listing(left) and _is_multi_variant_catalog_listing(right)):
return False
@@ -3823,16 +4003,15 @@ def _has_variant_descriptor_conflict(left: ProductIdentity, right: ProductIdenti
def _explicit_variant_option_tokens(identity: ProductIdentity) -> set[str]:
text = identity.searchable_name
options: set[str] = set()
for match in re.finditer(r"(?<![a-z0-9])([a-z]?\d{1,3}[a-z]?)\s*(?:~||至|-)\s*([a-z]?\d{1,3}[a-z]?)(?![a-z0-9])", text, re.I):
for group in (match.group(1), match.group(2)):
options.update(_normalize_variant_option(group))
for match in re.finditer(r"(?:#|no\.?|色號|號色)\s*([a-z]?\d{1,3}[a-z]?)(?![a-z0-9])", text, re.I):
value = re.sub(r"[^a-z0-9]", "", match.group(1).lower())
if value:
options.add(value)
options.update(_normalize_variant_option(match.group(1)))
for match in re.finditer(r"(?<![a-z0-9])((?:0?\d){1,2})(?=[\u4e00-\u9fff])", text, re.I):
if text[match.end(1):match.end(1) + 4] in {"號護唇膏", "號護脣膏"}:
continue
value = re.sub(r"[^a-z0-9]", "", match.group(1).lower())
if value:
options.add(value)
options.update(_normalize_variant_option(match.group(1)))
for color_word in VARIANT_OPTION_COLOR_WORDS:
if color_word in text:
options.add(color_word)
@@ -3891,9 +4070,21 @@ def _has_explicit_variant_option_conflict(
return False
if left_options == right_options:
return False
if left_options & right_options:
if _variant_options_overlap(left_options, right_options):
if _has_catalog_options_against_generic_count_alignment(left, right, left_options, right_options):
return False
pair_text = f"{left.searchable_name} {right.searchable_name}"
if any(term in pair_text for term in ("眉筆", "眼線膠筆", "唇膏", "唇釉", "粉餅盒", "遮瑕蜜")) and (
(
len(left_options) > len(right_options)
and _is_catalog_or_delimited_variant_listing(left)
)
or (
len(right_options) > len(left_options)
and _is_catalog_or_delimited_variant_listing(right)
)
):
return False
if (
len(left_options) > len(right_options)
and _has_variant_option_selection_gap(left, left_options)
@@ -3923,15 +4114,37 @@ def _has_named_variant_selection_review(
return True
left_options = _explicit_variant_option_tokens(left)
right_options = _explicit_variant_option_tokens(right)
if left_options and right_options:
for catalog_identity, catalog_options, specific_options in (
(left, left_options, right_options),
(right, right_options, left_options),
):
if (
_is_catalog_or_delimited_variant_listing(catalog_identity)
and len(catalog_options) > len(specific_options)
and _variant_options_overlap(catalog_options, specific_options)
and _is_variant_sensitive_identity(left, right, shared_anchor)
):
return True
if bool(left_options) != bool(right_options):
option_identity = left if left_options else right
catalog_identity = right if left_options else left
if (
_is_variant_sensitive_identity(left, right, shared_anchor)
and _is_multi_variant_catalog_listing(catalog_identity)
and _is_catalog_or_delimited_variant_listing(catalog_identity)
and _explicit_variant_option_tokens(option_identity)
):
return True
if (
_is_variant_sensitive_identity(left, right, shared_anchor)
and _has_overlapping_base_spec(left, right)
and _explicit_variant_option_tokens(option_identity)
and any(
term in f"{left.searchable_name} {right.searchable_name}"
for term in ("粉餅盒", "護手霜", "護唇膏", "護唇棒", "滋養霜", "眼線膠筆", "遮瑕蜜")
)
):
return True
if bool(left_options) == bool(right_options):
return False

View File

@@ -2286,6 +2286,91 @@ def test_marketplace_matcher_sends_catalog_specific_variant_gaps_to_review():
assert "variant_selection_review" in diagnostics.reasons
def test_marketplace_matcher_blocks_scent_and_core_line_conflicts():
from services.marketplace_product_matcher import score_marketplace_match
rolling_oil_scent_gap = score_marketplace_match(
"【AUS LIFE 澳思萊】檸檬草滾珠精油 5.3ml",
"【AUS LIFE澳思萊】茶樹滾珠精油5.3ml",
)
ingredient_gap = score_marketplace_match(
"【NOW娜奧】純椰子油膏207ml-Now Foods",
"【NOW 娜奧】Now Foods 純乳木果油油膏 207ml",
)
powder_line_gap = score_marketplace_match(
"【港香蘭】漢本爽身粉100g/盒|誠意中西藥局",
"【港香蘭】艾魔菈 草本爽身粉(100g/罐)",
)
assert rolling_oil_scent_gap.hard_veto is True
assert "aroma_scent_variant_conflict" in rolling_oil_scent_gap.reasons
assert ingredient_gap.hard_veto is True
assert "core_ingredient_line_conflict" in ingredient_gap.reasons
assert powder_line_gap.hard_veto is True
assert "branded_powder_line_conflict" in powder_line_gap.reasons
def test_marketplace_matcher_sends_delimited_or_range_catalog_variants_to_review():
from services.marketplace_product_matcher import score_marketplace_match
powder_case = score_marketplace_match(
"【KATE 凱婷】零瑕肌密柔焦粉餅盒/極致零瑕光粉餅盒(單售粉盒)",
"凱婷 極致零瑕光粉餅盒",
)
shu_brow = score_marketplace_match(
"【Shu uemura 植村秀】武士刀眉筆(平輸航空版/多色任選/橡棕.暗灰. 灰棕)",
"《Shu Uemura 植村秀》武士刀眉筆(H9) 4g -#橡棕06",
)
peripera_range = score_marketplace_match(
"【peripera】韓國【PERIPERA 速描眼線膠筆 01~07】現貨 韓國境內版",
"PERIPERA 速描眼線膠筆07淺玫粉",
)
jo_malone = score_marketplace_match(
"【Jo Malone】車用擴香蕊芯1入 多款可選(國際航空版)",
"JO MALONE 車用擴香蕊心-鼠尾草與海鹽 1入",
)
for diagnostics in (powder_case, shu_brow, peripera_range, jo_malone):
assert diagnostics.hard_veto is False
assert diagnostics.score >= 0.76
assert diagnostics.price_basis == "manual_review"
assert diagnostics.alert_tier == "identity_review"
assert "variant_selection_review" in diagnostics.reasons
def test_marketplace_matcher_blocks_cleanser_lotion_line_conflict():
from services.marketplace_product_matcher import score_marketplace_match
diagnostics = score_marketplace_match(
"【Cetaphil 舒特膚】官方直營 三酸煥膚嫩亮修護乳473ml",
"Cetaphil 舒特膚 三酸煥膚嫩亮潔膚露 473ml",
)
assert diagnostics.hard_veto is True
assert diagnostics.comparison_mode == "not_comparable"
assert "cleanser_lotion_line_conflict" in diagnostics.reasons
def test_marketplace_matcher_sends_single_sided_private_mousse_and_cream_variants_to_review():
from services.marketplace_product_matcher import score_marketplace_match
private_mousse = score_marketplace_match(
"【isLeaf】韓國女性私密防護慕絲250ml二款可選",
"韓國isLeaf 女性私密防護慕絲250ml 花妍巧語",
)
cream_variant = score_marketplace_match(
"【雪芙蘭】滋養霜60g(撫平粗糙 重現光滑)",
"【雪芙蘭】滋養霜《清爽型》60g",
)
for diagnostics in (private_mousse, cream_variant):
assert diagnostics.hard_veto is False
assert diagnostics.score >= 0.76
assert diagnostics.price_basis == "manual_review"
assert diagnostics.alert_tier == "identity_review"
assert "variant_selection_review" in diagnostics.reasons
def test_marketplace_matcher_promotes_eaoron_classic_tone_up_cream_exact_line():
from services.marketplace_product_matcher import score_marketplace_match