diff --git a/config.py b/config.py index 6340787..de10723 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.415" +SYSTEM_VERSION = "V10.416" 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 fc686c9..63d2897 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.416 私密清潔 / 彩妝用途 / 棉棒 / 蘭蔻品線防錯配**: marketplace matcher 追加窄範圍 hard-veto guard,讓 SAUGELLA 日用/加強 vs 黃金女郎型、Lactacyd 清新舒涼 vs 生理呵護、LUNASOL 頰彩 vs 眼彩、MUJI 細軸棉棒 vs 黑色棉棒、LANCOME 超極光晶露 vs 超極限肌因精華露不再停留在模糊 `true_low_confidence`,而是以 `*_variant_conflict` / `makeup_usage_conflict` / `lancome_line_conflict` 明確拒絕;不調整 `MIN_MATCH_SCORE`,也不放寬真同款進 matched 的門檻。 - **V10.415 Hermes 預設不落 111 + 比對保護**: `OllamaService.generate()` 新增 `allow_111_fallback` 參數,預設維持三主機相容;Hermes intent / competitor analyst 改以 `HERMES_ALLOW_111_FALLBACK=false` 預設只跑 GCP-A → GCP-B,兩台都不可用時交給規則引擎或 DB 證據 fallback,不再把批量價格分析與意圖分類轉嫁到 111。同版 marketplace matcher 將防曬類列入 variant-sensitive,排除 SPF/PA/UVA/UVB 這類規格 token 被誤當型號,避免「兒童防曬乳」與「海洋友善保濕防曬乳」誤配;Recipe Box 兒童防曬氣墊粉餅保留精準同品線例外;另新增 `pack_quantity_difference`,讓 Beauty Foot 足膜 5入 vs 4入走 unit comparable,不再卡在低信心。 - **V10.415 production pilot**: 上線後以 SKU `12670442` 單筆回刷驗證 Beauty Foot 足膜 5入 vs 4入:最新 attempt 由 `true_low_confidence` 轉為 `refresh_unit_comparable`,`diagnostic_codes` 補上 `pack_quantity_difference` / `unit_comparable`,`matched` 不增加、正式 `competitor_prices` 不覆寫;整體最新分布由 `true_low_confidence=760, refresh_unit_comparable=64` 變為 `true_low_confidence=759, refresh_unit_comparable=65`,符合「可單位價覆核但不可直接當同款總價告警」邊界。 - **V10.414 MCP fetch run readiness gate**: 新增 `mcp_fetch_run_readiness` read-only builder、GET/POST endpoint、UI run readiness 審核面板與 deployment readiness smoke target,在 run package 後檢查 command preview、receipt path、artifact path、節流/timeout/dry-run-first 與操作員 shell-only 邊界;API/UI 不執行 CLI、不抓外站、不寫檔、不開 DB、不掛 scheduler,只放行到人工 shell dry-run 與後續 receipt gate。 diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index a26ab1a..eb80a8a 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -1891,6 +1891,21 @@ def score_marketplace_match( variant_option_conflict = _has_explicit_variant_option_conflict(left, right, shared_anchor) if variant_option_conflict: reasons.append("variant_option_conflict") + saugella_variant_conflict = _has_saugella_private_wash_variant_conflict(left, right) + if saugella_variant_conflict: + reasons.append("saugella_variant_conflict") + lactacyd_variant_conflict = _has_lactacyd_private_wash_variant_conflict(left, right) + if lactacyd_variant_conflict: + reasons.append("lactacyd_variant_conflict") + makeup_usage_conflict = _has_makeup_usage_conflict(left, right) + if makeup_usage_conflict: + reasons.append("makeup_usage_conflict") + lancome_line_conflict = _has_lancome_ultra_line_conflict(left, right) + if lancome_line_conflict: + reasons.append("lancome_line_conflict") + cotton_swab_variant_conflict = _has_cotton_swab_variant_conflict(left, right) + if cotton_swab_variant_conflict: + reasons.append("cotton_swab_variant_conflict") variant_selection_review = _has_named_variant_selection_review(left, right, shared_anchor) if variant_selection_review: reasons.append("variant_selection_review") @@ -1918,6 +1933,16 @@ def score_marketplace_match( hard_veto = True if variant_option_conflict: hard_veto = True + if saugella_variant_conflict: + hard_veto = True + if lactacyd_variant_conflict: + hard_veto = True + if makeup_usage_conflict: + hard_veto = True + if lancome_line_conflict: + hard_veto = True + if cotton_swab_variant_conflict: + hard_veto = True focused_exact_line_reason = _has_focused_low_score_exact_identity_line(left, right) if focused_exact_line_reason in FOCUSED_IDENTITY_REVIEW_ONLY_REASONS: @@ -2664,6 +2689,82 @@ def _has_serum_formulation_conflict(left: ProductIdentity, right: ProductIdentit return bool(left_hit and right_hit and left_hit != right_hit) +def _has_saugella_private_wash_variant_conflict(left: ProductIdentity, right: ProductIdentity) -> bool: + left_text = left.searchable_name + right_text = right.searchable_name + if not ( + ("saugella" in left_text or "賽吉兒" in left_text) + and ("saugella" in right_text or "賽吉兒" in right_text) + ): + return False + variant_tokens = ("日用", "加強", "黃金女郎型") + left_hits = {token for token in variant_tokens if token in left_text} + right_hits = {token for token in variant_tokens if token in right_text} + return bool(left_hits and right_hits and left_hits.isdisjoint(right_hits)) + + +def _has_lactacyd_private_wash_variant_conflict(left: ProductIdentity, right: ProductIdentity) -> bool: + left_text = left.searchable_name + right_text = right.searchable_name + if not ( + ("lactacyd" in left_text or "立朵舒" in left_text) + and ("lactacyd" in right_text or "立朵舒" in right_text) + ): + return False + variant_tokens = ( + "清新舒涼", + "生理呵護", + "滋潤緊緻", + "加倍修護", + "柔軟滋潤", + "亮肌柔滑", + "全日清爽", + ) + left_hits = {token for token in variant_tokens if token in left_text} + right_hits = {token for token in variant_tokens if token in right_text} + return bool(left_hits and right_hits and left_hits.isdisjoint(right_hits)) + + +def _has_makeup_usage_conflict(left: ProductIdentity, right: ProductIdentity) -> bool: + left_text = left.searchable_name + right_text = right.searchable_name + cheek_terms = ("頰彩", "腮紅", "blush") + eye_terms = ("眼彩", "眼影", "eyeshadow") + left_cheek = any(term in left_text for term in cheek_terms) + right_cheek = any(term in right_text for term in cheek_terms) + left_eye = any(term in left_text for term in eye_terms) + right_eye = any(term in right_text for term in eye_terms) + return bool((left_cheek and right_eye) or (left_eye and right_cheek)) + + +def _has_lancome_ultra_line_conflict(left: ProductIdentity, right: ProductIdentity) -> bool: + left_text = left.searchable_name + right_text = right.searchable_name + if not ( + ("lancome" in left_text or "蘭蔻" in left_text) + and ("lancome" in right_text or "蘭蔻" in right_text) + ): + return False + glow_terms = ("超極光", "極光水", "晶露", "活粹晶露", "四重酸") + genifique_terms = ("超極限", "肌因", "小黑瓶", "賦活露", "肌因精華") + left_glow = any(term in left_text for term in glow_terms) + right_glow = any(term in right_text for term in glow_terms) + left_genifique = any(term in left_text for term in genifique_terms) + right_genifique = any(term in right_text for term in genifique_terms) + return bool((left_glow and right_genifique) or (left_genifique and right_glow)) + + +def _has_cotton_swab_variant_conflict(left: ProductIdentity, right: ProductIdentity) -> bool: + left_text = left.searchable_name + right_text = right.searchable_name + if "棉棒" not in left_text or "棉棒" not in right_text: + return False + variant_tokens = ("細軸", "黑色") + left_hits = {token for token in variant_tokens if token in left_text} + right_hits = {token for token in variant_tokens if token in right_text} + return bool(left_hits and right_hits and left_hits.isdisjoint(right_hits)) + + def _has_taicend_baby_spray_equivalence(left: ProductIdentity, right: ProductIdentity) -> bool: brand_tokens = {"taicend", "泰陞"} return ( diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index ce4e5d5..4547ddd 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -1356,6 +1356,33 @@ def test_marketplace_matcher_keeps_high_variant_low_score_lines_outside_focused_ assert sunscreen_line_gap.hard_veto is True assert "variant_descriptor_conflict" in sunscreen_line_gap.reasons + assert lactacyd.hard_veto is True + assert "lactacyd_variant_conflict" in lactacyd.reasons + assert lunasol.hard_veto is True + assert "makeup_usage_conflict" in lunasol.reasons + assert muji_swab.hard_veto is True + assert "cotton_swab_variant_conflict" in muji_swab.reasons + assert lancome_line_gap.hard_veto is True + assert "lancome_line_conflict" in lancome_line_gap.reasons + + +def test_marketplace_matcher_rejects_saugella_private_wash_variant_gap(): + from services.marketplace_product_matcher import score_marketplace_match + + for momo_name in ( + "【SAUGELLA 賽吉兒】菁萃潔浴凝露日用250ml二入組", + "【SAUGELLA 賽吉兒】菁萃潔浴凝露加強250ml二入組", + ): + diagnostics = score_marketplace_match( + momo_name, + "SAUGELLA賽吉兒 pH3.5菁萃婦潔凝露【黃金女郎型】250ml(2入特惠)", + momo_price=1080, + competitor_price=990, + ) + assert diagnostics.score < 0.76 + assert diagnostics.hard_veto is True + assert diagnostics.comparison_mode == "not_comparable" + assert "saugella_variant_conflict" in diagnostics.reasons def test_marketplace_matcher_rejects_refill_core_vs_case_only_pack():