diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 012828d..888c495 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -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()` 成為共用 formatter,OpenClaw 週報/日報/月報與競品簡報 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 guardrails,Dashboard、Agent、Telegram、PPT 後續不得再各自重建比價判讀格式;同版將 review queue cache key 升到 v3,避免正式環境沿用舊 payload。 diff --git a/config.py b/config.py index f6ecaf6..d0da8b2 100644 --- a/config.py +++ b/config.py @@ -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 # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index fcfecba..d172050 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-24 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 -> **適用版本**: V10.458 +> **適用版本**: V10.459 --- diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index 071cbda..f166619 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -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、人工下一步、預期價差與不可自動寫正式價差的 guardrails;Dashboard、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 寫正式價格。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 7d3ce93..e065e10 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **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 覆核信封共用摘要 formatter;OpenClaw 週報/日報/月報、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、建議人工動作、預期價差、資料品質與「不可自動寫正式價差」guardrails;review queue cache key 升到 v3,避免正式環境沿用舊 payload。 diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 404ea01..5662fc9 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -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, } diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index 8b13c09..1edd8ae 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -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,