From a1df2bc8d5c3f5ddbefeef66dee2a2583e0f319c Mon Sep 17 00:00:00 2001 From: OoO Date: Sun, 24 May 2026 16:51:19 +0800 Subject: [PATCH] V10.433 improve competitor review diagnostics --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 3 +- docs/memory/history_logs.md | 1 + services/competitor_intel_repository.py | 46 +++++++++++++------ services/competitor_price_feeder.py | 7 +++ services/marketplace_product_matcher.py | 20 ++++++++ tests/test_competitor_intel_cache.py | 37 +++++++++++++++ ...t_competitor_match_attempts_persistence.py | 33 +++++++++++++ tests/test_frontend_v2_assets.py | 3 ++ tests/test_marketplace_product_matcher.py | 30 ++++++++++++ 10 files changed, 167 insertions(+), 15 deletions(-) diff --git a/config.py b/config.py index c8c9300..320e0a3 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.432" +SYSTEM_VERSION = "V10.433" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 34a4cfd..3ec7731 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-24 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 -> **適用版本**: V10.432 +> **適用版本**: V10.433 --- @@ -375,6 +375,7 @@ LEFT JOIN competitor_prices cp - 套組/買送/件數不同但品牌、核心商品線與單一基礎規格一致時,matcher 必須回傳 `comparison_mode='unit_comparable'` 與 `unit_comparable` reason;Feeder 只能寫入 `competitor_match_attempts.attempt_status='unit_comparable'` 或 `refresh_unit_comparable`,不得寫入 `competitor_prices`。Dashboard 與 `competitor_intel_repository` 必須用 `build_unit_price_comparison()` 產生每 ml / 每 g / 每入單位價證據,讓 PPT / AI 報表可說明「需單位價比較」而不是把總價當同款價差。商品看板在正式配對尚未成立時,仍必須顯示最佳候選 PChome 商品名稱、候選價與「候選價,需單位換算」說明,讓人工覆核可直接看見下一步;daily/growth、PPT 與 OpenClaw 摘要不得自建查詢,需消費 `fetch_competitor_review_queue()` 與 coverage 的 `unit_comparable_count`。若任一側含多個不同容量/重量規格,視為多品項套組,不可進 `unit_comparable`。 - PChome feeder 的外部 request timeout 由 `PCHOME_FEEDER_TIMEOUT` 控制,預設 12 秒;排程不得因單一 PChome 搜尋 API timeout 被拖到數分鐘。 - 商品看板的 PChome 狀態必須把 matcher 診斷原因翻成可行動語意:品牌不符已排除、規格不符已排除、補充包不相容、組合規格不相容、系列不符已排除、需單位價比較、低信心待補強等,不可只顯示籠統「待比對」或「身份否決」。 +- 商品看板、PChome review queue 與 `/api/export/excel/pchome-review` 必須優先讀取 `match_diagnostic_json.reasons` 並轉成操作員可讀標籤;文字版 `error_message` 只作 legacy fallback。新增 matcher reason 時需同步更新 `MATCH_DIAGNOSTIC_REASON_LABELS`,避免 UI 顯示 `makeup_finish_conflict` 這類 machine code。PChome 標題缺品牌但有窄範圍 exact identity anchor 的商品,只能透過具名 brandless recovery 進 manual-review identity;多色任選 / 單一色號 gap 必須標記 `variant_selection_review`,並從 `recoverable_low_score` 降回 `true_low_confidence`,不得自動批次寫正式價差。 - Dashboard 必須把「待比對」拆成可診斷狀態:`價格過期待刷新`、`舊版配對待重驗`、`低分配對待補強`、`已排除`、`需單位價比較`、`找不到同款`、`抓取異常`、`尚未搜尋`。硬性不相容候選應顯示為已排除/不相容,不得讓使用者誤以為每筆都需要人工待審。 ### 執行方式 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 7933282..00be519 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.433 PChome 覆核診斷標籤與 variant 回刷補強**: `competitor_intel_repository` 的 review queue / 商品看板 / Excel export 改為優先讀取 `match_diagnostic_json.reasons`,再 fallback 文字版 `error_message`;同步補 `makeup_finish_conflict`、`nail_tool_function_conflict`、`schick_razor_line_conflict`、`variant_descriptor_conflict` 等操作員可讀標籤,讓商品列表顯示「妝效質地不同、工具功能不同、除毛刀品線不同」而不是 raw machine code。matcher 另補 MUJI 精油芬香護手霜的 brandless exact recovery,PChome 標題缺品牌但身份詞與 50g 規格一致時可進 manual-review identity;peripera 多色任選 vs 單一色號會標記 `variant_selection_review` 並留在 `true_low_confidence`,避免被誤列為可批次救回。 - **V10.432 近門檻比價 hard-veto 補強**: marketplace matcher 不放寬 `MIN_MATCH_SCORE`,針對正式 `true_low_confidence` 前段新增窄範圍防錯配:M.A.C `MACximal` 柔霧唇膏 vs 緞光唇膏標記 `makeup_finish_conflict`、ERBE 指甲清垢棒 vs 指甲緣刨刀標記 `nail_tool_function_conflict`、Schick 舒芙 vs 舒綺仕女除毛刀標記 `schick_razor_line_conflict`,三者皆進 hard veto;同時把 `潤膚乳` / `身體乳` / `嬰兒乳液` / `寶寶乳液` 納入乳液型別,讓慕之幼爽身潤膚乳等真同款回刷更穩定。新增測試鎖住 MUJI 護手霜、Mustela 慕之幼潤膚乳、Herbacin 小甘菊護手霜可 exact,並確保高 variant 錯配不被 focused rule 推進。 - **V10.431 Telegram callback byte-safe**: `triaged_alert()` 的 `momo:eig:*` HITL callback 改為依 UTF-8 byte 長度截斷,不再用字元數截斷;中文或過長 `event.id` / `decision_envelope.decision_id` 仍會保留可追蹤 payload,且保證 `callback_data` 不超過 Telegram 64-byte 限制,避免專業排版告警因 callback 太長而整則送出失敗。 - **V10.430 NemoTron 決策 callback 追蹤 ID 修補**: `NemoTronDispatcher._send_telegram()` 會把 `decision_envelope.decision_id` 提升為 EventRouter `event.id`;`triaged_alert()` 也會在上游缺 `event.id` 時改用 `decision_id` 產生 `momo:eig:*` callback,避免價格決策通知的「忽略此事件」audit 落成 `unknown` 而無法追查。 diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index a7d126c..01f244d 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -102,6 +102,15 @@ MATCH_DIAGNOSTIC_REASON_LABELS = { "price_ratio_extreme": "價差極端", "price_ratio_wide": "價差過大", "catalog_count_omission": "目錄入數待確認", + "makeup_usage_conflict": "彩妝用途不同", + "makeup_finish_conflict": "妝效質地不同", + "nail_tool_function_conflict": "工具功能不同", + "schick_razor_line_conflict": "除毛刀品線不同", + "variant_descriptor_conflict": "款式描述不同", + "variant_selection_review": "多款任選待確認", + "strong_exact_spec_match": "強規格同款", + "strong_product_line_match": "商品線強吻合", + "shared_model_token": "型號一致", } MATCH_TYPE_LABELS = { "exact": "高信心同款", @@ -203,25 +212,36 @@ def _empty_manual_review_summary() -> dict[str, Any]: } -def _extract_match_diagnostic_reasons(diagnostic_text: Any) -> list[dict[str, str]]: +def _extract_match_diagnostic_reasons( + diagnostic_text: Any, + diagnostic_payload: Optional[dict[str, Any]] = None, +) -> list[dict[str, str]]: """Translate matcher diagnostics into short operator-facing reason chips.""" text_value = str(diagnostic_text or "") - if not text_value: - return [] + raw_reasons: list[Any] = [] + if isinstance(diagnostic_payload, dict): + payload_reasons = diagnostic_payload.get("reasons") + if isinstance(payload_reasons, list): + raw_reasons.extend(payload_reasons) + elif isinstance(payload_reasons, str): + raw_reasons.extend(payload_reasons.replace("|", ",").split(",")) - reason_blob = "" - for part in text_value.split(";"): - key, _, value = part.strip().partition("=") - if key.strip() == "reasons": - reason_blob = value.strip() - break - if not reason_blob: + if text_value: + reason_blob = "" + for part in text_value.split(";"): + key, _, value = part.strip().partition("=") + if key.strip() == "reasons": + reason_blob = value.strip() + break + if reason_blob: + raw_reasons.extend(reason_blob.replace("|", ",").split(",")) + if not raw_reasons: return [] reasons: list[dict[str, str]] = [] seen: set[str] = set() - for raw_reason in reason_blob.replace("|", ",").split(","): - code = raw_reason.strip() + for raw_reason in raw_reasons: + code = str(raw_reason or "").strip() if not code or code in seen: continue seen.add(code) @@ -262,7 +282,7 @@ def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]: price_basis = diagnostic_payload.get("price_basis") or _tag_suffix(tags, "price_basis") or "" alert_tier = diagnostic_payload.get("alert_tier") or _tag_suffix(tags, "alert_tier") or "" evidence_flags = diagnostic_payload.get("evidence_flags") or [] - diagnostic_reasons = _extract_match_diagnostic_reasons(match_diagnostic) + diagnostic_reasons = _extract_match_diagnostic_reasons(match_diagnostic, diagnostic_payload) return { "sku": str(item.get("sku") or ""), "name": item.get("name") or "", diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 301de74..c7bc547 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -104,6 +104,8 @@ def _is_multi_variant_listing_name(name: str) -> bool: def _classify_low_score_attempt(score: float, diagnostics) -> str: if getattr(diagnostics, "hard_veto", False): return "identity_veto" + if "variant_selection_review" in set(getattr(diagnostics, "reasons", ()) or ()): + return "true_low_confidence" if score >= RECOVERABLE_LOW_SCORE_FLOOR and _has_recoverable_identity_signal(diagnostics): return "recoverable_low_score" return "true_low_confidence" @@ -1990,6 +1992,11 @@ class CompetitorPriceFeeder: continue attempt_status = _classify_low_score_attempt(score, diagnostics) + if ( + attempt_status == "recoverable_low_score" + and _has_variant_selection_gap(momo_name, [(best_product, score, diagnostics)], score) + ): + attempt_status = "true_low_confidence" self._record_match_attempt( sku, momo_name, diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index 8395173..5f497c2 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -336,6 +336,7 @@ SEARCH_IDENTITY_ANCHORS = ( "經典旋轉眉筆", "3d造型眉彩餅補充芯", "止汗爽身乳液", + "精油芬香護手霜", "持久植物香氛精油", "口袋雙色修容打亮盤", "經典乳霜", @@ -466,6 +467,7 @@ SEARCH_IDENTITY_ANCHORS = ( ) FOCUSED_IDENTITY_REVIEW_ONLY_REASONS = { + "muji_aroma_hand_cream_brandless", "johnsons_baby_lotion_variant_catalog", "im_meme_fixx_cool_setting_spray", "so_natural_fixx_setting_spray_catalog", @@ -479,6 +481,7 @@ FOCUSED_IDENTITY_REVIEW_ONLY_REASONS = { } FOCUSED_IDENTITY_BRANDLESS_REVIEW_REASONS = { + "muji_aroma_hand_cream_brandless", "the_forest_maple_diffuser_flower_brandless", } @@ -3076,6 +3079,14 @@ def _has_focused_low_score_exact_identity_line(left: ProductIdentity, right: Pro and _has_shared_count(left, right, 4, "片") ): return "biodance_deep_mask" + if ( + {"muji", "無印良品"} & brand_tokens + and "精油芬香護手霜" in left_text + and "精油芬香護手霜" in right_text + and _has_shared_weight(left, right, 50) + and bool(left.brand_tokens) != bool(right.brand_tokens) + ): + return "muji_aroma_hand_cream_brandless" if ( {"sab", "初淨肌"} & (left.brand_tokens & right.brand_tokens) and "私密防護舒緩噴霧" in left_text @@ -3504,6 +3515,15 @@ def _has_named_variant_selection_review( ) -> bool: left_options = _explicit_variant_option_tokens(left) right_options = _explicit_variant_option_tokens(right) + if bool(left_options) != bool(right_options): + option_identity = left if left_options else right + catalog_identity = right if left_options else left + if ( + _is_variant_sensitive_identity(left, right, shared_anchor) + and _is_multi_variant_catalog_listing(catalog_identity) + and _explicit_variant_option_tokens(option_identity) + ): + return True if bool(left_options) == bool(right_options): return False diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index bdb245c..6b30060 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -120,6 +120,43 @@ def test_competitor_review_queue_is_canonical_unit_price_handoff(): assert "coverage.manual_unit_price_count" in growth_template +def test_competitor_review_reasons_prefer_json_payload_labels(): + from services.competitor_intel_repository import _format_competitor_review_item + + item = _format_competitor_review_item({ + "sku": "SKU-1", + "name": "M.A.C Macximal 柔霧唇膏", + "momo_price": 990, + "attempt_status": "identity_veto", + "candidate_count": 1, + "best_competitor_product_id": "DABC123", + "best_competitor_product_name": "MAC Macximal 緞光唇膏", + "best_competitor_price": 880, + "best_match_score": 0.32, + "match_diagnostic_json": { + "match_type": "no_match", + "price_basis": "none", + "alert_tier": "suppress", + "reasons": [ + "makeup_finish_conflict", + "nail_tool_function_conflict", + "schick_razor_line_conflict", + ], + }, + "error_message": "", + }) + + assert item["match_type_label"] == "非同款" + assert item["price_basis_label"] == "不可比" + assert item["alert_tier_label"] == "不告警" + assert item["diagnostic_reason_text"] == "妝效質地不同、工具功能不同、除毛刀品線不同" + assert [reason["code"] for reason in item["diagnostic_reasons"]] == [ + "makeup_finish_conflict", + "nail_tool_function_conflict", + "schick_razor_line_conflict", + ] + + def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint(): source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8") diff --git a/tests/test_competitor_match_attempts_persistence.py b/tests/test_competitor_match_attempts_persistence.py index 7469e98..7c958aa 100644 --- a/tests/test_competitor_match_attempts_persistence.py +++ b/tests/test_competitor_match_attempts_persistence.py @@ -112,6 +112,22 @@ def test_competitor_feeder_persists_all_match_attempt_outcomes(): assert "idx_comp_match_attempts_sku_source_time" in migration +def test_competitor_feeder_keeps_variant_selection_review_out_of_recoverable(): + from services.competitor_price_feeder import _classify_low_score_attempt + + diagnostics = SimpleNamespace( + score=0.86, + brand_score=1.0, + token_score=0.9, + sequence_score=0.8, + hard_veto=False, + comparison_mode="exact_identity", + reasons=("variant_selection_review", "strong_product_line_match"), + ) + + assert _classify_low_score_attempt(0.86, diagnostics) == "true_low_confidence" + + def test_competitor_feeder_records_browse_sh_plan_for_no_result(monkeypatch): from services.competitor_price_feeder import CompetitorPriceFeeder @@ -676,6 +692,23 @@ def test_competitor_feeder_marks_weak_identity_as_true_low_confidence(monkeypatc assert attempts[0]["attempt_status"] == "true_low_confidence" +def test_competitor_feeder_keeps_variant_selection_review_out_of_recoverable_queue(): + from types import SimpleNamespace + + from services.competitor_price_feeder import _classify_low_score_attempt + + diagnostics = SimpleNamespace( + hard_veto=False, + reasons=("variant_selection_review", "shared_identity_anchor_packaging_variant"), + brand_score=1.0, + token_score=0.72, + sequence_score=0.66, + comparison_mode="exact_identity", + ) + + assert _classify_low_score_attempt(0.734, diagnostics) == "true_low_confidence" + + def test_competitor_feeder_does_not_treat_spec_only_match_as_recoverable(monkeypatch): from services.competitor_price_feeder import CompetitorPriceFeeder from services.pchome_crawler import PChomeProduct diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 3a61465..d82aaae 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -214,6 +214,9 @@ def test_pchome_review_export_and_diagnostics_use_real_queue_data(): assert "diagnostic_reasons" in repository_source assert "商品線不符" in repository_source assert "容量差異" in repository_source + assert "妝效質地不同" in repository_source + assert "工具功能不同" in repository_source + assert "多款任選待確認" in repository_source assert "匯出覆核" in dashboard assert "review.diagnostic_reasons" in dashboard assert "dashboard-review-reasons" in dashboard diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index cb008d4..5c11850 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -1322,6 +1322,10 @@ def test_marketplace_matcher_keeps_high_variant_low_score_lines_outside_focused_ "【rom&nd】水感唇釉", "rom&nd 果汁唇釉5.5g_多款可選", ) + peripera_variant_gap = score_marketplace_match( + "【peripera官方直營】雙頭旋轉極細眉筆_多色任選(1.5mm極細筆頭)", + "PERIPERA 雙頭旋轉極細眉筆 09灰褐棕 0.05g", + ) summer_eve_variant_gap = score_marketplace_match( "即期品【Summer’s Eve 舒摩兒】全肌防護浴潔露2入(私密清潔 經典防護王)無外盒裸瓶包裝", "eve舒摩兒 生理呵護浴潔露 2入組", @@ -1351,6 +1355,7 @@ def test_marketplace_matcher_keeps_high_variant_low_score_lines_outside_focused_ ysl_powder_variant_guard, opi_series_gap, romand_line_gap, + peripera_variant_gap, summer_eve_variant_gap, solone_type_gap, erbe_tool_gap, @@ -1369,6 +1374,8 @@ def test_marketplace_matcher_keeps_high_variant_low_score_lines_outside_focused_ 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 + assert peripera_variant_gap.hard_veto is False + assert "variant_selection_review" in peripera_variant_gap.reasons assert mac_finish_gap.hard_veto is True assert "makeup_finish_conflict" in mac_finish_gap.reasons assert schick_bundle_gap.hard_veto is True @@ -1744,6 +1751,10 @@ def test_marketplace_matcher_promotes_safe_exact_spec_near_threshold(): "【MUJI 無印良品】精油芬香護手霜 50g", "MUJI 無印良品 精油芬香護手霜/薰衣草&迷迭香/50g", ) + muji_brandless_hand_cream = score_marketplace_match( + "【MUJI 無印良品】精油芬香護手霜/50g(薰衣草&迷迭香/葡萄柚&柳橙/檜木&佛手柑)", + "精油芬香護手霜/薰衣草&迷迭香/50g", + ) mustela_lotion_2pack = score_marketplace_match( "【Mustela 慕之恬廊】慕之幼 爽身潤膚乳500mlX2", "Mustela慕之恬廊 慕之幼爽身潤膚乳500mlX2", @@ -1758,6 +1769,11 @@ def test_marketplace_matcher_promotes_safe_exact_spec_near_threshold(): assert diagnostics.hard_veto is False assert "strong_exact_spec_match" in diagnostics.reasons + assert muji_brandless_hand_cream.score >= 0.76 + assert muji_brandless_hand_cream.hard_veto is False + assert "focused_exact_identity_muji_aroma_hand_cream_brandless" in muji_brandless_hand_cream.reasons + assert "variant_selection_review" in muji_brandless_hand_cream.reasons + def test_marketplace_matcher_does_not_promote_different_option_without_spec(): from services.marketplace_product_matcher import score_marketplace_match @@ -1774,6 +1790,20 @@ def test_marketplace_matcher_does_not_promote_different_option_without_spec(): assert "variant_descriptor_conflict" in diagnostics.reasons +def test_marketplace_matcher_keeps_named_option_vs_catalog_in_review(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【DASHING DIVA】MAGICPRESS時尚潮流美甲片_極光之藍", + "DASHING DIVA MAGIC PRESS 時尚潮流美甲片 多款任選", + ) + + assert diagnostics.score >= 0.76 + assert diagnostics.hard_veto is False + assert diagnostics.alert_tier == "identity_review" + assert "variant_selection_review" in diagnostics.reasons + + def test_marketplace_matcher_promotes_variant_safe_exact_option(): from services.marketplace_product_matcher import score_marketplace_match