diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index d68599c..ca7adf2 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -344,6 +344,7 @@ LEFT JOIN competitor_prices cp - `CompetitorPriceFeeder.run_expired_identity_refresh()` 會優先刷新已通過 `identity_v2` 但 TTL 過期的 PChome row:直接用既有 `competitor_product_id` 批次呼叫 PChome 商品 API,再用新版 matcher 重新驗證名稱/規格/價格 sanity,通過後寫回 `competitor_prices` 與 `competitor_price_history`。這條路徑提升新鮮價格覆蓋率,但不降低 match threshold,也不讓過期價格直接進入決策。 - `marketplace_product_matcher.py` 的擴充只能走「正向證據 + 反向 veto」:品牌一致、商品線/型號訊號強、價格合理且無 hard veto 時才允許 `strong_product_line_match` 加分;補充瓶/補充包/refill 與一般正裝不互相配對,分享組/加量組/明星組等組合包不得誤配單品。 - PChome feeder 的外部 request timeout 由 `PCHOME_FEEDER_TIMEOUT` 控制,預設 12 秒;排程不得因單一 PChome 搜尋 API timeout 被拖到數分鐘。 +- 商品看板的 PChome 狀態必須把 matcher 診斷原因翻成可行動語意:品牌衝突、規格衝突、補充包差異、組合差異、商品線不符等,不可只顯示籠統「待比對」或「身份否決」。 - Dashboard 必須把「待比對」拆成可診斷狀態:`價格過期待刷新`、`舊版配對待重驗`、`低分配對待審`、`身份否決`、`找不到同款`、`抓取異常`、`尚未搜尋`。不可再用單一「待比對」掩蓋資料品質原因。 ### 執行方式 diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 4e62a5f..06ee401 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -62,6 +62,24 @@ def _to_float(value): return None +def _diagnostic_match_rejection_label(diagnostic_text, score_text, *, blocked=True): + diagnostic_text = diagnostic_text or '' + suffix = '已停止自動採用' if blocked else '不自動採用以避免錯配' + if 'refill_pack_conflict' in diagnostic_text: + return '補充包差異待審', f'{score_text},補充瓶/補充包與一般正裝不同,{suffix}' + if any(token in diagnostic_text for token in ('bundle_offer_conflict', 'multi_component_conflict')): + return '組合差異待審', f'{score_text},組合包/多件組與單品不同,{suffix}' + if 'brand_conflict' in diagnostic_text: + return '品牌衝突待審', f'{score_text},品牌不一致,{suffix}' + if any(token in diagnostic_text for token in ('volume_conflict', 'weight_conflict', 'count_conflict', 'component_count_conflict')): + return '規格衝突待審', f'{score_text},容量/件數不一致,{suffix}' + if 'type_conflict' in diagnostic_text: + return '品類衝突待審', f'{score_text},品類不一致,{suffix}' + if 'product_line_conflict' in diagnostic_text: + return '商品線不符待審', f'{score_text},商品線訊號不足,{suffix}' + return '身份否決' if blocked else '低信心待審', f'{score_text},{suffix}' + + def _build_pchome_match_status(attempt=None, ineligible=None): if attempt: status = attempt.get('attempt_status') or 'unknown' @@ -86,10 +104,15 @@ def _build_pchome_match_status(attempt=None, ineligible=None): if status == 'identity_veto': score = _to_float(attempt.get('best_match_score')) score_text = f"最佳候選 {round(score * 100)}%" if score is not None else "已拒絕候選" + label, summary = _diagnostic_match_rejection_label( + attempt.get('error_message'), + score_text, + blocked=True, + ) return { - 'label': '身份否決', + 'label': label, 'tone': 'neutral', - 'summary': '新版 identity_v2 判定不是同款,已阻擋自動比價', + 'summary': summary, 'detail': score_text, } @@ -141,18 +164,11 @@ def _build_pchome_match_status(attempt=None, ineligible=None): if status == 'low_score': diagnostic_text = attempt.get('error_message') or '' - if 'brand_conflict' in diagnostic_text: - label = '品牌衝突待審' - summary = f'{score_text},品牌不一致,已停止自動採用' - elif any(token in diagnostic_text for token in ('volume_conflict', 'weight_conflict', 'count_conflict')): - label = '規格衝突待審' - summary = f'{score_text},容量/件數不一致,已停止自動採用' - elif 'type_conflict' in diagnostic_text: - label = '品類衝突待審' - summary = f'{score_text},品類不一致,已停止自動採用' - else: - label = '低信心待審' - summary = f'{score_text},不自動採用以避免錯配' + label, summary = _diagnostic_match_rejection_label( + diagnostic_text, + score_text, + blocked=False, + ) return { 'label': label, 'tone': 'neutral', diff --git a/tests/test_competitor_identity_revalidator.py b/tests/test_competitor_identity_revalidator.py index cc748c9..c747955 100644 --- a/tests/test_competitor_identity_revalidator.py +++ b/tests/test_competitor_identity_revalidator.py @@ -63,3 +63,23 @@ def test_dashboard_match_status_distinguishes_expired_and_legacy_rows(): assert expired["tone"] == "watch" assert legacy["label"] == "舊版配對待重驗" assert "identity_v2" in legacy["summary"] + + +def test_dashboard_match_status_explains_identity_veto_reason(): + from routes.dashboard_routes import _build_pchome_match_status + + bundle = _build_pchome_match_status({ + "attempt_status": "identity_veto", + "best_match_score": 0.32, + "error_message": "score=0.32; reasons=bundle_offer_conflict,product_line_conflict", + }) + refill = _build_pchome_match_status({ + "attempt_status": "identity_veto", + "best_match_score": 0.32, + "error_message": "score=0.32; reasons=refill_pack_conflict", + }) + + assert bundle["label"] == "組合差異待審" + assert "組合包/多件組" in bundle["summary"] + assert refill["label"] == "補充包差異待審" + assert "補充瓶/補充包" in refill["summary"]