diff --git a/config.py b/config.py
index a2d274a..939c5a1 100644
--- a/config.py
+++ b/config.py
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.567"
+SYSTEM_VERSION = "V10.568"
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 7e033eb..280dea2 100644
--- a/docs/AI_INTELLIGENCE_MODULE_SOT.md
+++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md
@@ -444,6 +444,7 @@ python3 -m services.competitor_identity_revalidator --limit 500 --apply
3. **收斂行動呼籲 (Call to Action)** — 每則訊息只有一個明確的 👉 建議行動
4. **底部運算足跡** — FinOps + Observability,用分隔線隔開主訊息
5. **EA HITL 專業 brief** — `ea_escalation` 必須分成決策狀態、背景摘要、風險摘要、TOP 待審 SKU 與建議處置;價格類行動不得用長 bullet 串接,必須拆出 MOMO/PChome 價格、價差、人工處置與 PChome ID。
+6. **價格類決策信封專業 brief** — `price_alert`、`pchome_match_review`、`competitor_price_review` 等含 PChome / 價格證據的 `decision_envelope`,EventRouter 必須直送 evidence template,不得進 L1/L2 重摘要;Telegram 內容必須拆成「標的、價格證據、比對證據、人工下一步」,並從信封讀取 `momo_price`、`competitor_price`、`candidate_gap_pct`、`match_score`、`unit_price_insight` 與 `existing_match_conflict`。
### 5.2 語意化 Emoji 字典
diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md
index fe34135..d9f341f 100644
--- a/docs/memory/current_execution_queue_20260524.md
+++ b/docs/memory/current_execution_queue_20260524.md
@@ -105,6 +105,7 @@
- 2026-05-31 起,`V10.509` 新增市場情報 MCP Fetch Candidate Queue Writer Review Decision Approval Writer Preflight gate:在 human approval 通過後只審核 operator writer preflight 摘要,要求 approval linkage、writer_preflight_id、target operation、row count、dedupe keys、approved decision 到 target review_state 的逐列映射、decision/approval/preflight evidence refs、matched row exact-identity/variant/overwrite guard 與 operator boundary;仍不讀 token、不執行 CLI、不開 DB、不寫 preflight/approval/decision/match、不更新 review_state、不補 queue、不掛 scheduler,只放行到後續 CLI review / run package 設計。
- 2026-06-01 起,`V10.566` 新增市場情報 Professional Source Governance gate:將 robots/REP、sitemap/lastmod、JSON-LD / schema.org structured data、canonical URL、rate limit、公開資料邊界、provenance、snapshot hash 與 idempotency key 納入 source contract,並接上 `/api/market_intel/mcp_professional_source_governance`、UI preview panel、deployment readiness check 與 production smoke target;仍不抓外站、不讀 robots/sitemap、不開 DB、不寫檔、不掛 scheduler。
- 2026-06-02 起,`V10.567` 將 MCP 市場洞察 fallback 收斂為 GCP-A / GCP-B only,不再讓 111 承接非即時市場分析長任務;預設 timeout 25 秒、`num_predict` 500,GCP 不可用時直接保守降級,避免 Elephant Alpha 60 秒 timeout 與 111 負載尖峰。
+- 2026-06-02 起,`V10.568` 將價格類 `decision_envelope` 的 Telegram 直送訊息改為專業 brief:標的、價格證據、比對證據、人工下一步四段式;review queue 信封 subject 同步帶 `momo_price` / `competitor_price`,讓 Telegram、PPT、Webcrumbs 與 AI 摘要共用價格證據。
## 3. 12 Agent 決策信封整合
diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md
index 71d067f..899e2ed 100644
--- a/docs/memory/history_logs.md
+++ b/docs/memory/history_logs.md
@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-06-01:PChome 比價新鮮度操作閉環
+- **V10.568 價格類決策信封專業 brief**: `decision_envelope` 的價格 / PChome 覆核事件在 Telegram EventRouter 直送時,改以「標的、價格證據、比對證據、人工下一步」四段式排版呈現,保留 `momo:eig:` 忽略按鈕且不進 L1/L2 AI 重摘要。`competitor_intel_repository` 同步在 review queue 信封 subject 補上 `momo_price` / `competitor_price`,讓 Telegram、PPT、Webcrumbs 與 AI 摘要可共用同一份價格證據,不再各自補查或重組。
- **V10.567 MCP 市場洞察 GCP-only fallback**: `MCPCollectorService._ollama_topic_fallback()` 改成只使用 GCP-A / GCP-B Ollama,失敗後保守回本地 fallback,不再把市場洞察長分析轉嫁到 111。預設 timeout 收斂為 25 秒、`num_predict` 收斂為 500,避免 Elephant Alpha 在 GCP-A/GCP-B 短暫不可用時撞 60 秒總上限或造成 111 負載尖峰;Gemini 仍維持 `GEMINI_API_HARD_DISABLED=true` 預設硬封鎖。
- **V10.565 PChome 覆蓋率操作建議**: 補強 `/api/ai/pchome-match/backfill/status`,將低覆蓋率拆成 `operation_backlog`:刷新舊 identity、重評近門檻、補抓未配對、人工覆核、單位價覆核與過期搜尋救援預覽;並新增 `recommended_next_action`,Dashboard 狀態摘要會直接顯示建議下一步,避免使用者只看到低覆蓋率卻不知道該按哪條產線。
- **V10.563 正式 preview 假可救候選收斂**: 針對正式 `retryable_candidate_preview` 露出的 M.A.C 蜜粉與 SAUGELLA 菁萃潔浴凝露案例補 guard。M.A.C 單邊明確色號(如 `#絕絕紫`)會進 `variant_selection_review`,維持 `true_low_confidence`;SAUGELLA 潤澤 / 日用型 / 加強 / 黃金女郎型互斥,直接 hard veto,避免同品線不同私密清潔款式被當成 recoverable low_score。
diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py
index f61b153..e4679df 100644
--- a/services/competitor_intel_repository.py
+++ b/services/competitor_intel_repository.py
@@ -522,6 +522,8 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]:
"sku": str(item.get("sku") or ""),
"name": item.get("name") or "",
"event_type": "pchome_match_review",
+ "momo_price": momo_price,
+ "competitor_price": candidate_price,
"competitor_product_id": item.get("candidate_pc_id") or "",
"competitor_product_name": item.get("candidate_pc_name") or "",
},
diff --git a/services/telegram_templates.py b/services/telegram_templates.py
index 4c98974..e5b55c3 100644
--- a/services/telegram_templates.py
+++ b/services/telegram_templates.py
@@ -718,10 +718,207 @@ def _format_ea_escalation_alert(
return "\n".join(lines)
+def _numeric_value(value: Any) -> Optional[float]:
+ if value is None or value == "":
+ return None
+ try:
+ text = str(value).strip().replace(",", "")
+ text = text.replace("NT$", "").replace("$", "").replace("%", "")
+ return float(text)
+ except (TypeError, ValueError):
+ return None
+
+
+def _format_money_value(value: Any) -> str:
+ number = _numeric_value(value)
+ if number is None or number <= 0:
+ return ""
+ if number == int(number):
+ return f"NT$ {int(number):,}"
+ return f"NT$ {number:,.2f}"
+
+
+def _format_percent_value(value: Any) -> str:
+ number = _numeric_value(value)
+ if number is None:
+ text = str(value or "").strip()
+ return escape(text) if text else ""
+ return f"{number:+.1f}%"
+
+
+def _evidence_items(envelope: Dict[str, Any]) -> List[Dict[str, Any]]:
+ raw_items = envelope.get("evidence") if isinstance(envelope.get("evidence"), list) else []
+ return [item for item in raw_items if isinstance(item, dict)]
+
+
+def _find_evidence(envelope: Dict[str, Any], metric: str) -> Optional[Dict[str, Any]]:
+ for item in _evidence_items(envelope):
+ if str(item.get("metric") or item.get("type") or "") == metric:
+ return item
+ return None
+
+
+def _is_price_decision_envelope(envelope: Dict[str, Any]) -> bool:
+ decision_type = str(envelope.get("decision_type") or "").lower()
+ if decision_type in {"price_alert", "pchome_match_review", "competitor_price_review"}:
+ return True
+ subject = envelope.get("subject") if isinstance(envelope.get("subject"), dict) else {}
+ return bool(
+ subject.get("competitor_product_id")
+ or subject.get("competitor_price")
+ or subject.get("pchome_price")
+ or _find_evidence(envelope, "candidate_gap_pct")
+ or _find_evidence(envelope, "unit_price_gap_pct")
+ )
+
+
+def _action_label(action_code: str) -> str:
+ labels = {
+ "price_follow_review": "確認是否跟價或改用促銷防守",
+ "review_accept_identity": "人工確認同款後採納 identity",
+ "unit_price_required": "改用單位價覆核,不寫總價型價差",
+ "verify_or_reject_identity": "確認候選是否同款;非同款即駁回",
+ "compare_existing_identity": "比較既有正式 identity 與新候選",
+ "refresh_or_compare_identity": "刷新過期 identity 後再覆核",
+ "needs_research": "補搜尋或補證據後再判斷",
+ "human_review": "人工覆核",
+ }
+ return labels.get(action_code or "", action_code or "人工覆核")
+
+
+def _format_price_decision_envelope(envelope: Dict[str, Any]) -> List[str]:
+ """將價格/競品決策信封排成可讀的專業 brief。"""
+ severity = escape(str(envelope.get("severity") or "info"))
+ decision_type = escape(str(envelope.get("decision_type") or "price_review"))
+ confidence = _numeric_value(envelope.get("confidence"))
+ subject = envelope.get("subject") if isinstance(envelope.get("subject"), dict) else {}
+ expected = envelope.get("expected_impact") if isinstance(envelope.get("expected_impact"), dict) else {}
+ guardrails = envelope.get("guardrails") if isinstance(envelope.get("guardrails"), dict) else {}
+ recommended_action = envelope.get("recommended_action") if isinstance(envelope.get("recommended_action"), dict) else {}
+
+ data_quality = escape(str(guardrails.get("data_quality") or envelope.get("data_quality") or "unknown"))
+ can_auto_execute = bool(guardrails.get("can_auto_execute", False))
+ blocked_reason = escape(str(guardrails.get("blocked_reason") or ""))
+ confidence_text = f" 信心度:{confidence:.0%}" if confidence is not None else ""
+
+ lines = [
+ "🧭 決策信封",
+ f"• 狀態:{decision_type} 等級:{severity}{confidence_text}",
+ f"• 資料品質:{data_quality} 自動執行:{'允許' if can_auto_execute else '不允許'}",
+ ]
+ if blocked_reason:
+ lines.append(f"• 邊界:{blocked_reason}")
+
+ 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 ""))
+ competitor_name = escape(_short_text(subject.get("competitor_product_name") or subject.get("pchome_name") or "", 96))
+
+ target_lines = []
+ if sku:
+ target_lines.append(f"• SKU:{sku}")
+ if name:
+ target_lines.append(f"• MOMO:{name}")
+ if competitor_id:
+ target_lines.append(f"• PChome:{competitor_id}")
+ if competitor_name:
+ target_lines.append(f"• 候選:{competitor_name}")
+ if target_lines:
+ lines += ["", "🎯 標的", *target_lines]
+
+ momo_price = (
+ subject.get("momo_price")
+ or expected.get("momo_price")
+ or (_find_evidence(envelope, "momo_price") or {}).get("value")
+ )
+ competitor_price = (
+ subject.get("competitor_price")
+ or subject.get("pchome_price")
+ or expected.get("competitor_price")
+ or expected.get("candidate_price")
+ or expected.get("pchome_price")
+ or (_find_evidence(envelope, "pchome_price") or {}).get("value")
+ )
+ gap_pct = (
+ expected.get("candidate_gap_pct")
+ if expected.get("candidate_gap_pct") is not None
+ else (_find_evidence(envelope, "candidate_gap_pct") or {}).get("value")
+ )
+ gap_amount = expected.get("gap_amount")
+
+ price_lines = []
+ momo_price_text = _format_money_value(momo_price)
+ competitor_price_text = _format_money_value(competitor_price)
+ if momo_price_text or competitor_price_text:
+ price_lines.append(
+ f"• MOMO:{momo_price_text or '—'} PChome:{competitor_price_text or '—'}"
+ )
+ gap_text = _format_percent_value(gap_pct)
+ gap_amount_text = _format_money_value(gap_amount)
+ if gap_text or gap_amount_text:
+ detail = []
+ if gap_text:
+ detail.append(f"{gap_text}")
+ if gap_amount_text:
+ detail.append(gap_amount_text)
+ price_lines.append(f"• 價差:{' / '.join(detail)}(正值代表 MOMO 較貴)")
+
+ unit_insight = expected.get("unit_price_insight")
+ if isinstance(unit_insight, dict) and unit_insight:
+ unit_summary = escape(_short_text(unit_insight.get("summary") or "", 120))
+ if unit_summary:
+ price_lines.append(f"• 單位價:{unit_summary}")
+ if price_lines:
+ lines += ["", "📊 價格證據", *price_lines]
+
+ evidence_lines = []
+ match_evidence = _find_evidence(envelope, "match_score")
+ if match_evidence:
+ score = match_evidence.get("value")
+ basis = escape(str(match_evidence.get("basis") or ""))
+ score_text = escape(str(score if score is not None else ""))
+ evidence_lines.append(f"• Match:{score_text}" + (f" {basis}" if basis else ""))
+ reason_evidence = _find_evidence(envelope, "reasons")
+ if reason_evidence:
+ evidence_lines.append(f"• 診斷:{escape(_short_text(reason_evidence.get('value') or '', 130))}")
+ conflict = expected.get("existing_match_conflict")
+ if isinstance(conflict, dict) and conflict:
+ incoming = escape(str(conflict.get("incoming_product_id") or "unknown"))
+ existing = escape(str(conflict.get("existing_product_id") or "unknown"))
+ delta_value = _numeric_value(conflict.get("score_delta"))
+ delta = f"{delta_value:+.3f}" if delta_value is not None else ""
+ evidence_lines.append(f"• 既有保護:新候選 {incoming} vs 既有 {existing}" + (f" delta {delta}" if delta else ""))
+ if evidence_lines:
+ lines += ["", "🧩 比對證據", *evidence_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))
+ lines += [
+ "",
+ "✅ 人工下一步",
+ f"• {_action_label(action_code)}",
+ f"• 動作:{escape(action_code)} 負責:{owner} HITL:{'需要' if requires_hitl else '不需要'}",
+ ]
+
+ trace = envelope.get("trace")
+ if isinstance(trace, dict):
+ trace_parts = []
+ for key in ("ai_call_id", "insight_id", "action_plan_id", "source", "attempted_at", "model", "provider"):
+ if trace.get(key) is not None:
+ trace_parts.append(f"{key}={trace[key]}")
+ if trace_parts:
+ lines += ["", f"{escape(' | '.join(trace_parts))}"]
+
+ return lines + [""]
+
+
def _format_decision_envelope(envelope: Dict[str, Any]) -> List[str]:
"""將 12 Agent 共用決策信封轉成可審核的 Telegram 區塊。"""
if not isinstance(envelope, dict) or not envelope:
return []
+ if _is_price_decision_envelope(envelope):
+ return _format_price_decision_envelope(envelope)
severity = escape(str(envelope.get("severity") or "info"))
decision_type = escape(str(envelope.get("decision_type") or "general"))
diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py
index 26a0d2b..acb7152 100644
--- a/tests/test_competitor_intel_cache.py
+++ b/tests/test_competitor_intel_cache.py
@@ -255,6 +255,8 @@ def test_rescore_accepted_review_item_has_actionable_decision_envelope():
envelope = item["decision_envelope"]
assert envelope["severity"] in {"P1", "P2"}
+ assert envelope["subject"]["momo_price"] == 99
+ assert envelope["subject"]["competitor_price"] == 89
assert envelope["recommended_action"]["action"] == "review_accept_identity"
assert envelope["guardrails"]["data_quality"] == "complete"
assert envelope["expected_impact"]["gap_amount"] == 10
diff --git a/tests/test_event_router.py b/tests/test_event_router.py
index 9c04a78..f9cf941 100644
--- a/tests/test_event_router.py
+++ b/tests/test_event_router.py
@@ -149,6 +149,8 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch):
"subject": {
"sku": "SKU-1",
"name": "測試精華液",
+ "momo_price": 120,
+ "competitor_price": 100,
"competitor_product_id": "PC-1",
"competitor_product_name": "PChome 測試精華液",
},
@@ -167,6 +169,10 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch):
"data_quality": "complete",
"blocked_reason": "價格調整需人工覆核",
},
+ "expected_impact": {
+ "gap_amount": 20,
+ "candidate_gap_pct": 20,
+ },
}
result = asyncio.run(event_router.dispatch({
@@ -184,7 +190,14 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch):
assert len(sent) == 1
message, kwargs = sent[0]
assert "🧭 決策信封" in message
+ assert "🎯 標的" in message
+ assert "📊 價格證據" in message
+ assert "🧩 比對證據" in message
+ assert "✅ 人工下一步" in message
assert "SKU:SKU-1" in message
+ assert "MOMO:NT$ 120" in message
+ assert "PChome:NT$ 100" in message
+ assert "價差:+20.0% / NT$ 20" in message
assert "PChome:PC-1" in message
assert "exact/total_price/price_alert_exact" in message
assert "legacy raw message" not in message