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

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.582 補 PChome 比價通知專業分級與 Nick 副標身份證據NemoTron 價格決策信封現在保留 `momo_price`、`competitor_price`、`candidate_gap_pct` 與 `sales_7d_delta_pct`EventRouter / Telegram 模板會把 `match_type / price_basis / alert_tier` 翻成「直接價格威脅、單位價覆核、身份覆核、壓制告警」與操作邊界PChome crawler 會保留 `Nick` 副標為 `match_name` 給 matcher 使用UI/DB 顯示仍維持原品名,讓容量、入數、濃度資訊可參與比對。
- V10.581 將 V10.580 的重複單品組安全線接進 PChome retryable revalidation 窄門:只允許 `true_low_confidence`、舊診斷為 `match_type=exact / price_basis=manual_review`、無阻擋原因且命中具名安全商品線Bioneo 150ml x2、Cetaphil 150ml x2、Avene 300ml x4、Schick 2+1 入、KOSE 雪肌精 500ml x2 禮盒)的候選進小批次重評;仍由最新版 matcher 最終判斷是否寫入正式 `competitor_prices`。
- V10.580 補 PChome 重複單品組 total-price 窄門:同品牌、同入數、同基礎規格且名稱高度對齊的 150ml x2、300ml x4、2+1 入等候選,可由 `exact / manual_review` 進 `exact / total_price / price_alert_exact`,正式部署前估算 213 筆高分 `true_low_confidence` 中只有 7 筆會轉自動寫入。同步新增 NEW DIRECTIONS 甜杏仁油 vs 杏桃核仁油核心油種 hard veto避免規格一樣但油種不同的錯配污染正式價差Paula's Choice 這類 PChome 端缺 30ml 規格的雙入組仍保留 manual review不放寬全域門檻。
- V10.579 補 PChome 高信心 total-price safe familySAB 私密防護舒緩噴霧 30ml 與 Herbacin 小甘菊 20ml 護手霜在同款式、同規格、無 variant/commercial gap 時可由 focused matcher 進 `exact / total_price / price_alert_exact`讓近門檻重評能真正寫入正式比價Herbacin 柔皙 vs 野生玫瑰等跨 variant 仍保留在 review不放寬全域門檻。同版將 Code Review GCP-B secondary timeout 預設由 60 秒收斂到 25 秒GCP-A preflight 不通且 GCP-B 生成卡住時更快回 deterministic local degraded不呼叫 Gemini/111。

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.581"
SYSTEM_VERSION = "V10.582"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -107,6 +107,7 @@
- 2026-06-04 起,`V10.579` 補 PChome 高信心 total-price safe familySAB 私密防護舒緩噴霧 30ml、Herbacin 小甘菊 20ml 護手霜在同款式同規格且無 variant/commercial gap 時可進 `exact / total_price / price_alert_exact`;跨款式反測仍擋在 review`MIN_MATCH_SCORE` 不變。同版將 Code Review GCP-B secondary timeout 收斂到 25 秒GCP-A/GCP-B 都慢時更快回 local degraded。
- 2026-06-04 起,`V10.580` 補 PChome 重複單品組 total-price 窄門與核心油種 veto同品牌、同入數、同基礎規格且名稱高度對齊的重複單品組例如 Bioneo 150ml x2、Cetaphil 150ml x2、Avene 300ml x4、Schick 2+1 入)可進 `exact / total_price / price_alert_exact`;正式部署前估算 213 筆高分 `true_low_confidence` 中僅 7 筆會被自動寫入。NEW DIRECTIONS 甜杏仁油 vs 杏桃核仁油改 hard vetoPaula's Choice 缺 30ml 規格的雙入組仍留 manual review`MIN_MATCH_SCORE` 不變。
- 2026-06-04 起,`V10.581` 將重複單品組安全線接進 retryable revalidation只收 `true_low_confidence` 中舊診斷為 `match_type=exact / price_basis=manual_review`、無 commercial / variant / count / bundle 等阻擋,且命中具名安全商品線的候選;最後仍由最新版 matcher 與 overwrite protection 決定是否寫正式比價。
- 2026-06-04 起,`V10.582` 補 PChome 比價通知專業分級與 Nick 副標身份證據NemoTron 決策信封保留 MOMO / PChome 價格、價差與 7 日業績變化Telegram decision envelope 將 `exact / total_price / price_alert_exact` 等工程路徑翻成直接價格威脅、單位價覆核、身份覆核或壓制告警,並把「單位價/身份未確認不得用總價直接告警」寫進操作邊界。PChome `Nick` 副標會以 `match_name` 參與 matcher比價可用到容量、入數、濃度資訊但不改 UI/DB 正式顯示品名。
- 2026-06-04 起,`V10.578` 修正 Code Review deterministic scan 的 timeout 判定,多行 `requests.*(... timeout=...)` 不再被誤報為未設定 timeout。
- 2026-06-04 起,`V10.577` Code Review OpenClaw 會在 explicit Ollama host generate 前先做短 `/api/version` preflightGCP-A 不通時快速跳 GCP-B避免 15 秒 timeout 後才降級,且仍不呼叫 Gemini / 111。
- 2026-06-04 起,`V10.576` 修正 GCP-only Ollama retrycaller 禁用 111 fallback 時resolver 若回到 111 會改試 GCP-A/GCP-B allowlist不再讓 Hermes / Code Review 類任務因 resolver 快取到 111 而 `all 0 hosts failed`

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-06-01PChome 比價新鮮度操作閉環
- **V10.582 PChome 比價通知專業分級 + Nick 副標身份證據**: NemoTron 價格決策信封補齊 `momo_price``competitor_price``candidate_gap_pct``sales_7d_delta_pct`,避免 EventRouter / Telegram 模板拿不到核心價格事實。價格決策模板新增「通知分級」,將 `match_type / price_basis / alert_tier` 翻成直接價格威脅、單位價覆核、身份覆核或壓制告警,並同步顯示操作邊界;單位價或 identity 未確認時明確禁止以總價直接判定價格威脅。PChome crawler 另保留 `Nick` 副標為 `match_name` 給 matcher 使用UI/DB 顯示仍用原品名,讓容量、入數、濃度資訊可參與比對。
- **V10.581 重複單品組 retryable revalidation 接線**: 將 V10.580 的安全 matcher 線接到 `run_retryable_candidate_revalidation()`,但只收 `true_low_confidence` 中舊診斷已是 `match_type=exact / price_basis=manual_review`、沒有 review block且命中具名安全商品線的候選。這讓 Bioneo / Cetaphil / Avene / Schick / KOSE 等重複單品組可以小批次重評並由最新版 matcher 決定是否寫入正式 `competitor_prices`,仍不開放泛用高分掃描。
- **V10.580 PChome 重複單品組 total-price 窄門 + 核心油種 veto**: matcher 將同品牌、同入數、同基礎規格且名稱高度對齊的重複單品組,從 `exact / manual_review` 收斂到 `exact / total_price / price_alert_exact`;正式部署前以最新 `true_low_confidence` 重算213 筆高分候選中僅 7 筆符合自動寫入安全條件。同步新增 NEW DIRECTIONS 甜杏仁油 vs 杏桃核仁油 hard veto避免同容量同按壓頭但核心油種不同的候選被誤放行PChome 端缺規格的 Paula's Choice 雙入組仍停在 manual review。
- **V10.579 PChome 高信心 total-price safe family + Code Review timeout 收斂**: matcher 新增 SAB 私密防護舒緩噴霧 30ml 與 Herbacin 小甘菊 20ml 護手霜的窄範圍 total-price safe 路徑。這些候選仍必須通過既有 score、hard veto、variant/commercial gap 與 overwrite protectionHerbacin 柔皙 vs 野生玫瑰跨 variant 反測維持不進正式價差。目的在不放寬 `MIN_MATCH_SCORE` 的前提下,把可證明同款的高信心 `true_low_confidence` 轉進正式比價覆蓋。同版將 Code Review GCP-B secondary timeout 預設由 60 秒收斂到 25 秒GCP-A preflight 不通且 GCP-B 生成卡住時更快回 deterministic local degraded不呼叫 Gemini/111。

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))

View File

@@ -84,6 +84,42 @@ def test_competitor_review_queue_starts_from_latest_attempts_not_all_products():
assert "valid_competitor AS" not in review_cte_body
def test_competitor_feeder_scores_with_pchome_match_name(monkeypatch):
from services.competitor_price_feeder import _rank_match_details
from services.pchome_crawler import PChomeProduct
product = PChomeProduct(
product_id="DDDE15-A900JZ4GR",
name="【寶拉珍選】水楊酸身體乳雙入組",
price=1777,
original_price=2640,
discount=33,
image_url="",
product_url="https://24h.pchome.com.tw/prod/DDDE15-A900JZ4GR",
stock=20,
store="DDDE15",
rating=None,
review_count=0,
is_on_sale=True,
crawled_at=datetime.now(),
subtitle="【寶拉珍選】水楊酸身體乳雙入組 (2%水楊酸身體乳 210ml x2)",
match_name="【寶拉珍選】水楊酸身體乳雙入組 (2%水楊酸身體乳 210ml x2)",
)
captured = {}
def fake_score(momo_name, competitor_name, **kwargs):
captured["competitor_name"] = competitor_name
return SimpleNamespace(score=0.91)
monkeypatch.setattr("services.marketplace_product_matcher.score_marketplace_match", fake_score)
ranked = _rank_match_details("【Paulas Choice 寶拉珍選】2%水楊酸身體乳210ml二入", [product])
assert ranked[0][0] is product
assert ranked[0][1] == 0.91
assert "210ml x2" in captured["competitor_name"]
def test_competitor_feeder_persists_all_match_attempt_outcomes():
source = (ROOT / "services/competitor_price_feeder.py").read_text(encoding="utf-8")
migration = (ROOT / "migrations/023_competitor_match_attempts.sql").read_text(encoding="utf-8")

View File

@@ -194,6 +194,9 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch):
assert "📊 <b>價格證據</b>" in message
assert "🧩 <b>比對證據</b>" in message
assert "✅ <b>人工下一步</b>" in message
assert "🚦 <b>通知分級</b>" in message
assert "直接價格威脅" in message
assert "高信心同款 / 總價可比 / 可直接價格告警" in message
assert "SKU<code>SKU-1</code>" in message
assert "MOMO<b>NT$ 120</b>" in message
assert "PChome<b>NT$ 100</b>" in message
@@ -204,6 +207,49 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch):
assert kwargs["reply_markup"]["inline_keyboard"][0][0]["callback_data"].startswith("momo:eig:")
def test_price_decision_template_marks_unit_price_review_as_non_direct_alert():
from services.telegram_templates import _format_decision_envelope
envelope = {
"decision_id": "review_queue:SKU-UNIT",
"decision_type": "pchome_match_review",
"severity": "P2",
"confidence": 0.82,
"subject": {
"sku": "SKU-UNIT",
"name": "測試乳液 500ml x2",
"momo_price": 1200,
"competitor_price": 950,
"competitor_product_id": "PCH-UNIT",
"competitor_product_name": "測試乳液 1000ml",
},
"evidence": [
{
"type": "match",
"metric": "match_score",
"value": 0.82,
"basis": "same_product_different_pack/unit_price/unit_price_review",
}
],
"recommended_action": {"action": "unit_price_required", "owner": "營運", "requires_hitl": True},
"guardrails": {
"can_auto_execute": False,
"data_quality": "partial",
"match_type": "same_product_different_pack",
"price_basis": "unit_price",
"alert_tier": "unit_price_review",
},
}
message = "\n".join(_format_decision_envelope(envelope))
assert "🚦 <b>通知分級</b>" in message
assert "單位價覆核" in message
assert "同商品不同包裝 / 單位價可比 / 單位價覆核" in message
assert "禁止用總價直接判定價格威脅" in message
assert "改用單位價覆核,不寫總價型價差" in message
def test_replay_failed_deliveries_removes_successful_records(tmp_path, monkeypatch):
import services.event_router as event_router

View File

@@ -29,6 +29,8 @@ def test_price_decision_envelope_has_hilt_guardrails():
assert envelope["severity"] == "P1"
assert envelope["confidence"] == 0.91
assert envelope["subject"]["sku"] == "10413050"
assert envelope["subject"]["momo_price"] == 1200
assert envelope["subject"]["competitor_price"] == 999
assert envelope["subject"]["competitor_product_id"] == "PCHOME-1"
assert envelope["guardrails"]["can_auto_execute"] is False
assert envelope["guardrails"]["data_quality"] == "complete"
@@ -38,6 +40,10 @@ def test_price_decision_envelope_has_hilt_guardrails():
assert envelope["recommended_action"]["requires_hitl"] is True
assert envelope["expected_impact"]["revenue_loss_7d"] == 65000
assert envelope["expected_impact"]["gap_amount"] == 201
assert envelope["expected_impact"]["momo_price"] == 1200
assert envelope["expected_impact"]["competitor_price"] == 999
assert envelope["expected_impact"]["candidate_gap_pct"] == 16.7
assert envelope["expected_impact"]["sales_7d_delta_pct"] == -31.2
assert any(e["metric"] == "match_score" and e["value"] == 0.93 for e in envelope["evidence"])

View File

@@ -107,6 +107,7 @@ def test_pchome_fetch_product_details_accepts_list_payload():
{
"Id": "DDABCD-12345678",
"Name": "測試商品 50ml",
"Nick": "測試商品 50ml x2 限量組",
"Price": {"P": 799, "M": 999},
"Pic": {"B": "/items/DDABCD12345678.jpg"},
"Qty": 8,
@@ -124,6 +125,16 @@ def test_pchome_fetch_product_details_accepts_list_payload():
assert len(calls) == 1
assert [product.product_id for product in products] == ["DDABCD-12345678"]
assert products[0].price == 799
assert products[0].subtitle == "測試商品 50ml x2 限量組"
assert products[0].match_name == "測試商品 50ml x2 限量組"
def test_pchome_match_name_combines_non_duplicate_nick():
from services.pchome_crawler import _build_match_name
assert _build_match_name("水楊酸身體乳雙入組", "2% 水楊酸身體乳 210ml x2") == (
"水楊酸身體乳雙入組 2% 水楊酸身體乳 210ml x2"
)
def test_feeder_search_cleanup_preserves_bracket_brand_and_specs():