細分 PChome 配對拒絕狀態
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s

This commit is contained in:
OoO
2026-05-19 23:31:44 +08:00
parent d20615992a
commit 645cd397c2
3 changed files with 51 additions and 14 deletions

View File

@@ -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 必須把「待比對」拆成可診斷狀態:`價格過期待刷新``舊版配對待重驗``低分配對待審``身份否決``找不到同款``抓取異常``尚未搜尋`。不可再用單一「待比對」掩蓋資料品質原因。
### 執行方式

View File

@@ -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',

View File

@@ -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"]