From bbf21b81af65ea3e9ccd398b0750c2f1f1485c37 Mon Sep 17 00:00:00 2001 From: OoO Date: Sun, 24 May 2026 12:27:12 +0800 Subject: [PATCH] V10.400 normalize cushion refill pack matches --- config.py | 2 +- docs/memory/history_logs.md | 1 + services/marketplace_product_matcher.py | 125 +++++++++++++++++++++- tests/test_marketplace_product_matcher.py | 67 +++++++++++- 4 files changed, 191 insertions(+), 4 deletions(-) diff --git a/config.py b/config.py index 4ac1018..c5ce7d2 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.399" +SYSTEM_VERSION = "V10.400" 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 de4c075..29db29d 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.400 氣墊粉餅補充蕊包數正規化**: marketplace matcher 針對氣墊粉餅新增保守的 `cushion_refill_pack_alignment`,只在一側明確為 `一盒兩蕊 / 2蕊`、另一側為單規格 `15g x2` 這類乘數包裝時,解除 `multi_component_conflict` 並進 `identity_review`。CLIO 羽緻無限緞光氣墊粉餅「一盒兩蕊」可被同款覆核;`2盒4蕊` 對 `15g x2` 仍維持 hard veto。同版補香氛蠟燭、TOKYO 車用消臭芳香劑、融蠟燈與有機護膚油 search identity anchors。離線 audit 759 筆 accepted 從 753 提升到 754,剩餘 5 筆 fresh veto 皆為應擋的套組/品類差異。 - **V10.399 多香味 catalog 價格告警降級**: marketplace matcher 新增 `variant_selection_review`,當一側是無明確選項的商品線、另一側列出多個具名香味/款式選項時,允許同款身份回收但只進 `identity_review`,不直接進 `price_alert_exact`。首個正式案例是 HH 女性私密衣物抗菌手洗精 200ml 對 PChome 白麝香/清新花園/寶貝粉香多香味 listing;此規則避免把多香味 catalog 價格誤當單一 variant 精準比價。 - **V10.398 true low confidence 保守回收**: marketplace matcher 針對正式前段 `true_low_confidence` 補一輪 focused exact identity lines,讓 Baan 嬰兒修護唇膏、植村秀 3D 極細防水眼線膠筆、YSL 恆久完美透膚煙染腮紅、HH 私密植萃美白緊緻凝露、Lab52 學習刷牙漱口水、Benefit 經典菲菲染唇液、Herb24 晨霧純精油擴香儀、Pavaruni 40 香味 10ml 精油與 GATSBY 爆水擦澡濕巾等近門檻真同款可被回收;未放寬 `MIN_MATCH_SCORE`。同版保留 peripera 多色任選對單一色號、LUNASOL 頰彩對眼彩組、MUJI 細軸棉棒對黑色棉棒的低信心保護,並讓多組件套組即使達強身份證據也停在 `identity_review`,避免總價被誤當精準價格告警。 diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index b6bc1e9..cd1b799 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -362,6 +362,10 @@ SEARCH_IDENTITY_ANCHORS = ( "晨霧純精油擴香儀", "天然植物香氛精油", "爆水擦澡濕巾", + "香氛蠟燭20種香味", + "tokyo車用夾式消臭芳香劑", + "北歐簡樸融蠟燈桌面氣氛夜燈", + "大地有機植萃護膚油", "3d立體持色眉彩盤", "細芯睛彩雙頭眉筆", "雙頭旋轉極細眉筆", @@ -1315,6 +1319,51 @@ def _multi_component_count(identity: ProductIdentity) -> int: return len(parts) if len(parts) > 1 else 1 +def _repeated_single_spec_count(identity: ProductIdentity) -> Optional[int]: + text = _component_separator_text(identity) + matches = re.findall( + r"\d+(?:\.\d+)?\s*(?:ml|g|mg|毫升|公克|毫克)\s*x\s*(\d+)", + text, + flags=re.I, + ) + if len(matches) != 1: + return None + try: + count = int(matches[0]) + except (TypeError, ValueError): + return None + return count if count > 1 else None + + +def _refill_piece_count(identity: ProductIdentity) -> Optional[int]: + refill_counts = [ + count + for count, unit in identity.counts + if _count_unit_family(unit) == "refill" + ] + return max(refill_counts) if refill_counts else None + + +def _has_cushion_refill_pack_alignment(left: ProductIdentity, right: ProductIdentity) -> bool: + """Align cushion compact refill language such as `一盒兩蕊` with `15g x2`.""" + if left.product_type != "氣墊粉餅" or right.product_type != "氣墊粉餅": + return False + + def aligned(refill_side: ProductIdentity, spec_side: ProductIdentity) -> bool: + refill_count = _refill_piece_count(refill_side) + spec_count = _repeated_single_spec_count(spec_side) + if not refill_count or not spec_count or refill_count != spec_count: + return False + box_counts = [ + count + for count, unit in refill_side.counts + if unit in {"盒", "組", "入"} and count > 1 + ] + return not box_counts + + return aligned(left, right) or aligned(right, left) + + def _has_refill_pack(identity: ProductIdentity) -> bool: text = identity.normalized_name return bool( @@ -1735,8 +1784,11 @@ def score_marketplace_match( ) if bundle_offer_conflict: reasons.append("bundle_offer_conflict") - if _has_multi_component(left) != _has_multi_component(right): + cushion_refill_pack_alignment = _has_cushion_refill_pack_alignment(left, right) + if _has_multi_component(left) != _has_multi_component(right) and not cushion_refill_pack_alignment: reasons.append("multi_component_conflict") + if cushion_refill_pack_alignment: + reasons.append("cushion_refill_pack_alignment") multi_component_count_conflict = ( _has_multi_component(left) and _has_multi_component(right) @@ -1770,7 +1822,7 @@ def score_marketplace_match( hard_veto = brand_conflict or spec_conflict if bundle_offer_conflict: hard_veto = True - if _has_multi_component(left) != _has_multi_component(right): + if _has_multi_component(left) != _has_multi_component(right) and not cushion_refill_pack_alignment: hard_veto = True if multi_component_count_conflict: hard_veto = True @@ -1898,6 +1950,18 @@ def score_marketplace_match( ): score += 0.025 reasons.append("strong_exact_spec_match") + if ( + cushion_refill_pack_alignment + and brand_score >= 0.95 + and not hard_veto + and price_penalty == 0 + and type_score >= 0.95 + and token_score >= 0.65 + and sequence_score >= 0.65 + and not variant_descriptor_conflict + ): + score += 0.04 + reasons.append("cushion_refill_pack_alignment_score") if ( focused_exact_line_reason and brand_score >= 0.95 @@ -2606,6 +2670,19 @@ def _has_pavaruni_40_scent_oil_alignment(left: ProductIdentity, right: ProductId ) +def _has_pavaruni_20_scent_candle_alignment(left: ProductIdentity, right: ProductIdentity) -> bool: + left_text = left.searchable_name + right_text = right.searchable_name + return ( + "pavaruni" in (left.brand_tokens & right.brand_tokens) + and "香氛蠟燭" in left_text + and "香氛蠟燭" in right_text + and _has_shared_weight(left, right, 450) + and ("20香味" in left_text or "20種香味" in left_text) + and ("20香味" in right_text or "20種香味" in right_text) + ) + + def _has_shared_count(left: ProductIdentity, right: ProductIdentity, count: int, unit: str) -> bool: return (count, unit) in set(left.counts) and (count, unit) in set(right.counts) @@ -2616,6 +2693,12 @@ def _has_shared_volume(left: ProductIdentity, right: ProductIdentity, volume_ml: ) +def _has_shared_weight(left: ProductIdentity, right: ProductIdentity, weight_g: float) -> bool: + return any(_close_number(value, weight_g) for value in left.weights_g) and any( + _close_number(value, weight_g) for value in right.weights_g + ) + + def _has_focused_low_score_exact_identity_line(left: ProductIdentity, right: ProductIdentity) -> str: left_text = left.searchable_name right_text = right.searchable_name @@ -2761,6 +2844,42 @@ def _has_focused_low_score_exact_identity_line(left: ProductIdentity, right: Pro return "herb24_mist_diffuser_black" if _has_pavaruni_40_scent_oil_alignment(left, right): return "pavaruni_40_scent_oil" + if _has_pavaruni_20_scent_candle_alignment(left, right): + return "pavaruni_20_scent_candle" + if ( + {"laundrin", "朗德林"} & (left.brand_tokens & right.brand_tokens) + and "tokyo" in left_text + and "tokyo" in right_text + and "車用" in left_text + and "車用" in right_text + and "芳香劑" in left_text + and "芳香劑" in right_text + and _has_shared_count(left, right, 1, "入") + ): + return "laundrin_tokyo_car_freshener" + if ( + "好物良品" in (left.brand_tokens & right.brand_tokens) + and "北歐簡樸融蠟燈桌面氣氛夜燈" in left_text + and "北歐簡樸融蠟燈桌面氣氛夜燈" in right_text + ): + return "goodgoods_nordic_wax_lamp" + if ( + {"derma", "丹麥德瑪"} & (left.brand_tokens & right.brand_tokens) + and "有機植萃" in left_text + and "有機植萃" in right_text + and "護膚油" in left_text + and "護膚油" in right_text + and _has_shared_volume(left, right, 150) + ): + return "derma_eco_skin_oil" + if ( + {"yuskin", "悠斯晶"} & (left.brand_tokens & right.brand_tokens) + and "乳霜" in left_text + and "乳霜" in right_text + and _has_shared_weight(left, right, 30) + and _has_shared_count(left, right, 6, "入") + ): + return "yuskin_classic_cream_30g_6pack" if ( "gatsby" in (left.brand_tokens & right.brand_tokens) and "爆水擦澡濕巾" in left_text @@ -2829,6 +2948,8 @@ def _has_variant_descriptor_conflict(left: ProductIdentity, right: ProductIdenti return False if _has_pavaruni_40_scent_oil_alignment(left, right): return False + if _has_pavaruni_20_scent_candle_alignment(left, right): + return False if _is_relove_private_cleanser_line(left, right): return False if ( diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index d68f736..661b511 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -859,6 +859,38 @@ def test_marketplace_matcher_keeps_named_scent_catalog_in_identity_review(): assert "variant_selection_review" in diagnostics.reasons +def test_marketplace_matcher_aligns_cushion_one_box_two_refills_with_spec_times_two(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【CLIO 珂莉奧 官方直營】羽緻無限緞光氣墊粉餅 SPF50+ PA+++(任選 一盒兩蕊)", + "珂莉奧 羽緻無限緞光氣墊粉餅 SPF50+, PA+++15g*2", + momo_price=885, + competitor_price=808, + ) + + assert diagnostics.score >= 0.76 + assert diagnostics.hard_veto is False + assert diagnostics.alert_tier == "identity_review" + assert "cushion_refill_pack_alignment" in diagnostics.reasons + assert "multi_component_conflict" not in diagnostics.reasons + + +def test_marketplace_matcher_keeps_cushion_four_refills_vs_spec_times_two_vetoed(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "即期品【CLIO 珂莉奧 官方直營】玫瑰精萃亮采氣墊粉餅SPF 50+ PA++++(2入組-任選 共2盒4蕊)", + "CLIO珂莉奧 粉鑽亮采氣墊粉餅 SPF50+, PA++++ (15gX2)", + momo_price=999, + competitor_price=1150, + ) + + assert diagnostics.score < 0.76 + assert diagnostics.hard_veto is True + assert "multi_component_conflict" in diagnostics.reasons + + def test_marketplace_matcher_promotes_multi_variant_catalog_listings(): from services.marketplace_product_matcher import score_marketplace_match @@ -1069,6 +1101,31 @@ def test_marketplace_matcher_promotes_focused_low_score_exact_identity_lines(): "【美國Pavaruni】天然植物香氛精油40種香味10ml 多款任選", "focused_exact_identity_pavaruni_40_scent_oil", ), + ( + "【Pavaruni】美國香氛蠟燭20種香味450g(禮盒大豆蠟植物天然精油花香木質果香生日聖誕交換女友女生情人禮物)", + "【美國Pavaruni】香氛蠟燭20種香味450g 卡啡那系列 多款任選", + "focused_exact_identity_pavaruni_20_scent_candle", + ), + ( + "【日本Laundrin朗德林】TOKYO汽車空調出風口專用夾式消臭芳香劑1入/袋(車用清新擴香劑香氛淨化馨香約30天)", + "日本Laundrin朗德林-TOKYO車用夾式消臭芳香劑1入/袋", + "focused_exact_identity_laundrin_tokyo_car_freshener", + ), + ( + "【好物良品】北歐簡樸融蠟燈桌面氣氛夜燈|附燈泡(氛圍燈 室內芳香 交換禮物 桌上檯燈 香氛蠟燭 擴香)", + "【好物良品】北歐簡樸融蠟燈桌面氣氛夜燈(2款任選|附燈泡)", + "focused_exact_identity_goodgoods_nordic_wax_lamp", + ), + ( + "【Derma 丹麥德瑪】大地有機植萃撫紋護膚油 150ml(有機植物油複合配方 滋潤不黏膩)", + "Derma 大地 Eco 有機植萃護膚油 150ml", + "focused_exact_identity_derma_eco_skin_oil", + ), + ( + "【悠斯晶】日本No.1維他命乳霜★經典乳霜30g(攜帶型 6入組)", + "【Yuskin悠斯晶】A乳霜 攜帶型 6盒組(30g/盒)", + "focused_exact_identity_yuskin_classic_cream_30g_6pack", + ), ( "【GATSBY】爆水擦澡濕巾24張入(涼感乾洗澡)", "GATSBY 爆水擦澡濕巾24張入(240g)", @@ -1102,8 +1159,16 @@ def test_marketplace_matcher_keeps_high_variant_low_score_lines_outside_focused_ "【MUJI 無印良品】細軸棉棒/200支", "【MUJI 無印良品】棉棒(黑色)/200支", ) + kate_limited = score_marketplace_match( + "【KATE 凱婷】絕美綻放睫毛膏(NANA限量聯名款)", + "【KATE 凱婷】絕美綻放睫毛膏 7.1g", + ) + schick_bundle_gap = score_marketplace_match( + "【Schick 舒適牌】舒芙仕女除毛刀把刀片組 (1刀把2刀片+刀片3入)", + "【Schick舒適牌】舒綺 仕女除毛刀 敏感肌用 1刀把+2刀片 送卡娜赫拉束口袋", + ) - for diagnostics in (lush, lactacyd, lunasol, muji_swab): + for diagnostics in (lush, lactacyd, lunasol, muji_swab, kate_limited, schick_bundle_gap): assert diagnostics.score < 0.76 assert not any(reason.startswith("focused_exact_identity_") for reason in diagnostics.reasons)