Explain protected PChome match conflicts
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-05-24 23:39:48 +08:00
parent bc48926cc5
commit b297491efe
7 changed files with 93 additions and 3 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.459 強化 PChome `protected_existing_match` 決策封包:解析 `existing_match_conflict` 的既有候選、新候選與雙方 score寫入 `decision_envelope.evidence` / `expected_impact` / `guardrails`,並把下一步明確標成「比較既有正式候選與新候選」;仍保持 `can_auto_execute=false`,避免新候選分數較高時繞過人工覆核自動覆蓋正式價差。
- V10.458 將 OpenClaw / 競品 PPT 接上 PChome 覆核 `decision_envelope` 摘要:`competitor_intel_repository.summarize_review_decision_envelopes()` 成為共用 formatterOpenClaw 週報/日報/月報與競品簡報 data_summary / KPI slide 都讀同一份信封文字,避免策略報告與 PPT 各自翻譯覆核狀態或遺失 HITL guardrails。
- V10.457 將 PChome 覆核 `decision_envelope` 連到人工操作面Dashboard 覆核卡新增決策等級、資料品質、HITL/trace 信封摘要;`/api/export/excel/pchome-review` 匯出同步增加決策信封 ID、決策類型、建議代碼、責任人、資料品質、自動執行允許與證據摘要讓線上操作與下載檔都保留同一份 guardrails。
- V10.456 將 PChome 覆核隊列接上 `decision_envelope` contract`fetch_competitor_review_queue()` 與 `/api/pchome-review/queue` 每筆候選都輸出同一份 SKU、PChome 候選、match evidence、recommended_action、expected_impact 與 HITL guardrailsDashboard、Agent、Telegram、PPT 後續不得再各自重建比價判讀格式;同版將 review queue cache key 升到 v3避免正式環境沿用舊 payload。

View File

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

View File

@@ -2,7 +2,7 @@
> **最後更新**: 2026-05-24 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯Gemini 備援預設關閉
> **適用版本**: V10.458
> **適用版本**: V10.459
---

View File

@@ -38,6 +38,7 @@
- 將狀態拆成:尚未搜尋、價格過期待刷新、近門檻可救回、證據不足、既有強配對保護、已排除、需單位價比較、找不到同款。
- 每筆覆核要顯示候選 PChome 商品、候選價、match score、診斷原因、下一步動作。
- 人工採用 / 否決 / 單位價 / 補搜尋必須能回寫 review queue並影響 feeder 後續行為。
- 2026-05-24 23:40 CST 起,`protected_existing_match` 的 review `decision_envelope` 會解析 `existing_match_conflict`,列出既有正式候選、新候選、雙方 score 與 delta這類案件仍不可自動覆蓋正式價差但人工覆核、Agent 與 PPT 不再只看到籠統「既有保護」。
## 3. 12 Agent 決策信封整合
@@ -46,6 +47,7 @@
- 2026-05-24 23:00 CST 起,`fetch_competitor_review_queue()``fetch_competitor_review_queue_page()``/api/pchome-review/queue` 每筆候選也帶 `decision_envelope`,包含 SKU/PChome 標的、match evidence、人工下一步、預期價差與不可自動寫正式價差的 guardrailsDashboard、Agent、Telegram、PPT 後續共用此 contract。
- 2026-05-24 23:15 CST 起Dashboard 覆核卡與 PChome 覆核 Excel 匯出也顯示/輸出信封摘要、資料品質、HITL、trace、自動執行阻擋原因與證據摘要下載檔不得丟失 guardrails。
- 2026-05-24 23:25 CST 起OpenClaw 週報/日報/月報與 competitor PPT 使用 `summarize_review_decision_envelopes()` 的同一份 HITL 信封摘要,不再手寫 attempt_status 統計或自行翻譯覆核狀態。
- 2026-05-24 23:40 CST 起,`compare_existing_identity` 成為 `protected_existing_match` 的明確建議動作Agent 只能提示「比較既有正式候選與新候選」,不得因新候選分數較高自動寫正式價差。
- 告警不得再輸出空泛「預期效益」必須帶資料品質、證據來源、HITL 邊界與 trace id。
- Agent 建議只能輔助排序與分析,不得繞過 matcher / feeder / review service 寫正式價格。

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-24PChome 近門檻身份回收第二輪
- **V10.459 protected_existing_match 決策封包**: PChome 覆核信封開始解析 `existing_match_conflict`,把既有正式候選、新候選、雙方 matcher score 與 score delta 寫入 evidence / expected_impact / guardrails新候選即使分數較高也維持 `can_auto_execute=false`,但 OpenClaw、PPT、Dashboard 與人工覆核可清楚看見該比較哪兩個候選。
- **V10.458 OpenClaw / PPT 決策信封摘要**: 新增 `summarize_review_decision_envelopes()` 作為 PChome 覆核信封共用摘要 formatterOpenClaw 週報/日報/月報、OpenClaw Bot competitor PPT data_summary 與 PPT KPI slide 都使用同一份 HITL / 資料品質 / action / trace 摘要,不再各自手寫 attempt_status 翻譯。
- **V10.457 Dashboard / Excel 決策信封連動**: 商品看板 PChome 覆核卡顯示 `decision_envelope` 的決策等級、資料品質、HITL 與 trace`/api/export/excel/pchome-review` 匯出新增決策信封 ID、建議代碼、責任人、資料品質、自動執行允許、阻擋原因與證據摘要讓下載檔仍保留不可自動寫正式價差的 guardrails。
- **V10.456 review queue 決策信封**: `fetch_competitor_review_queue()``fetch_competitor_review_queue_page()``/api/pchome-review/queue` 每筆 PChome 覆核候選都輸出 `decision_envelope`,包含標的 SKU/PChome 候選、match evidence、建議人工動作、預期價差、資料品質與「不可自動寫正式價差」guardrailsreview queue cache key 升到 v3避免正式環境沿用舊 payload。

View File

@@ -13,6 +13,7 @@ from __future__ import annotations
import os
import json
import pickle
import re
import time
from datetime import date, datetime, timedelta
from pathlib import Path
@@ -99,6 +100,7 @@ MANUAL_REVIEW_ACTION_LABELS = {
"needs_research": "需補搜尋",
}
DECISION_ACTION_LABELS = {
"compare_existing_identity": "比較既有正式候選與新候選",
"review_accept_identity": "人工覆核後採用同款",
"unit_price_required": "確認單位價 / 組合差異",
"needs_research": "補搜尋詞或重新抓取",
@@ -318,7 +320,9 @@ def _review_action_code(attempt_status: str) -> str:
return "needs_research"
if attempt_status in {"identity_veto", "manual_rejected"}:
return "verify_or_reject_identity"
if attempt_status in {"expired_match", "protected_existing_match"}:
if attempt_status == "protected_existing_match":
return "compare_existing_identity"
if attempt_status == "expired_match":
return "refresh_or_compare_identity"
return "human_review"
@@ -348,11 +352,37 @@ def _review_severity(attempt_status: str, item: dict[str, Any]) -> str:
return "P2"
if attempt_status in UNIT_COMPARABLE_STATUSES:
return "P2"
if attempt_status == "protected_existing_match":
conflict = item.get("existing_match_conflict")
if isinstance(conflict, dict) and _num(conflict.get("score_delta")) >= 0.03:
return "P2"
return "P3"
if attempt_status in {"recoverable_low_score", "expired_match"}:
return "P3"
return "P4"
_EXISTING_MATCH_FIELD_RE = re.compile(r"\b(existing_id|incoming_id|existing_score|incoming_score)=([^;]+)")
def _parse_existing_match_conflict(error_message: Any) -> dict[str, Any]:
text = str(error_message or "")
if "existing_match_conflict" not in text:
return {}
fields = {match.group(1): match.group(2).strip() for match in _EXISTING_MATCH_FIELD_RE.finditer(text)}
if not fields:
return {}
existing_score = _num(fields.get("existing_score"))
incoming_score = _num(fields.get("incoming_score"))
return {
"existing_product_id": fields.get("existing_id") or "",
"incoming_product_id": fields.get("incoming_id") or "",
"existing_score": round(existing_score, 3),
"incoming_score": round(incoming_score, 3),
"score_delta": round(incoming_score - existing_score, 3),
}
def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]:
"""Build the shared evidence contract for an operator review queue item."""
attempt_status = str(item.get("attempt_status") or "")
@@ -402,6 +432,22 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]:
"value": reason_text,
"basis": "match_diagnostic_json.reasons",
})
existing_conflict = item.get("existing_match_conflict")
if isinstance(existing_conflict, dict) and existing_conflict:
score_delta = _num(existing_conflict.get("score_delta"))
incoming_score = _num(existing_conflict.get("incoming_score"))
existing_score = _num(existing_conflict.get("existing_score"))
evidence.append({
"type": "conflict",
"metric": "existing_match_conflict",
"value": (
f"新候選 {existing_conflict.get('incoming_product_id') or 'unknown'} "
f"{incoming_score:.3f} vs "
f"既有候選 {existing_conflict.get('existing_product_id') or 'unknown'} "
f"{existing_score:.3f}"
),
"basis": f"score_delta={score_delta:+.3f}; overwrite_protection=on",
})
return {
"decision_id": (
@@ -429,6 +475,7 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]:
"expected_impact": {
"gap_amount": gap_amount,
"candidate_gap_pct": gap_pct,
"existing_match_conflict": existing_conflict if isinstance(existing_conflict, dict) else {},
"risk_reduction": "medium" if attempt_status in {"rescore_accepted_current", "recoverable_low_score"} else "watch",
},
"confidence": round(_num(item.get("best_match_score")), 3),
@@ -437,6 +484,7 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]:
"blocked_reason": "PChome 候選需人工覆核;不得自動寫入正式 competitor_prices",
"data_quality": _review_data_quality(attempt_status, item),
"attempt_status": attempt_status,
"existing_match_protected": bool(existing_conflict),
"match_type": item.get("match_type") or "",
"price_basis": item.get("price_basis") or "",
"alert_tier": item.get("alert_tier") or "",
@@ -556,6 +604,7 @@ def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]:
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_payload)
existing_match_conflict = _parse_existing_match_conflict(match_diagnostic)
formatted = {
"sku": str(item.get("sku") or ""),
"name": item.get("name") or "",
@@ -579,6 +628,7 @@ def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]:
"evidence_flags": list(evidence_flags) if isinstance(evidence_flags, list) else [],
"diagnostic_reasons": diagnostic_reasons,
"diagnostic_reason_text": "".join(reason["label"] for reason in diagnostic_reasons),
"existing_match_conflict": existing_match_conflict,
"attempted_at": _date_label(item.get("attempted_at")),
"unit_comparison": unit_comparison,
}

View File

@@ -220,6 +220,42 @@ def test_rescore_accepted_review_item_has_actionable_decision_envelope():
assert any(evidence["metric"] == "candidate_gap_pct" for evidence in envelope["evidence"])
def test_protected_existing_match_envelope_explains_candidate_conflict():
from services.competitor_intel_repository import _format_competitor_review_item
item = _format_competitor_review_item({
"sku": "14338675",
"name": "【Relove】胺基酸私密潔淨精華凝露120ml",
"momo_price": 399,
"attempt_status": "protected_existing_match",
"candidate_count": 2,
"best_competitor_product_id": "QEAE1O-A900A6DRS",
"best_competitor_product_name": "RELOVE胺基酸私密清潔凝露120ml",
"best_competitor_price": 329,
"best_match_score": 0.817,
"match_diagnostic_json": {
"match_type": "exact",
"price_basis": "total_price",
"alert_tier": "identity_review",
"reasons": ["strong_exact_spec_match", "spec_name_alignment"],
},
"error_message": (
"existing_match_conflict;existing_id=QEAE1O-A900A6DNN;"
"incoming_id=QEAE1O-A900A6DRS;existing_score=0.766;incoming_score=0.817"
),
})
envelope = item["decision_envelope"]
conflict = envelope["expected_impact"]["existing_match_conflict"]
assert item["existing_match_conflict"]["score_delta"] == 0.051
assert envelope["recommended_action"]["action"] == "compare_existing_identity"
assert envelope["severity"] == "P2"
assert envelope["guardrails"]["existing_match_protected"] is True
assert conflict["existing_product_id"] == "QEAE1O-A900A6DNN"
assert conflict["incoming_product_id"] == "QEAE1O-A900A6DRS"
assert any(evidence["metric"] == "existing_match_conflict" for evidence in envelope["evidence"])
def test_review_decision_brief_is_shared_by_openclaw_and_ppt():
from services.competitor_intel_repository import (
_format_competitor_review_item,