diff --git a/config.py b/config.py
index 1d2170d..52e04cd 100644
--- a/config.py
+++ b/config.py
@@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.422"
+SYSTEM_VERSION = "V10.423"
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 a19c71c..2e35454 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.422
+> **適用版本**: V10.423
---
@@ -34,6 +34,15 @@
- 111 的 LAN 入口必須經 `scripts/ops/ollama111_allow_proxy.py` allowlist proxy:真實 Ollama 綁 `127.0.0.1:11434`,proxy 綁 `192.168.0.111:11434`,預設只允許 111 本機與 188 生產宿主;110 / 121 / 其他 LAN client 不能直接打 111,避免跨專案 CI 或 VM 繞過 momo-pro router 載入 7B+ runner。111 上以 `scripts/ops/install_ollama111_allow_proxy.sh` 安裝 user LaunchAgent,讓 proxy 與 `OLLAMA_HOST=127.0.0.1:11434` 在登入/重啟後自動恢復。
- ElephantAlpha 的 `price_drop_alert` / `market_opportunity` Telegram HITL 告警必須把同款證據獨立呈現,至少包含 `match_type`、`price_basis`、`alert_tier` 與 `match_score`;沒有高信心同款與總價可比證據時,不得把 PChome/MOMO 價差寫成可直接跟價建議。
+## 零之一、12 Agent 決策信封(2026-05-24)
+
+- 12 角色分工不作為 12 個常駐模型;在產品層統一收斂成 `decision_envelope`,由 Hermes / NemoTron / OpenClaw / ElephantAlpha 與人工審核、PPT QA、競品 review queue 共用。
+- `decision_envelope` 必須至少能表達:`decision_type`、`severity`、`evidence[]`、`recommended_action`、`expected_impact`、`confidence`、`guardrails`、`trace`。
+- `guardrails.can_auto_execute=false` 是預設;價格調整、正式比價覆寫、PPT 發送與修復執行都必須遵守 HITL 或既有 service guard,不得因 Agent 信心高就繞過 matcher / feeder / review service。
+- 證據不足時不得輸出空泛效益預測;必須標記 `data_quality=missing|partial|stale`,並把建議行動降級成 `human_review`、`needs_research` 或 `silence_alert`。
+- Telegram `triaged_alert()` 已支援渲染 `decision_envelope`,讓告警固定呈現嚴重度、證據、建議行動、預期影響、信心度與追蹤 ID;後續觀測台與 PPT 也應共用同一份欄位語意。
+- 競品比價相關的 Agent 建議只能讀 `competitor_match_attempts` / review queue / `competitor_prices` 的既有證據;不得直接寫 `competitor_prices` 或覆蓋 `_should_upsert_competitor_price()` 的保護規則。
+
## 一、四 AI Agent 路由架構
```
diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md
index 8dcfc7f..70f8315 100644
--- a/docs/memory/history_logs.md
+++ b/docs/memory/history_logs.md
@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-24:PChome 近門檻身份回收第二輪
+- **V10.423 12 Agent 決策信封**: `triaged_alert()` 支援 `decision_envelope` 結構化區塊,讓 Hermes / NemoTron / OpenClaw / ElephantAlpha 與後續 12 角色決策統一輸出 `severity`、`evidence`、`recommended_action`、`expected_impact`、`confidence`、`guardrails` 與 `trace`;缺證據時必須明確標記資料品質與 HITL 邊界,避免再出現空泛效益預測或不可追溯告警。
- **V10.422 111 proxy LaunchAgent 持久化**: 新增 `scripts/ops/install_ollama111_allow_proxy.sh`,在 111 以 user LaunchAgent 安裝 `com.momo.ollama111-allow-proxy`,啟動時設定 `OLLAMA_HOST=127.0.0.1:11434`、重啟 Ollama、載入 allowlist proxy,避免重開機或重新登入後 111 又回到 LAN 全開狀態。
- **V10.421 Kanebo Milano / hoi 蠟燭品類防錯配**: marketplace matcher 追加 `kanebo_milano_type_conflict` 與 `hoi_candle_line_conflict`,將 Kanebo Milano Collection 蜜粉餅 vs 絕色香水、hoi 日京山風香氛蠟燭 vs hoi!LAB 實驗室香氛蠟燭經典篇列為 hard veto;同品牌、同系列字樣或同容量仍不可跨品類/跨產品線直接比價。
- **V10.420 111 Ollama LAN allowlist proxy**: 追查 111 高負載時確認來源不是 momo-pro,而是 110 上 `awoooi-cd` 臨時測試與 121 VMware VM 直接打 `192.168.0.111:11434`,繞過 `ai_calls` 與 momo-pro router 載入 7B runner。新增 `scripts/ops/ollama111_allow_proxy.py`,將真實 Ollama 收斂到 `127.0.0.1:11434`,由 user-space proxy 綁 `192.168.0.111:11434` 並預設只允許 111 本機與 188 生產宿主;110 / 121 會被 reset,111 fallback 保留給 momo production。
diff --git a/services/telegram_templates.py b/services/telegram_templates.py
index b76cd91..7783a00 100644
--- a/services/telegram_templates.py
+++ b/services/telegram_templates.py
@@ -696,6 +696,90 @@ def _format_ea_escalation_alert(
return "\n".join(lines)
+
+def _format_decision_envelope(envelope: Dict[str, Any]) -> List[str]:
+ """將 12 Agent 共用決策信封轉成可審核的 Telegram 區塊。"""
+ if not isinstance(envelope, dict) or not envelope:
+ return []
+
+ severity = escape(str(envelope.get("severity") or "info"))
+ decision_type = escape(str(envelope.get("decision_type") or "general"))
+ confidence = envelope.get("confidence")
+ guardrails = envelope.get("guardrails") if isinstance(envelope.get("guardrails"), 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 = ""
+ try:
+ if confidence is not None:
+ confidence_text = f" 信心度:{float(confidence):.0%}"
+ except (TypeError, ValueError):
+ confidence_text = ""
+
+ lines = [
+ "🧭 決策信封",
+ f"• 類型:{decision_type} 嚴重度:{severity}{confidence_text}",
+ f"• 資料品質:{data_quality} 自動執行:{'允許' if can_auto_execute else '不允許'}",
+ ]
+ if blocked_reason:
+ lines.append(f"• 邊界:{blocked_reason}")
+
+ evidence_items = envelope.get("evidence") if isinstance(envelope.get("evidence"), list) else []
+ if evidence_items:
+ lines += ["", "證據"]
+ for item in evidence_items[:3]:
+ if not isinstance(item, dict):
+ lines.append(f"• {escape(str(item))[:180]}")
+ continue
+ metric = escape(str(item.get("metric") or item.get("type") or "evidence"))
+ value = escape(str(item.get("value") if item.get("value") is not None else ""))
+ basis = escape(str(item.get("basis") or ""))
+ freshness = escape(str(item.get("freshness") or ""))
+ item_confidence = item.get("confidence")
+ confidence_suffix = ""
+ try:
+ if item_confidence is not None:
+ confidence_suffix = f" / {float(item_confidence):.0%}"
+ except (TypeError, ValueError):
+ confidence_suffix = ""
+ detail = " / ".join(part for part in (value, basis, freshness) if part)
+ lines.append(f"• {metric}{confidence_suffix}" + (f":{detail}" if detail else ""))
+
+ recommended_action = envelope.get("recommended_action")
+ if isinstance(recommended_action, dict):
+ action = escape(str(recommended_action.get("action") or "human_review"))
+ owner = escape(str(recommended_action.get("owner") or "未指定"))
+ deadline = escape(str(recommended_action.get("deadline") or ""))
+ requires_hitl = bool(recommended_action.get("requires_hitl", True))
+ lines += [
+ "",
+ "建議行動",
+ f"• 動作:{action} 負責:{owner}",
+ f"• HITL:{'需要' if requires_hitl else '不需要'}" + (f" 期限:{deadline}" if deadline else ""),
+ ]
+
+ expected_impact = envelope.get("expected_impact")
+ if isinstance(expected_impact, dict) and expected_impact:
+ impact_parts = []
+ for key in ("revenue_loss_7d", "gap_amount", "cost_usd", "risk_reduction"):
+ if key in expected_impact and expected_impact[key] is not None:
+ impact_parts.append(f"{escape(key)}={escape(str(expected_impact[key]))}")
+ if impact_parts:
+ lines += ["", "預期影響", "• " + " / ".join(impact_parts[:4])]
+
+ trace = envelope.get("trace")
+ if isinstance(trace, dict):
+ trace_parts = []
+ for key in ("ai_call_id", "insight_id", "action_plan_id", "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 triaged_alert(base_event: Dict[str, Any], tier_label: str,
ai_summary: str, ai_cause: Optional[str] = None,
ai_actions: Optional[list] = None,
@@ -730,6 +814,9 @@ def triaged_alert(base_event: Dict[str, Any], tier_label: str,
lines += [f"🧠 AI 摘要:{safe_ai_summary[:400]}", ""]
if safe_ai_cause:
lines += [f"💡 可能原因:{safe_ai_cause}", ""]
+ decision_envelope = base_event.get("decision_envelope") or base_event.get("decision")
+ if isinstance(decision_envelope, dict):
+ lines += _format_decision_envelope(decision_envelope)
if safe_actions:
lines += ["📋 建議行動:"] + [f" • {a}" for a in safe_actions] + [""]
if safe_executed:
diff --git a/tests/test_telegram_triaged_alert_format.py b/tests/test_telegram_triaged_alert_format.py
index dc55eca..9fac9aa 100644
--- a/tests/test_telegram_triaged_alert_format.py
+++ b/tests/test_telegram_triaged_alert_format.py
@@ -124,3 +124,73 @@ def test_ea_escalation_generic_actions_do_not_render_as_sku_cards():
assert "📋 TOP 待審 SKU" not in msg
assert "• 待審 SKU" not in msg
assert "未取得實證前,不執行自動調價、修復或策略派發" in msg
+
+
+def test_triaged_alert_renders_decision_envelope_contract():
+ msg, keyboard = triaged_alert(
+ base_event={
+ "event_type": "price_alert",
+ "title": "MOMO / PChome 價格威脅",
+ "summary": "高信心同款且 PChome 低價。",
+ "id": "decision_env_001",
+ "decision_envelope": {
+ "decision_type": "price_alert",
+ "severity": "P1",
+ "confidence": 0.86,
+ "evidence": [
+ {
+ "type": "match",
+ "metric": "match_score",
+ "value": 0.91,
+ "basis": "identity_v2 + price_alert_exact",
+ "freshness": "2026-05-24T10:00:00+08:00",
+ "confidence": 0.91,
+ },
+ {
+ "type": "price",
+ "metric": "gap_pct",
+ "value": "18.4%",
+ "basis": "latest price_records",
+ },
+ ],
+ "recommended_action": {
+ "action": "human_review",
+ "owner": "ops",
+ "deadline": "2026-05-24T18:00:00+08:00",
+ "requires_hitl": True,
+ },
+ "expected_impact": {
+ "revenue_loss_7d": 42000,
+ "gap_amount": 120,
+ "risk_reduction": "high",
+ },
+ "guardrails": {
+ "can_auto_execute": False,
+ "blocked_reason": "price adjustment requires HITL",
+ "data_quality": "complete",
+ },
+ "trace": {
+ "ai_call_id": 123,
+ "action_plan_id": 456,
+ "model": "qwen3:14b",
+ "provider": "ollama_gcp_a",
+ },
+ },
+ },
+ tier_label="Hermes · P1",
+ ai_summary="建議進人工價格審核。",
+ )
+
+ assert "🧭 決策信封" in msg
+ assert "類型:price_alert" in msg
+ assert "嚴重度:P1" in msg
+ assert "信心度:86%" in msg
+ assert "資料品質:complete" in msg
+ assert "自動執行:不允許" in msg
+ assert "邊界:price adjustment requires HITL" in msg
+ assert "match_score / 91%" in msg
+ assert "identity_v2 + price_alert_exact" in msg
+ assert "動作:human_review" in msg
+ assert "revenue_loss_7d=42000" in msg
+ assert "ai_call_id=123" in msg
+ assert keyboard["inline_keyboard"][0][0]["callback_data"] == "momo:eig:decision_env_001"