From 83b813c1b9b089972ee7a4d9680f190b3beae7b6 Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 1 Jun 2026 10:33:22 +0800 Subject: [PATCH] =?UTF-8?q?V10.544=20=E6=94=B6=E6=96=82=E8=AE=8A=E9=AB=94?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E8=88=87=20YES=20=E5=B7=A5=E5=85=B7=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- docs/memory/history_logs.md | 1 + services/competitor_price_feeder.py | 36 +++++ services/marketplace_product_matcher.py | 166 +++++++++++++++++++++- tests/test_marketplace_product_matcher.py | 100 +++++++++++++ 6 files changed, 300 insertions(+), 6 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index d15bef0..6fdb07a 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.544 收斂變體安全與 YES 指甲工具線:新增 YES 德悅氏指甲剪附除垢銼刀、腳皮銼腳板、藍寶石銼刀、三面拋光棒與 6/8cm 指甲剪的精準 total-price 線,要求同品牌、同工具名稱、同尺寸與同亮面/霧面/可收納/三面/不掉屑等款式訊號;同步接進 revalidation SQL。新增 MUJI / COCODOR 未知香味差異與 OPI 無型號不同色名 hard veto,HOOOME 暖燈材質差留人工覆核,搜尋詞也會優先帶香味/色名,提升 crawler 精準候選率。 - V10.543 打通 `rescore_accepted_current` 窄門回刷:已進人工覆核池的候選若命中具名 focused exact 線,可進 `run_retryable_candidate_revalidation()` 重新評分;新增 SK-II 青春露 330ml 兩入、AMIINO 安美諾 30ml、YES 腳指甲剪刀 10.5cm、YES 極細指甲緣硬皮剪刀 9cm 的安全 total-price 線,並補 ANNY / OPI 指甲油型號 code hard veto,避免不同色號被錯配。 - V10.542 拆開「可用比價覆蓋率」與「身份覆蓋率」:`decision_ready_rate = fresh identity / ACTIVE 商品數`,Dashboard 第一張 KPI 改顯示真正可進入決策、圖表、簡報的比價資料比例;daily / growth / Webcrumbs / OpenClaw payload 同步輸出,避免把身份覆蓋、新鮮率、價格可用率混成單一數字。 - V10.541 補正式覆核頁高信心 exact 線:The Ordinary 咖啡因 EGCG 單側漏 30ml、Natures Care 綿羊油同入數 125ml/125m、TOMOON 指甲剪同 L/S 尺寸、HH 私密潔淨露+衣物手洗精雙 200ml、SEBAMED 護潔露 200ml x2、YES 德悅氏 9cm 剪刀;都同步進 revalidation SQL,且 TOMOON/O.P.I 不同型號或尺寸仍不得自動通過。 diff --git a/config.py b/config.py index aff54d3..23d6ebd 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.543" +SYSTEM_VERSION = "V10.544" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 75b8399..c02d3e1 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.544 變體安全與 YES 工具線收斂**: 延續近門檻 `low_score` 救回,但把安全邊界補得更硬。新增 YES 德悅氏指甲工具精準線,只有同品牌、同工具線、同尺寸且同亮面/霧面/可收納/三面等關鍵款式時才進 `total_price`,並接入 revalidation SQL。同步新增未知香味差異與無型號指彩色名差異 hard veto:MUJI / COCODOR 不同香型、OPI 無型號不同色名不再被高分誤配;HOOOME 暖燈陶瓷/玻璃/水晶/金屬等材質差保留人工覆核。搜尋詞對護手霜、擴香瓶、無型號指彩優先帶上香味/色名,提升 crawler 找到真同款候選的機率。 - **V10.543 rescore accepted 窄門回刷與高信心線補強**: `run_retryable_candidate_revalidation()` 追加 `rescore_accepted_current` 窄門,只允許已進人工池且命中具名 focused exact 品線的候選重新評分,仍由 matcher 判定是否可寫正式價差。新增 SK-II 青春露 330ml 兩入、AMIINO 安美諾美白修護霜 30ml、YES 腳指甲剪刀 10.5cm、YES 極細指甲緣硬皮剪刀 9cm 的 total-price 安全線;同時新增指甲油型號衝突防線,ANNY `A10.074.60` vs `A10.500`、OPI 不同 `ISL...` 型號都會 hard veto,不會為了拉覆蓋率誤配色號。 - **V10.542 可用比價覆蓋率口徑拆分**: `fetch_competitor_coverage()` 新增 `decision_ready_matches` / `decision_ready_rate`,以「高信心 identity 且價格仍新鮮」除以 ACTIVE 商品數,和 `match_rate`(身份覆蓋)及 `fresh_match_rate`(已配對中的新鮮率)分開。Dashboard 第一屏改顯示可用比價覆蓋率,daily / growth / Webcrumbs / OpenClaw payload 同步輸出,避免使用者把舊截圖的低價格可用率、身份覆蓋率與新鮮率混成同一個 KPI。 - **V10.541 高信心覆核池 exact 線再收斂**: 從正式覆核頁抓到多筆 `rescore_accepted_current` 仍卡人工的同款:The Ordinary 咖啡因 EGCG(MOMO 單側漏 30ml)、Natures Care 綿羊油同入數、TOMOON 指甲剪同 L/S 尺寸、HH 私密潔淨露+私密衣物手洗精雙 200ml、SEBAMED 護潔露 200ml x2、YES 德悅氏 9cm 剪刀。新增 focused total-price 規則與 revalidation SQL;TOMOON / O.P.I 仍要求同型號或同尺寸,避免跨款式誤配。 diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 01df863..c1b53a0 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -1518,6 +1518,42 @@ class CompetitorPriceFeeder: AND lower(COALESCE(p.name, '')) LIKE '%9cm%' AND lower(COALESCE(la.best_competitor_product_name, '')) LIKE '%9cm%' ) + OR ( + ( + lower(COALESCE(p.name, '')) LIKE '%yes%' + OR COALESCE(p.name, '') LIKE '%德悅氏%' + ) + AND ( + lower(COALESCE(la.best_competitor_product_name, '')) LIKE '%yes%' + OR COALESCE(la.best_competitor_product_name, '') LIKE '%德悅氏%' + ) + AND ( + ( + COALESCE(p.name, '') LIKE '%指甲剪附除垢銼刀%' + AND COALESCE(la.best_competitor_product_name, '') LIKE '%指甲剪附除垢銼刀%' + ) + OR ( + COALESCE(p.name, '') LIKE '%腳皮銼腳板%' + AND COALESCE(la.best_competitor_product_name, '') LIKE '%腳皮銼腳板%' + ) + OR ( + COALESCE(p.name, '') LIKE '%藍寶石銼刀%' + AND COALESCE(la.best_competitor_product_name, '') LIKE '%藍寶石銼刀%' + ) + OR ( + COALESCE(p.name, '') LIKE '%指甲拋光棒%' + AND COALESCE(la.best_competitor_product_name, '') LIKE '%指甲拋光棒%' + ) + OR ( + COALESCE(p.name, '') LIKE '%指甲剪%' + AND COALESCE(la.best_competitor_product_name, '') LIKE '%指甲剪%' + AND COALESCE(p.name, '') NOT LIKE '%附除垢%' + AND COALESCE(la.best_competitor_product_name, '') NOT LIKE '%附除垢%' + AND COALESCE(p.name, '') NOT LIKE '%腳指甲%' + AND COALESCE(la.best_competitor_product_name, '') NOT LIKE '%腳指甲%' + ) + ) + ) ) ) ) diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index b80a0df..1ea7741 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -511,6 +511,7 @@ FOCUSED_IDENTITY_VARIANT_REVIEW_BYPASS_REASONS = { "yes_cuticle_scissors_9cm", "yes_curved_scissors_9cm", "yes_foot_nail_scissors_10_5cm", + "yes_nail_tool_exact_model_size", "cetaphil_long_lotion_237ml", "cetaphil_long_lotion_473ml", "clarins_double_serum_eye_20ml", @@ -560,6 +561,7 @@ FOCUSED_IDENTITY_TOTAL_PRICE_REASONS = { "yes_cuticle_scissors_9cm", "yes_curved_scissors_9cm", "yes_foot_nail_scissors_10_5cm", + "yes_nail_tool_exact_model_size", "bone_diffuser_gift_3pack", "selection1990_half_dome_wax_lamp_white", "selection1990_bendable_wax_lamp_white", @@ -622,6 +624,8 @@ VARIANT_SENSITIVE_KEYWORDS = { "素顏霜", "粉底", "美甲片", + "指甲油", + "指彩", "眼影盤", "唇釉", "唇膏", @@ -2225,6 +2229,12 @@ 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") + unknown_scent_variant_conflict = _has_unknown_scent_variant_conflict(left, right) + if unknown_scent_variant_conflict: + reasons.append("unknown_scent_variant_conflict") + nail_polish_color_name_conflict = _has_nail_polish_color_name_conflict(left, right) + if nail_polish_color_name_conflict: + reasons.append("nail_polish_color_name_conflict") ingredient_line_conflict = _has_core_ingredient_line_conflict(left, right) if ingredient_line_conflict: reasons.append("core_ingredient_line_conflict") @@ -2353,6 +2363,10 @@ def score_marketplace_match( hard_veto = True if aroma_scent_variant_conflict: hard_veto = True + if unknown_scent_variant_conflict: + hard_veto = True + if nail_polish_color_name_conflict: + hard_veto = True if ingredient_line_conflict: hard_veto = True if clarins_body_oil_line_conflict: @@ -3108,6 +3122,7 @@ def _shared_model_tokens(left: ProductIdentity, right: ProductIdentity) -> set[s def _variant_descriptors(identity: ProductIdentity) -> set[str]: descriptors: set[str] = set() + brand_compacts = {brand.replace(" ", "") for brand in identity.brand_tokens} for token in identity.core_tokens: value = token for anchor in sorted(_extract_anchor_phrases(token), key=len, reverse=True): @@ -3116,6 +3131,8 @@ def _variant_descriptors(identity: ProductIdentity) -> set[str]: compact = value.replace(" ", "") if len(compact) < 2: continue + if compact in brand_compacts: + continue if compact in SEARCH_NOISE_TOKENS or compact in SEARCH_BROAD_ANCHORS: continue if any(keyword in compact for keyword in VARIANT_DESCRIPTOR_NOISE_KEYWORDS): @@ -3317,6 +3334,42 @@ def _has_nail_tool_function_conflict(left: ProductIdentity, right: ProductIdenti return bool((left_cleaning and right_plane) or (left_plane and right_cleaning)) +def _has_yes_nail_tool_exact_alignment(left: ProductIdentity, right: ProductIdentity) -> bool: + if not ({"yes", "德悅氏"} & (left.brand_tokens & right.brand_tokens)): + return False + left_text = left.searchable_name + right_text = right.searchable_name + pair_text = f"{left_text} {right_text}" + if not any(term in pair_text for term in ("指甲剪", "銼刀", "腳皮銼", "拋光棒")): + return False + + exact_lines = ( + ("指甲剪附除垢銼刀", ("8cm",), ("亮面", "霧面")), + ("腳皮銼腳板", ("23.5cm",), ()), + ("藍寶石銼刀", ("9cm",), ("可收納",)), + ("指甲拋光棒", ("17.5cm",), ("三面",)), + ) + for line, sizes, required_options in exact_lines: + if line not in left_text or line not in right_text: + continue + if not any(size in left_text and size in right_text for size in sizes): + continue + if required_options and not any(option in left_text and option in right_text for option in required_options): + continue + return True + + if "指甲剪" in left_text and "指甲剪" in right_text: + excluded = ("附除垢", "腳", "硬皮", "鋒利窄弧型") + if any(term in left_text or term in right_text for term in excluded): + return False + if not any(size in left_text and size in right_text for size in ("6cm", "8cm")): + return False + finishes = ("亮面", "霧面", "不掉屑") + return any(finish in left_text and finish in right_text for finish in finishes) + + return False + + def _has_schick_razor_line_conflict(left: ProductIdentity, right: ProductIdentity) -> bool: left_text = left.searchable_name right_text = right.searchable_name @@ -3484,6 +3537,74 @@ def _has_aroma_scent_variant_conflict(left: ProductIdentity, right: ProductIdent return False +def _has_unknown_scent_variant_conflict(left: ProductIdentity, right: ProductIdentity) -> bool: + pair_text = f"{left.searchable_name} {right.searchable_name}" + if any(term in pair_text for term in ("暖燈", "融蠟燈", "融燭燈", "香氛燈")): + return False + if any(term in pair_text for term in ("香氛誘霜", "車用", "芳香劑", "香氛機", "擴香儀", "噴香機")): + return False + if not ( + ("護手霜" in pair_text and any(term in pair_text for term in ("芬香", "香味", "香氣", "精油"))) + or "擴香瓶" in pair_text + ): + return False + if _is_multi_variant_catalog_listing(left) or _is_multi_variant_catalog_listing(right): + return False + left_descriptors = _variant_descriptors(left) + right_descriptors = _variant_descriptors(right) + if not left_descriptors or not right_descriptors: + return False + shared = _shared_variant_descriptors(left, right) + left_unique = left_descriptors - shared + right_unique = right_descriptors - shared + descriptor_noise = ("護手霜", "擴香瓶", "精油芬香", "經典擴香") + left_unique = { + descriptor for descriptor in left_unique + if 2 <= len(descriptor) <= 6 and not any(noise in descriptor for noise in descriptor_noise) + } + right_unique = { + descriptor for descriptor in right_unique + if 2 <= len(descriptor) <= 6 and not any(noise in descriptor for noise in descriptor_noise) + } + if not left_unique or not right_unique: + return False + for left_descriptor in left_unique: + for right_descriptor in right_unique: + if left_descriptor == right_descriptor: + return False + if left_descriptor in right_descriptor or right_descriptor in left_descriptor: + return False + return True + + +def _has_nail_polish_color_name_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 + if _shared_model_tokens(left, right): + return False + left_model_codes = _nail_polish_model_codes(left) + right_model_codes = _nail_polish_model_codes(right) + if left_model_codes and right_model_codes: + return left_model_codes.isdisjoint(right_model_codes) + if _is_multi_variant_catalog_listing(left) or _is_multi_variant_catalog_listing(right): + return False + left_descriptors = _variant_descriptors(left) + right_descriptors = _variant_descriptors(right) + if not left_descriptors or not right_descriptors: + return False + shared = _shared_variant_descriptors(left, right) + left_unique = left_descriptors - shared + right_unique = right_descriptors - shared + if not left_unique or not right_unique: + return False + for left_descriptor in left_unique: + for right_descriptor in right_unique: + if left_descriptor in right_descriptor or right_descriptor in left_descriptor: + return False + return True + + def _has_aroma_lamp_style_selection_gap(left: ProductIdentity, right: ProductIdentity) -> bool: pair_text = f"{left.searchable_name} {right.searchable_name}" if not ({"les", "nez", "香鼻子"} & (left.brand_tokens & right.brand_tokens)): @@ -3629,7 +3750,7 @@ def _has_hooome_wax_lamp_design_gap(left: ProductIdentity, right: ProductIdentit pair_text = f"{left.searchable_name} {right.searchable_name}" if not any(term in pair_text for term in ("香氛蠟燭暖燈", "蠟燭暖燈", "融蠟燈")): return False - concrete_design_terms = ("大理石", "雲石", "原木", "半圓罩") + concrete_design_terms = ("大理石", "雲石", "原木", "半圓罩", "陶瓷", "玻璃", "水晶", "金屬", "鐵藝") left_designs = {term for term in concrete_design_terms if term in left.searchable_name} right_designs = {term for term in concrete_design_terms if term in right.searchable_name} return bool(left_designs or right_designs) and left_designs != right_designs @@ -4809,6 +4930,8 @@ def _has_focused_low_score_exact_identity_line(left: ProductIdentity, right: Pro and "9cm" in right_text ): return "yes_cuticle_scissors_9cm" + if _has_yes_nail_tool_exact_alignment(left, right): + return "yes_nail_tool_exact_model_size" if ( {"kussen", "葵森"} & (left.brand_tokens & right.brand_tokens) and "寶寶益菌屁屁膏" in left_text @@ -5298,7 +5421,8 @@ def build_search_terms(name: str, max_terms: int = 3) -> list[str]: return short_latin[0] if short_latin else "" brand_part = primary_brand_phrase() - spec_part = " ".join(_search_spec_terms(identity)) + spec_terms = _search_spec_terms(identity) + spec_part = " ".join(spec_terms) core_phrases = _ranked_search_core_phrases(identity, limit=4) full_name_anchor_phrases = _extract_anchor_phrases(name) if full_name_anchor_phrases: @@ -5319,7 +5443,10 @@ def build_search_terms(name: str, max_terms: int = 3) -> list[str]: part for part in (chinese_detail_phrases[0] if chinese_detail_phrases else "", core_primary) if part ) variant_primary = _variant_primary_phrase(identity) - variant_options = sorted(_explicit_variant_option_tokens(identity), key=lambda token: (len(token), token)) + variant_options = sorted( + (token for token in _explicit_variant_option_tokens(identity) if token != "0"), + key=lambda token: (len(token), token), + ) variant_option_part = " ".join(variant_options[:2]) model_phrases = [ phrase @@ -5327,16 +5454,45 @@ def build_search_terms(name: str, max_terms: int = 3) -> list[str]: if re.fullmatch(r"[a-z]*\d+[a-z0-9-]*", phrase) or re.fullmatch(r"[a-z][a-z0-9-]{2,}", phrase) ] + if "護甲油" in identity.searchable_name: + model_phrases = [ + phrase for phrase in model_phrases + if phrase.lower() not in {"top", "coat"} and not re.fullmatch(r"ist\d+", phrase, re.I) + ] primary_with_model = " ".join( part for part in (core_primary, model_phrases[0] if model_phrases else "") if part ) variant_sensitive = any(keyword in identity.searchable_name for keyword in VARIANT_SENSITIVE_KEYWORDS) + model_like_spec = any( + re.search(r"[a-z]", term) + and re.search(r"\d", term) + and not re.fullmatch(r"\d+(?:\.\d+)?(?:ml|g|mg|kg|l)", term, re.I) + for term in spec_terms + ) + prefer_variant_search = ( + variant_sensitive + and bool(variant_primary) + and not model_phrases + and not model_like_spec + and not variant_options + and "護甲油" not in identity.searchable_name + and any( + term in identity.searchable_name + for term in ("護手霜", "芬香", "香氛", "香味", "擴香", "精油", "指甲油", "指彩") + ) + ) for value in ( " ".join(part for part in (brand_part, core_primary, variant_primary, spec_part) if part) if is_dashing_diva_nail_line and variant_sensitive and variant_primary else "", " ".join(part for part in (brand_part, core_primary, variant_option_part, spec_part) if part) - if variant_sensitive and variant_option_part + if variant_sensitive and variant_option_part and not model_phrases and not model_like_spec + else "", + " ".join(part for part in (brand_part, core_primary, variant_primary, spec_part) if part) + if prefer_variant_search + else "", + " ".join(part for part in (brand_part, primary_with_model, spec_part) if part) + if primary_with_model and model_phrases else "", " ".join(part for part in (brand_part, modifier_with_primary, spec_part) if part) if modifier_with_primary and identity.product_type and identity.product_type in core_primary @@ -5345,7 +5501,7 @@ def build_search_terms(name: str, max_terms: int = 3) -> list[str]: if variant_sensitive and core_primary and not variant_options else "", " ".join(part for part in (brand_part, core_primary, variant_primary, spec_part) if part) - if variant_sensitive and variant_primary and variant_options + if variant_sensitive and variant_primary and variant_options and not model_phrases and not model_like_spec else "", " ".join(part for part in (brand_part, primary_with_model, spec_part) if part), " ".join(part for part in (brand_part, core_short, spec_part) if part), diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index 5d69a8a..c4c333c 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -651,6 +651,36 @@ def test_marketplace_matcher_promotes_focused_exact_pack_rows_to_total_price(): "【YES 德悅氏】德國製造 極細指甲緣硬皮剪刀(9cm)", "focused_exact_identity_yes_cuticle_scissors_9cm", ), + ( + "【YES 德悅氏】德國進口 指甲剪附除垢銼刀-亮面(8cm)", + "【YES 德悅氏】德國製造 指甲剪附除垢銼刀-亮面(8cm)", + "focused_exact_identity_yes_nail_tool_exact_model_size", + ), + ( + "【YES 德悅氏】德國進口 指甲剪附除垢銼刀-霧面(8cm)", + "【YES 德悅氏】德國製造 指甲剪附除垢銼刀-霧面(8cm)", + "focused_exact_identity_yes_nail_tool_exact_model_size", + ), + ( + "【YES 德悅氏】德國進口 腳皮銼腳板(23.5cm)", + "【YES 德悅氏】德國製造 腳皮銼腳板(23.5cm)", + "focused_exact_identity_yes_nail_tool_exact_model_size", + ), + ( + "【YES 德悅氏】德國進口 藍寶石銼刀-可收納(9cm)", + "【YES 德悅氏】德國製造 藍寶石銼刀-可收納(9cm)", + "focused_exact_identity_yes_nail_tool_exact_model_size", + ), + ( + "【YES 德悅氏】德國進口 指甲剪-不掉屑(6cm)", + "【YES 德悅氏】德國製造 指甲剪-不掉屑(6cm)", + "focused_exact_identity_yes_nail_tool_exact_model_size", + ), + ( + "【YES 德悅氏】德國進口 三面指甲拋光棒(17.5cm)", + "【YES 德悅氏】德國製造 指甲拋光棒-三面(17.5cm)", + "focused_exact_identity_yes_nail_tool_exact_model_size", + ), ( "【The Ordinary】Caffeine Solution 咖啡因 + EGCG兒茶眼部配方30ml", "The Ordinary 5%咖啡因 + EGCG兒茶眼部配方 (30ml)", @@ -3122,6 +3152,76 @@ def test_marketplace_matcher_keeps_generic_vs_specific_warmer_low_score(): assert "variant_descriptor_conflict" in diagnostics.reasons +def test_marketplace_matcher_blocks_unknown_scent_variant_conflicts(): + from services.marketplace_product_matcher import score_marketplace_match + + cases = [ + ( + "【無印良品】精油芬香護手霜50g 雨後森林", + "無印良品 MUJI 精油芬香護手霜 50g 午後陽光", + ), + ( + "【COCODOR】經典擴香瓶200ml 雨後森林", + "cocodor 經典擴香瓶 200ml 午後陽光", + ), + ] + + for momo_name, competitor_name in cases: + diagnostics = score_marketplace_match(momo_name, competitor_name, momo_price=399, competitor_price=390) + assert diagnostics.hard_veto is True + assert diagnostics.match_type == "no_match" + assert diagnostics.price_basis == "none" + assert diagnostics.alert_tier == "suppress" + assert "unknown_scent_variant_conflict" in diagnostics.reasons + + +def test_marketplace_matcher_blocks_nail_polish_cross_color_names_without_model_code(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【O.P.I】馬拉加葡萄酒 類光繚指甲油 15ml", + "OPI 極地白雪 類光繚指甲油 15ml", + momo_price=520, + competitor_price=500, + ) + + assert diagnostics.hard_veto is True + assert diagnostics.match_type == "no_match" + assert diagnostics.price_basis == "none" + assert diagnostics.alert_tier == "suppress" + assert "nail_polish_color_name_conflict" in diagnostics.reasons + + +def test_marketplace_matcher_keeps_wax_lamp_unknown_material_gap_in_review(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【HOOOME】香氛蠟燭暖燈 陶瓷白色款", + "HOOOME 香氛蠟燭暖燈 玻璃白色款", + momo_price=1280, + competitor_price=1180, + ) + + assert diagnostics.hard_veto is False + assert diagnostics.price_basis != "total_price" + assert diagnostics.alert_tier != "price_alert_exact" + assert "hooome_wax_lamp_design_gap" in diagnostics.reasons + assert "variant_selection_review" in diagnostics.reasons + + +def test_marketplace_search_terms_keep_unknown_variant_descriptor_first_for_sensitive_lines(): + from services.marketplace_product_matcher import build_search_terms + + opi_terms = build_search_terms("【O.P.I】馬拉加葡萄酒 類光繚指甲油 15ml", max_terms=5) + muji_terms = build_search_terms("【無印良品】精油芬香護手霜50g 雨後森林", max_terms=5) + cocodor_terms = build_search_terms("【COCODOR】經典擴香瓶200ml 雨後森林", max_terms=5) + + assert "馬拉加葡萄酒" in opi_terms[0] + assert "雨後森林" in muji_terms[0] + assert "雨後森林" in cocodor_terms[0] + assert "雨後森林" in " ".join(muji_terms[:2]) + + def test_marketplace_search_terms_prefer_readable_brand_core_spec(): from services.marketplace_product_matcher import build_search_terms