diff --git a/config.py b/config.py index 0faaf27..82835fc 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.387" +SYSTEM_VERSION = "V10.388" 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 6002436..6e113c3 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-21:瀏覽器測試守門與 PChome 熱路徑優化 +- **V10.388 精華乳 / 精華霜變體防錯配**: marketplace matcher 新增精華類 formulation conflict guard,當共享 identity anchor 只到「精華」但一側是「精華乳」、另一側是「精華霜 / 精華液」時會標記 `variant_descriptor_conflict` 並壓低同款分數,避免自白肌等同品牌相近品線被錯推成 PChome/MOMO 可直接價格告警。Competitor feeder 同步會用最新 matcher 重新驗舊配對;若舊 `identity_v2` 已被現行 matcher 判成低分或 veto,允許新的高信心候選替換,避免歷史錯配卡住正式 `competitor_prices`。 - **V10.387 EA 比價 HITL 告警證據排版**: Elephant Alpha 的 DB evidence 與 Hermes pre-fetch action 現在會把 PChome/MOMO 同款證據帶進 Telegram:`match_type`、`price_basis`、`alert_tier` 與 `match_score` 會獨立成「證據」行,讓人工審核能分辨高信心同款、總價可比、單位價覆核與身份覆核,不再只看到乾巴巴的 `MOMO vs PChome` 長句。同版 marketplace matcher 補 Relove「私密潔淨凝露」identity anchor 與聯名款搜尋噪音,避免 PLAY BOY / 小虎等活動詞壓過真同款名稱。 - **V10.386 Gemini compose hard default / KATE 唇膏比對**: `docker-compose.yml` 針對 `momo-app`、`scheduler`、`telegram-bot` 明確釘住 `GEMINI_API_HARD_DISABLED=true` 與 `GEMINI_FALLBACK_ENABLED=false` 的預設,讓 `.env` 保留 API key 時也不會自動產生 Gemini 付費出站;AI SOT 與 compose 測試同步鎖定此契約。同版 marketplace matcher 補 KATE/凱婷「柔霧裸唇膏」identity anchor,避免 MOMO 長標含東京夜喫茶系列與任選文案時漏掉 PChome 同款短標。 - **V10.385 Lactacyd / MAQuillAGE 櫃別同款比對**: Marketplace matcher 補強 Lactacyd 私密潔浴露多款任選長標與 PChome 短標同款放行,並橋接「資生堂東京櫃」與 `MAQuillAGE 心機彩妝` 在「心機星魅蜜光圈潤唇膏」上的櫃別/品牌 alias,避免真同款被 `brand_conflict` 擋掉。 diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index c31e611..dc3ca19 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -1238,6 +1238,7 @@ class CompetitorPriceFeeder: sku: str, product, match_score: float, + momo_name: str | None = None, source: str = "pchome", ) -> tuple[bool, str]: """ @@ -1279,6 +1280,26 @@ class CompetitorPriceFeeder: return True, "same_or_empty_existing" if existing_score < MIN_MATCH_SCORE: return True, f"replace_low_existing_score={existing_score:.3f}" + if momo_name and existing_name: + try: + from services.marketplace_product_matcher import score_marketplace_match + + existing_current = score_marketplace_match(momo_name, existing_name) + except Exception: + existing_current = None + if ( + existing_current + and ( + existing_current.hard_veto + or existing_current.score < MIN_MATCH_SCORE + ) + and match_score >= MIN_MATCH_SCORE + ): + return ( + True, + "replace_stale_existing_identity=" + f"{existing_score:.3f}->{existing_current.score:.3f}->{match_score:.3f}", + ) if existing_name and incoming_name: try: from services.marketplace_product_matcher import score_marketplace_match @@ -1568,6 +1589,7 @@ class CompetitorPriceFeeder: sku, best_product, score, + momo_name=momo_name, source=source, ) if manual_accept_override and not should_write: @@ -1763,6 +1785,7 @@ class CompetitorPriceFeeder: sku, best_product, score, + momo_name=momo_name, source=source, ) attempt_terms = search_terms + [term for term in recovery_terms if term not in search_terms] @@ -1890,6 +1913,7 @@ class CompetitorPriceFeeder: sku, best_product, score, + momo_name=momo_name, source=source, ) candidate_count = max(1, recovery_candidate_count or 1) @@ -1967,6 +1991,7 @@ class CompetitorPriceFeeder: sku, best_product, score, + momo_name=momo_name, source=source, ) if not should_write: diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index 1e8d396..79cf3ec 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -2205,6 +2205,15 @@ def _shared_variant_descriptors(left: ProductIdentity, right: ProductIdentity) - return shared +def _has_serum_formulation_conflict(left: ProductIdentity, right: ProductIdentity, shared_anchor: str) -> bool: + if "精華" not in shared_anchor: + return False + formulations = ("精華乳", "精華霜", "精華液") + left_hit = next((token for token in formulations if token in left.normalized_name), None) + right_hit = next((token for token in formulations if token in right.normalized_name), None) + return bool(left_hit and right_hit and left_hit != right_hit) + + def _is_variant_sensitive_identity( left: ProductIdentity, right: ProductIdentity, @@ -2221,6 +2230,8 @@ def _is_variant_sensitive_identity( def _has_variant_descriptor_conflict(left: ProductIdentity, right: ProductIdentity, shared_anchor: str) -> bool: + if _has_serum_formulation_conflict(left, right, shared_anchor): + return True if ( shared_anchor and shared_anchor not in SEARCH_BROAD_ANCHORS diff --git a/tests/test_competitor_match_attempts_persistence.py b/tests/test_competitor_match_attempts_persistence.py index d391342..7e7451f 100644 --- a/tests/test_competitor_match_attempts_persistence.py +++ b/tests/test_competitor_match_attempts_persistence.py @@ -873,6 +873,54 @@ def test_should_upsert_allows_same_identity_candidate_to_replace_lower_score(): assert reason.startswith("replace_same_identity_better_score=0.788->0.811") +def test_competitor_feeder_replaces_stale_existing_identity_when_current_matcher_rejects_it(): + from sqlalchemy import create_engine, text + + from services.competitor_price_feeder import CompetitorPriceFeeder + + engine = create_engine("sqlite:///:memory:") + with engine.begin() as conn: + conn.execute(text(""" + CREATE TABLE competitor_prices ( + sku TEXT, + source TEXT, + competitor_product_id TEXT, + competitor_product_name TEXT, + match_score REAL, + tags TEXT + ) + """)) + conn.execute(text(""" + INSERT INTO competitor_prices ( + sku, source, competitor_product_id, competitor_product_name, match_score, tags + ) VALUES ( + '6911139', + 'pchome', + 'OLD-WRONG', + '自白肌 極潤玻尿酸精華霜50g', + 0.802, + '[\"identity_v2\",\"comparison_exact_identity\",\"brand_match\"]' + ) + """)) + + feeder = CompetitorPriceFeeder(engine=engine) + product = SimpleNamespace( + product_id="NEW-RIGHT", + name="自白肌極潤玻尿酸精華乳200ml", + ) + + should_write, reason = feeder._should_upsert_competitor_price( + "6911139", + product, + 0.778, + momo_name="【自白肌】官方直營 極潤玻尿酸精華乳200ml", + source="pchome", + ) + + assert should_write is True + assert reason.startswith("replace_stale_existing_identity=0.802->") + + def test_competitor_feeder_marks_existing_stronger_match_as_protected(monkeypatch): from services.competitor_price_feeder import CompetitorPriceFeeder from services.pchome_crawler import PChomeProduct diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index 2f93c80..b4eb794 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -612,6 +612,21 @@ def test_marketplace_matcher_promotes_relove_private_cleanser_campaign_title(): assert "boy" not in terms[0].lower() +def test_marketplace_matcher_rejects_serum_milk_vs_serum_cream(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【自白肌】官方直營 極潤玻尿酸精華乳200ml", + "自白肌 極潤玻尿酸精華霜50g", + momo_price=399, + competitor_price=399, + ) + + assert diagnostics.score < 0.76 + assert diagnostics.hard_veto is False + assert "variant_descriptor_conflict" in diagnostics.reasons + + def test_marketplace_matcher_bridges_maquillage_shiseido_counter_alias(): from services.marketplace_product_matcher import score_marketplace_match