V10.582 強化比價通知與身份證據
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-06-04 14:03:21 +08:00
parent bfb8e3c99d
commit 35ad29b04d
12 changed files with 220 additions and 4 deletions

View File

@@ -832,6 +832,11 @@ def _product_id_key(product_id: str) -> str:
return re.sub(r"[^A-Z0-9]", "", str(product_id or "").upper())
def _candidate_match_name(product) -> str:
"""Return the identity-rich PChome text used for scoring, falling back to display name."""
return (getattr(product, "match_name", None) or getattr(product, "name", None) or "").strip()
def _find_best_match_detail(
momo_name: str,
pchome_products: list,
@@ -863,7 +868,7 @@ def _rank_match_details(
for p in pchome_products:
diagnostics = score_marketplace_match(
momo_name,
p.name,
_candidate_match_name(p),
momo_price=momo_price,
competitor_price=getattr(p, "price", None),
)

View File

@@ -543,6 +543,8 @@ def _build_price_decision_envelope(
"sku": sku,
"name": name,
"event_type": "price_competition",
"momo_price": momo_value if momo_value > 0 else None,
"competitor_price": comp_value if comp_value > 0 else None,
"competitor_product_id": competitor_product_id,
"competitor_product_name": str(competitor_product_name or "")[:120],
},
@@ -554,6 +556,10 @@ def _build_price_decision_envelope(
"requires_hitl": True,
},
"expected_impact": {
"momo_price": momo_value if momo_value > 0 else None,
"competitor_price": comp_value if comp_value > 0 else None,
"candidate_gap_pct": round(gap_value, 1),
"sales_7d_delta_pct": round(sales_value, 1),
"revenue_loss_7d": round(loss_value, 2),
"gap_amount": gap_amount,
"recommended_price": recommended_price,

View File

@@ -39,6 +39,8 @@ class PChomeProduct:
review_count: int # 評論數
is_on_sale: bool # 是否特價中
crawled_at: datetime # 爬取時間
subtitle: str = '' # PChome Nick / 副標,常含容量、入數與濃度
match_name: str = '' # 給 matcher 使用的身份文字UI/DB 顯示仍用 name
def to_dict(self) -> dict:
"""轉換為字典"""
@@ -47,6 +49,17 @@ class PChomeProduct:
return data
def _build_match_name(name: str, subtitle: str) -> str:
"""Build an identity-rich title without duplicating the PChome display name."""
display_name = str(name or '').strip()
nick = str(subtitle or '').strip()
if not nick or nick == display_name:
return display_name
if display_name and nick.startswith(display_name):
return nick
return f"{display_name} {nick}".strip()
class PChomeCrawler:
"""PChome 24h 爬蟲"""
@@ -334,9 +347,12 @@ class PChomeCrawler:
image_url = f"{self.IMAGE_BASE_URL}{pic_path}" if pic_path else ''
name = data.get('Name', '') or ''
subtitle = data.get('Nick', '') or ''
return PChomeProduct(
product_id=product_id,
name=data.get('Name', ''),
name=name,
price=price,
original_price=original_price,
discount=discount,
@@ -347,7 +363,9 @@ class PChomeCrawler:
rating=data.get('RatingValue'),
review_count=data.get('ReviewCount', 0),
is_on_sale=data.get('isOnSale', False),
crawled_at=crawled_at
crawled_at=crawled_at,
subtitle=subtitle,
match_name=_build_match_name(name, subtitle),
)
except Exception as e:

View File

@@ -776,7 +776,9 @@ def _action_label(action_code: str) -> str:
labels = {
"price_follow_review": "確認是否跟價或改用促銷防守",
"review_accept_identity": "人工確認同款後採納 identity",
"review_catalog_comparable": "依型錄證據覆核可比性",
"unit_price_required": "改用單位價覆核,不寫總價型價差",
"identity_or_price_review": "先確認身份,再判斷價格處置",
"verify_or_reject_identity": "確認候選是否同款;非同款即駁回",
"compare_existing_identity": "比較既有正式 identity 與新候選",
"refresh_or_compare_identity": "刷新過期 identity 後再覆核",
@@ -786,6 +788,55 @@ def _action_label(action_code: str) -> str:
return labels.get(action_code or "", action_code or "人工覆核")
_PRICE_MATCH_TYPE_LABELS = {
"exact": "高信心同款",
"same_product_different_pack": "同商品不同包裝",
"same_line_variant": "同系列不同款",
"comparable": "可比但需覆核",
"no_match": "非同款",
}
_PRICE_BASIS_LABELS = {
"total_price": "總價可比",
"unit_price": "單位價可比",
"manual_review": "人工覆核後可比",
"none": "不可比",
}
_PRICE_ALERT_TIER_LABELS = {
"price_alert_exact": "可直接價格告警",
"unit_price_review": "單位價覆核",
"identity_review": "身份覆核",
"suppress": "壓制告警",
}
def _price_match_path(envelope: Dict[str, Any]) -> tuple[str, str, str]:
guardrails = envelope.get("guardrails") if isinstance(envelope.get("guardrails"), dict) else {}
match_type = str(guardrails.get("match_type") or "")
price_basis = str(guardrails.get("price_basis") or "")
alert_tier = str(guardrails.get("alert_tier") or "")
if match_type and price_basis and alert_tier:
return match_type, price_basis, alert_tier
match_evidence = _find_evidence(envelope, "match_score") or {}
basis = str(match_evidence.get("basis") or "")
parts = [part.strip() for part in basis.split("/") if part.strip()]
if len(parts) >= 3:
return match_type or parts[0], price_basis or parts[1], alert_tier or parts[2]
return match_type, price_basis, alert_tier
def _price_notification_guidance(match_type: str, price_basis: str, alert_tier: str) -> tuple[str, str]:
if match_type == "exact" and price_basis == "total_price" and alert_tier == "price_alert_exact":
return "直接價格威脅", "可用總價比較;先確認庫存與促銷期,再人工決定跟價或促銷防守。"
if price_basis == "unit_price" or alert_tier == "unit_price_review":
return "單位價覆核", "先換算單位價與入數,禁止用總價直接判定價格威脅。"
if alert_tier == "identity_review" or price_basis == "manual_review":
return "身份覆核", "先確認同款、規格、組合與前台狀態,人工採納後才可寫入正式價差。"
if alert_tier == "suppress" or match_type == "no_match" or price_basis == "none":
return "壓制告警", "目前不可作為價格威脅;保留診斷紀錄,避免誤報。"
return "可比性待判讀", "依比對證據人工覆核,未確認前不自動調價、不覆蓋正式 identity。"
def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]:
"""將價格/競品決策信封排成可讀的專業 brief。"""
severity = escape(str(envelope.get("severity") or "info"))
@@ -809,6 +860,22 @@ def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]:
if blocked_reason:
lines.append(f"• 邊界:{blocked_reason}")
match_type, price_basis, alert_tier = _price_match_path(envelope)
if match_type or price_basis or alert_tier:
guidance_title, guidance_text = _price_notification_guidance(match_type, price_basis, alert_tier)
path_labels = [
_PRICE_MATCH_TYPE_LABELS.get(match_type, match_type) if match_type else "",
_PRICE_BASIS_LABELS.get(price_basis, price_basis) if price_basis else "",
_PRICE_ALERT_TIER_LABELS.get(alert_tier, alert_tier) if alert_tier else "",
]
lines += [
"",
"🚦 <b>通知分級</b>",
f"• 判讀:<b>{escape(guidance_title)}</b>",
f"• 路徑:{escape(' / '.join(part for part in path_labels if part))}",
f"• 邊界:{escape(guidance_text)}",
]
sku = escape(str(subject.get("sku") or ""))
name = escape(_short_text(subject.get("name") or "", 96))
competitor_id = escape(str(subject.get("competitor_product_id") or subject.get("pchome_id") or ""))
@@ -891,6 +958,24 @@ def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]:
if evidence_lines:
lines += ["", "🧩 <b>比對證據</b>", *evidence_lines]
difference_highlights = envelope.get("difference_highlights")
if isinstance(difference_highlights, list) and difference_highlights:
diff_lines = []
for row in difference_highlights[:3]:
if not isinstance(row, dict):
continue
dimension = escape(str(row.get("dimension") or row.get("label") or "差異"))
left = escape(_short_text(row.get("left") or row.get("momo") or "", 42))
right = escape(_short_text(row.get("right") or row.get("pchome") or "", 42))
if left or right:
diff_lines.append(f"{dimension}MOMO {left or ''} / PChome {right or ''}")
else:
note = escape(_short_text(row.get("note") or row.get("summary") or "", 84))
if note:
diff_lines.append(f"{dimension}{note}")
if diff_lines:
lines += ["", "⚖️ <b>差異提醒</b>", *diff_lines]
action_code = str(recommended_action.get("action") or "human_review")
owner = escape(str(recommended_action.get("owner") or "未指定"))
requires_hitl = bool(recommended_action.get("requires_hitl", True))