From e6b48cefa818d7c0b69ba57d361cdde7484cc423 Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 25 May 2026 21:35:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=B7=E5=8C=96=20PChome=20accepted=20queue?= =?UTF-8?q?=20=E8=AE=8A=E9=AB=94=E9=98=B2=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 2 + config.py | 2 +- .../current_execution_queue_20260524.md | 4 + .../competitor_match_attempt_rescore_audit.py | 22 +++-- services/marketplace_product_matcher.py | 89 +++++++++++++++++++ ..._competitor_match_attempt_rescore_audit.py | 16 +++- tests/test_marketplace_product_matcher.py | 47 ++++++++++ 7 files changed, 170 insertions(+), 12 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index ffafedd..ccf3721 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,8 @@ ================================================================================ 【已完成】 + - V10.481 補 rescore accepted retraction 工具缺口:`--retract-variant-accepted` 不只看舊 row 已存的 `diagnostic_codes`,也會用當前 matcher 重判 latest `rescore_accepted_current`;若新版規則已變成 `variant_selection_review / low_score_current`,會追加退回 `true_low_confidence`,避免舊 accepted queue 殘留不該採用候選。Production 已先保守 materialize 15 筆安全 SKU,再退回 7 筆舊 accepted 變體風險;最終 `rescore_accepted_current=89`,accepted audit `gate_pass=89 / still_low=0`。 + - V10.480 依 production accepted-current 風險樣本補安全閘門:rom&nd 零絲絨/果凍唇釉不可被果汁唇釉多款 listing 誤收為同款;Relove 潔淨凝露若一側為傳明酸/淨白活性變體改送 `variant_selection_review`;1990 融燭燈不同設計(歐式可彎 vs 韓風原木底座)改 hard veto。此版先清 accepted queue 風險,再做保守 materialize。 - V10.479 依 production audit 再補二階風險:Cetaphil 修護乳 vs 潔膚露改 hard veto;私密防護慕絲二款可選 vs 單一香型、雪芙蘭滋養霜 vs 單側清爽型改走 `variant_selection_review`,避免仍殘留在 accepted queue。 - V10.478 補 PChome 高分錯配 / catalog 變體防線:精油/香氛類若兩側明確香味不同(如檸檬草 vs 茶樹)直接 veto;NOW 椰子油膏 vs 乳木果油、港香蘭漢本 vs 艾魔菈爽身粉改為商品線硬擋;多色/多香/數字區間 catalog 對單一款式(粉餅盒、眉筆、眼線膠筆、車用擴香蕊等)只進 `variant_selection_review`,不自動進 accepted queue。 - V10.477 補 PChome 高分錯配防線:SPF 數值不同(如 SPF25 vs SPF50)直接 veto;MAKE UP FOR EVER 定妝噴霧 vs 活氧水不同線直接 veto;多款任選對單一款(私密潔浴露、身體去角質、乳液、染眉膏等)與單側色號改送 `variant_selection_review`,避免高分候選誤入 accepted queue。 diff --git a/config.py b/config.py index e558d93..eba7b94 100644 --- a/config.py +++ b/config.py @@ -350,7 +350,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.479" +SYSTEM_VERSION = "V10.481" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index 4b56691..2ef64e0 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -31,6 +31,8 @@ - 2026-05-25 15:20 CST 起,`V10.475` 補 rescore CLI 與高分錯配防線:audit CLI 預設不再只掃 `strong_exact_spec_match`,避免新版 `focused_exact_*` 理由漏掃;matcher 對香氛暖燈 S/M/L 尺寸差、NITORI 香氛噴霧器型號差直接 hard veto,彩妝色號單邊出現時送 `variant_selection_review`,避免高分但不同 variant 的候選被誤推入 accepted queue。 - 2026-05-25 16:15 CST 起,`V10.476` 補商業條件差防線:即期品、效期/保存期限、盒損、福利品等條件只出現在單側時,matcher 加 `commercial_condition_gap` 並送 `variant_selection_review`,不讓同名但商品狀態不同的候選自動進 accepted queue。 - 2026-05-25 19:20 CST 起,`V10.477` 補高分錯配防線:SPF 數值不同直接 veto,MAKE UP FOR EVER 定妝噴霧 vs 活氧水不同線直接 veto;多款任選對單一款與單側色號改送 `variant_selection_review`,涵蓋私密潔浴露、身體去角質、美體乳液與染眉膏等。 +- 2026-05-25 21:05 CST 起,`V10.480` 補 accepted-current 風險樣本防線:rom&nd 零絲絨/果凍唇釉 vs 果汁唇釉多款 listing 直接 `romand_lip_line_conflict` hard veto;Relove 潔淨凝露若傳明酸/淨白活性只出現在單側,保留高分但進 `variant_selection_review`;1990 融燭燈不同設計(歐式可彎 vs 韓風原木底座)直接 `selection1990_wax_lamp_design_conflict` hard veto。 +- 2026-05-25 21:25 CST 起,`V10.481` 補 rescore accepted retraction 工具缺口:退回工具會用當前 matcher 重判 latest `rescore_accepted_current`,凡新版已變 `variant_selection_review / low_score_current` 的舊 accepted 會追加退回 `true_low_confidence`,避免人工覆核隊列保留舊版安全閘門放行的候選。Production 已部署 `/health=V10.481`;保守 materialize 15 筆安全 SKU 後再退回 7 筆舊 accepted 變體風險,最新 accepted audit 為 `scanned=89 / gate_pass=89 / still_low=0`。 - 2026-05-25 12:05 CST 狀態:`main` 已部署到 188,正式 `/health` 為 `V10.467`,待推 Gitea。兩段變更已合併驗證:V10.466 rescore duplicate 改看 latest-state,7 筆 SKU 最新 attempt 全為 `rescore_accepted_current`,`competitor_prices` / `competitor_price_history` 目標計數未變;V10.467 focused exact matcher 在容器內回 `exact / total_price / price_alert_exact`。本輪 recreate `momo-app`、`scheduler`、`telegram-bot`;未使用 `--remove-orphans`,未碰 `momo-db`。Smoke 通過:三容器 healthy、PChome rescore queue API HTTP 200、Gemini 24 小時無 provider 紀錄、Ollama env 順序維持 GCP-A → GCP-B → 111、3 分鐘三容器 log 未見 Traceback / ERROR / CRITICAL / IntegrityError。 ## 1. MOMO / PChome 核心比價準確率 @@ -74,6 +76,8 @@ - 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`。 +- 2026-05-25 21:05 CST 起,依 accepted-current 二次抽樣續補三類風險:rom&nd 唇釉不同品線不可互收;Relove 潔淨凝露傳明酸/淨白變體不可自動 accepted;1990 融燭燈同色但不同燈座/結構不可互收。下一步先部署 V10.480,再回查 accepted-current 是否仍有上述 4 筆風險 SKU。 +- 2026-05-25 21:25 CST 起,accepted queue 清潔不再只靠舊 `diagnostic_codes`:`--retract-variant-accepted` 改為先抓 latest accepted,再用當前 matcher 判斷是否需要退回。這能清掉 V10.480 後才被新規則判為 `variant_selection_review` 的舊 accepted。正式最新狀態:`true_low_confidence=751`、`rescore_accepted_current=89`、`identity_veto=3994`、`matched=1570`、`unit_comparable=379`。 ## 3. 12 Agent 決策信封整合 diff --git a/services/competitor_match_attempt_rescore_audit.py b/services/competitor_match_attempt_rescore_audit.py index 599af21..762a4f3 100644 --- a/services/competitor_match_attempt_rescore_audit.py +++ b/services/competitor_match_attempt_rescore_audit.py @@ -350,11 +350,6 @@ def fetch_variant_rescore_accept_review_rows( limit: int = 100, ) -> list[dict[str, Any]]: """Fetch latest rescore-accepted rows that should be retracted to review.""" - reason_predicate = ( - "diagnostic_codes::text LIKE :reason_filter" - if conn.dialect.name == "postgresql" - else "CAST(diagnostic_codes AS TEXT) LIKE :reason_filter" - ) nulls_last = " NULLS LAST" if conn.dialect.name == "postgresql" else "" sql = text(f""" WITH ranked AS ( @@ -400,19 +395,30 @@ def fetch_variant_rescore_accept_review_rows( FROM ranked WHERE rn = 1 AND attempt_status = :attempt_status - AND {reason_predicate} ORDER BY attempted_at DESC{nulls_last} LIMIT :limit """) - return [ + rows = [ dict(row) for row in conn.execute(sql, { "source": source, "attempt_status": RESCORE_ACCEPTED_CURRENT_STATUS, - "reason_filter": "%variant_selection_review%", "limit": max(1, int(limit)), }).mappings().all() ] + retractable_rows: list[dict[str, Any]] = [] + for row in rows: + diagnostic_text = json.dumps(row.get("diagnostic_codes"), ensure_ascii=False, default=str) + if "variant_selection_review" in diagnostic_text: + retractable_rows.append(row) + continue + decision = classify_match_attempt_row(row) + if ( + "variant_selection_review" in set(decision.reasons) + and decision.suggested_status == "low_score_current" + ): + retractable_rows.append(row) + return retractable_rows def retract_variant_rescore_accept_reviews( diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index 89e6d63..875971e 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -1978,6 +1978,9 @@ def score_marketplace_match( makeup_spray_line_conflict = _has_makeup_spray_line_conflict(left, right) if makeup_spray_line_conflict: reasons.append("makeup_spray_line_conflict") + romand_lip_line_conflict = _has_romand_lip_line_conflict(left, right) + if romand_lip_line_conflict: + reasons.append("romand_lip_line_conflict") nail_tool_function_conflict = _has_nail_tool_function_conflict(left, right) if nail_tool_function_conflict: reasons.append("nail_tool_function_conflict") @@ -2011,6 +2014,9 @@ def score_marketplace_match( cleanser_lotion_line_conflict = _has_cleanser_lotion_line_conflict(left, right) if cleanser_lotion_line_conflict: reasons.append("cleanser_lotion_line_conflict") + selection1990_wax_lamp_design_conflict = _has_selection1990_wax_lamp_design_conflict(left, right) + if selection1990_wax_lamp_design_conflict: + reasons.append("selection1990_wax_lamp_design_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") @@ -2020,9 +2026,13 @@ def score_marketplace_match( commercial_condition_gap = _has_commercial_condition_gap(left, right) if commercial_condition_gap: reasons.append("commercial_condition_gap") + relove_private_cleanser_variant_gap = _has_relove_private_cleanser_variant_gap(left, right) + if relove_private_cleanser_variant_gap: + reasons.append("relove_private_cleanser_variant_gap") variant_selection_review = ( _has_named_variant_selection_review(left, right, shared_anchor) or commercial_condition_gap + or relove_private_cleanser_variant_gap ) if variant_selection_review: reasons.append("variant_selection_review") @@ -2062,6 +2072,8 @@ def score_marketplace_match( hard_veto = True if makeup_spray_line_conflict: hard_veto = True + if romand_lip_line_conflict: + hard_veto = True if nail_tool_function_conflict: hard_veto = True if schick_razor_line_conflict: @@ -2084,6 +2096,8 @@ def score_marketplace_match( hard_veto = True if cleanser_lotion_line_conflict: hard_veto = True + if selection1990_wax_lamp_design_conflict: + hard_veto = True if wax_lamp_size_letter_conflict: hard_veto = True if nitori_diffuser_model_conflict: @@ -2973,6 +2987,35 @@ def _has_makeup_spray_variant_selection_gap(left: ProductIdentity, right: Produc return left_groups != right_groups +def _romand_lip_line_groups(identity: ProductIdentity) -> set[str]: + text = identity.searchable_name + groups: set[str] = set() + if "果汁唇釉" in text or "juicy" in text: + groups.add("juicy") + if "零絲絨" in text or "zero velvet" in text or "霧面唇釉" in text: + groups.add("zero_velvet") + if "果凍唇釉" in text or "glasting" in text or "唇凍" in text: + groups.add("glasting") + if "水感唇釉" in text: + groups.add("water_gloss") + return groups + + +def _has_romand_lip_line_conflict(left: ProductIdentity, right: ProductIdentity) -> bool: + pair_text = f"{left.searchable_name} {right.searchable_name}" + if not ( + {"rom", "romand"} & (left.brand_tokens | right.brand_tokens) + or "rom&nd" in pair_text + or "romand" in pair_text + ): + return False + if "唇" not in left.searchable_name or "唇" not in right.searchable_name: + return False + left_groups = _romand_lip_line_groups(left) + right_groups = _romand_lip_line_groups(right) + return bool(left_groups and right_groups and left_groups.isdisjoint(right_groups)) + + def _has_nail_tool_function_conflict(left: ProductIdentity, right: ProductIdentity) -> bool: left_text = left.searchable_name right_text = right.searchable_name @@ -3202,6 +3245,31 @@ def _has_cleanser_lotion_line_conflict(left: ProductIdentity, right: ProductIden return bool((left_cleanser and right_lotion) or (right_cleanser and left_lotion)) +def _selection1990_wax_lamp_design_groups(identity: ProductIdentity) -> set[str]: + text = identity.searchable_name + groups: set[str] = set() + if "現代簡約半圓罩融燭燈" in text or "半圓罩" in text: + groups.add("half_dome") + if "歐式可彎融燭燈" in text or "可彎融燭燈" in text: + groups.add("bendable") + if "韓風原木底座融燭燈" in text or "原木底座融燭燈" in text: + groups.add("wood_base") + if "北歐簡樸融蠟燈" in text or "北歐簡樸" in text: + groups.add("nordic") + return groups + + +def _has_selection1990_wax_lamp_design_conflict(left: ProductIdentity, right: ProductIdentity) -> bool: + if not ({"1990", "選物"} <= (left.brand_tokens & right.brand_tokens)): + return False + pair_text = f"{left.searchable_name} {right.searchable_name}" + if not any(term in pair_text for term in ("融燭燈", "蠟燭暖燈", "融蠟燈")): + return False + left_groups = _selection1990_wax_lamp_design_groups(left) + right_groups = _selection1990_wax_lamp_design_groups(right) + return bool(left_groups and right_groups and left_groups.isdisjoint(right_groups)) + + def _standalone_size_letter_tokens(identity: ProductIdentity) -> set[str]: text = identity.searchable_name return { @@ -3282,6 +3350,15 @@ def _has_commercial_condition_gap(left: ProductIdentity, right: ProductIdentity) return bool(left_terms or right_terms) and left_terms != right_terms +def _has_relove_private_cleanser_variant_gap(left: ProductIdentity, right: ProductIdentity) -> bool: + if not _is_relove_cleanser_gel_like(left, right): + return False + brightening_terms = ("傳明酸", "淨白", "美白", "亮白", "菸鹼醯胺", "niacinamide") + left_brightening = any(term in left.searchable_name for term in brightening_terms) + right_brightening = any(term in right.searchable_name for term in brightening_terms) + return left_brightening != right_brightening + + def _has_catalog_specific_variant_selection_gap(left: ProductIdentity, right: ProductIdentity) -> bool: pair_text = f"{left.searchable_name} {right.searchable_name}" if not any( @@ -3888,6 +3965,18 @@ def _is_relove_private_cleanser_line(left: ProductIdentity, right: ProductIdenti ) +def _is_relove_cleanser_gel_like(left: ProductIdentity, right: ProductIdentity) -> bool: + if "relove" not in (left.brand_tokens | right.brand_tokens): + return False + cleanser_terms = ("私密", "潔淨", "清潔") + return ( + "凝露" in left.searchable_name + and "凝露" in right.searchable_name + and any(term in left.searchable_name for term in cleanser_terms) + and any(term in right.searchable_name for term in cleanser_terms) + ) + + def _is_multi_variant_catalog_listing(identity: ProductIdentity) -> bool: text = identity.normalized_name return any(phrase in text for phrase in MULTI_VARIANT_LISTING_PHRASES) diff --git a/tests/test_competitor_match_attempt_rescore_audit.py b/tests/test_competitor_match_attempt_rescore_audit.py index c0b09f2..20596cc 100644 --- a/tests/test_competitor_match_attempt_rescore_audit.py +++ b/tests/test_competitor_match_attempt_rescore_audit.py @@ -320,6 +320,15 @@ def test_match_attempt_rescore_retracts_variant_review_from_accepted_queue(): 89, 0.872, '["focused_exact_identity_herbacin_classic_hand_cream_20ml_brandless"]', 'matcher_rescore=accepted_current', '2026-05-24 14:24:17' + ), + ( + '10524966', 'pchome', 'rescore_accepted_current', 10524966, + '【Derma 丹麥德瑪】寶寶有機水嫩洗髮沐浴露 150ml(寶寶沐浴、嬰兒沐浴)', + 399, 1, 'DERMA-150ML', + 'Derma 寶寶洗髮沐浴露 150ml', + 349, 0.82, + '["strong_exact_spec_match", "spec_name_alignment"]', + 'matcher_rescore=accepted_current', '2026-05-24 14:24:17' ) """)) @@ -343,15 +352,16 @@ def test_match_attempt_rescore_retracts_variant_review_from_accepted_queue(): """)).mappings().all() after_rows = fetch_variant_rescore_accept_review_rows(conn, limit=10) - assert [row["sku"] for row in rows] == ["8884618"] - assert stats["retracted"] == 1 + assert [row["sku"] for row in rows] == ["10922465", "8884618"] + assert stats["retracted"] == 2 latest_by_sku = {row["sku"]: row for row in latest} assert latest_by_sku["8884618"]["attempt_status"] == "true_low_confidence" assert "matcher_rescore:retracted_variant_selection_review" in json.loads( latest_by_sku["8884618"]["search_terms"] ) assert "rescore_retracted_variant_selection_review" in latest_by_sku["8884618"]["error_message"] - assert latest_by_sku["10922465"]["attempt_status"] == "rescore_accepted_current" + assert latest_by_sku["10922465"]["attempt_status"] == "true_low_confidence" + assert latest_by_sku["10524966"]["attempt_status"] == "rescore_accepted_current" assert after_rows == [] diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index e63cb89..17143bc 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -2371,6 +2371,53 @@ def test_marketplace_matcher_sends_single_sided_private_mousse_and_cream_variant assert "variant_selection_review" in diagnostics.reasons +def test_marketplace_matcher_blocks_romand_lip_line_conflicts(): + from services.marketplace_product_matcher import score_marketplace_match + + zero_velvet = score_marketplace_match( + "【rom&nd】零絲絨 霧面唇釉 5.5g", + "rom&nd 果汁唇釉5.5g_多款可選", + ) + glasting = score_marketplace_match( + "【rom&nd】果凍唇釉 5.5g", + "rom&nd 果汁唇釉5.5g_多款可選", + ) + + for diagnostics in (zero_velvet, glasting): + assert diagnostics.hard_veto is True + assert diagnostics.comparison_mode == "not_comparable" + assert "romand_lip_line_conflict" in diagnostics.reasons + + +def test_marketplace_matcher_sends_relove_brightening_cleanser_variant_to_review(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【Relove】胺基酸私密潔淨精華凝露120ml", + "RELOVE傳明酸美白潔淨精華凝露120ml", + ) + + assert diagnostics.hard_veto is False + assert diagnostics.score >= 0.76 + assert diagnostics.price_basis == "manual_review" + assert diagnostics.alert_tier == "identity_review" + assert "relove_private_cleanser_variant_gap" in diagnostics.reasons + assert "variant_selection_review" in diagnostics.reasons + + +def test_marketplace_matcher_blocks_selection1990_wax_lamp_design_conflict(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【1990選物】歐式可彎融燭燈 香氛蠟燭暖燈-黑色款", + "【1990選物】韓風原木底座融燭燈 香氛蠟燭暖燈-黑色款", + ) + + assert diagnostics.hard_veto is True + assert diagnostics.comparison_mode == "not_comparable" + assert "selection1990_wax_lamp_design_conflict" in diagnostics.reasons + + def test_marketplace_matcher_promotes_eaoron_classic_tone_up_cream_exact_line(): from services.marketplace_product_matcher import score_marketplace_match