All checks were successful
CD Pipeline / deploy (push) Successful in 1m14s
ADR-012 核心設計: - 4 級信任邊界:L0 直出 / L1 Hermes 觀察 / L2 NemoTron 診斷執行 / L3 OpenClaw HITL - 通知鏈絕不中斷:每級失敗立即降級,保底 L0 模板 + 🟡 標記 - Audit Trail:每次 dispatch 自動寫 ai_insights (insight_type=agent_action) - 安全白名單:L2 可呼叫 6 個安全 action(retry/query_km/silence + 3 個既有 NemoTron tool) 新增檔案: - services/event_router.py — 事件分流入口,按 severity × event_type 分 Tier - services/agent_actions.py — 安全 action 白名單(Phase 1 stub + 完整介面) - docs/adr/ADR-012-agent-action-ladder.md — 完整設計 + 分階段計畫 Phase 1 狀態: - L0 直出完整可用 ✅ - L1 Hermes / L2 NemoTron 為 stub(Phase 2/3 填實作) - Fallback 降級鏈已完整 ✅ - 靜音檢查(is_silenced)+ Audit Trail 已就緒 ✅ 處理既有 TODO: - services/openclaw_strategist_service.py::_notify_telegram_group() 改用 telegram_templates.report() 統一週報格式 全景盤點(新 memory): - reference_telegram_endpoints_map.md — 21 個 Telegram 發送點 - feedback_agent_action_ladder.md — 操作規範 (+ 既有 ADR-011 跨專案隔離規範一併生效) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""
|
||
EventRouter — 事件分流入口(ADR-012 Phase 1 骨幹)
|
||
|
||
所有系統事件(exception / 排程完成 / 告警 / 資訊通報)**應**統一透過
|
||
`dispatch(event)` 進入,由 EventRouter 依 severity × event_type 分流到:
|
||
L0 Direct / L1 Hermes Observer / L2 NemoTron Investigator / L3 OpenClaw Operator
|
||
|
||
設計原則(ADR-012 §⑥):無論 AI 狀況,**通知鏈絕不中斷**。
|
||
每一級失敗立即降級到下一級,最終保底 L0 直出模板。
|
||
|
||
Phase 1 實作範圍:
|
||
- 骨幹 + 分類邏輯
|
||
- L0 模板直出(已可用)
|
||
- L1 Hermes / L2 NemoTron / L3 OpenClaw 為 stub(附 TODO 標記)
|
||
- 完整 fallback 鏈(AI 掛必降級)
|
||
- 靜音檢查 + Audit Trail
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import time
|
||
from datetime import datetime
|
||
from enum import Enum
|
||
from typing import Any
|
||
|
||
import requests
|
||
|
||
from services.logger_manager import SystemLogger
|
||
from services import telegram_templates as tpl
|
||
from services import agent_actions
|
||
|
||
sys_log = SystemLogger("EventRouter").get_logger()
|
||
|
||
|
||
class Tier(str, Enum):
|
||
L0_DIRECT = "L0"
|
||
L1_OBSERVER = "L1"
|
||
L2_INVESTIGATOR = "L2"
|
||
L3_OPERATOR = "L3"
|
||
|
||
|
||
class Severity(str, Enum):
|
||
INFO = "info"
|
||
SUCCESS = "success"
|
||
WARNING = "warning"
|
||
ALERT = "alert" # P0/P1
|
||
|
||
|
||
# =====================================================================
|
||
# 分類規則(ADR-012 §③)
|
||
# =====================================================================
|
||
def _classify(event: dict) -> Tier:
|
||
sev = event.get("severity", "info")
|
||
has_trace = bool(event.get("trace"))
|
||
event_type = event.get("event_type", "")
|
||
|
||
# L3 OpenClaw 由週期任務主動觸發(週報、Meta-Analysis),不走 router
|
||
# 這裡只處理 L0/L1/L2
|
||
|
||
if sev in (Severity.INFO, Severity.SUCCESS):
|
||
return Tier.L0_DIRECT
|
||
|
||
if sev == Severity.WARNING:
|
||
# 有技術 trace → L1 Hermes 翻譯
|
||
return Tier.L1_OBSERVER if has_trace else Tier.L0_DIRECT
|
||
|
||
if sev == Severity.ALERT:
|
||
# 符合 L2 白名單 event_type → NemoTron 介入
|
||
L2_EVENT_TYPES = {
|
||
"price_threat", "db_connection_error", "crawler_timeout",
|
||
"nim_quota_exhausted", "embedding_failure",
|
||
}
|
||
if event_type in L2_EVENT_TYPES:
|
||
return Tier.L2_INVESTIGATOR
|
||
return Tier.L1_OBSERVER
|
||
|
||
return Tier.L0_DIRECT
|
||
|
||
|
||
# =====================================================================
|
||
# 主入口
|
||
# =====================================================================
|
||
def dispatch(event: dict, admin_chat_ids: list[int] | None = None) -> dict:
|
||
"""
|
||
主要入口。回傳 dict: {tier, sent, insight_id, errors, latency_ms}
|
||
|
||
event 格式見 ADR-012 §③。必要欄位:source, event_type, severity, title, summary
|
||
可選欄位:trace, payload, time
|
||
|
||
admin_chat_ids 若未給,從 DB telegram_users where is_admin=true 取
|
||
"""
|
||
t0 = time.time()
|
||
event_key = f"{event.get('source', '?')}:{event.get('event_type', '?')}"
|
||
|
||
# 靜音檢查
|
||
if agent_actions.is_silenced(event_key):
|
||
sys_log.info(f"[EventRouter] 事件被靜音略過: {event_key}")
|
||
return {"tier": "silenced", "sent": 0, "event_key": event_key}
|
||
|
||
tier = _classify(event)
|
||
sys_log.info(f"[EventRouter] dispatch {event_key} → {tier.value}")
|
||
|
||
# 執行對應 Tier
|
||
try:
|
||
if tier == Tier.L0_DIRECT:
|
||
text = _render_l0(event)
|
||
elif tier == Tier.L1_OBSERVER:
|
||
text = _render_l1_with_fallback(event)
|
||
elif tier == Tier.L2_INVESTIGATOR:
|
||
text = _render_l2_with_fallback(event)
|
||
else:
|
||
text = _render_l0(event) # 未知 Tier 保底
|
||
except Exception as e:
|
||
sys_log.error(f"[EventRouter] 渲染失敗,降級 L0: {e}")
|
||
text = _render_l0(event)
|
||
|
||
# 發送 Telegram
|
||
result = _send(text, admin_chat_ids)
|
||
result["tier"] = tier.value
|
||
result["event_key"] = event_key
|
||
result["latency_ms"] = round((time.time() - t0) * 1000, 1)
|
||
|
||
# 審計(每次 dispatch 都入 KM)
|
||
_audit_dispatch(event, tier, result)
|
||
return result
|
||
|
||
|
||
# =====================================================================
|
||
# Tier 渲染器
|
||
# =====================================================================
|
||
def _render_l0(event: dict) -> str:
|
||
"""L0 直出:根據 severity 選用對應模板"""
|
||
sev = event.get("severity", "info")
|
||
title = event.get("title", "未命名事件")
|
||
module = event.get("source", "unknown")
|
||
summary = event.get("summary", "")
|
||
details = event.get("payload") if isinstance(event.get("payload"), dict) else None
|
||
|
||
if sev == Severity.SUCCESS:
|
||
return tpl.success(title=title, module=module, stats=summary)
|
||
if sev == Severity.INFO:
|
||
return tpl.info(title=title, module=module, content=summary)
|
||
if sev == Severity.WARNING:
|
||
return tpl.warning(title=title, module=module, summary=summary, details=details)
|
||
# alert 但降級到 L0
|
||
return tpl.alert(
|
||
title=title, module=module,
|
||
status=event.get("status", "未知"),
|
||
impact=event.get("impact", "未評估"),
|
||
summary=summary,
|
||
actions=event.get("suggested_actions"),
|
||
trace=event.get("trace"),
|
||
)
|
||
|
||
|
||
def _render_l1_with_fallback(event: dict) -> str:
|
||
"""L1 Hermes 翻譯 stack trace。Phase 1 stub — 直接降 L0 + 標記"""
|
||
# TODO Phase 2: 呼叫 Hermes 做 stack trace 翻譯與摘要
|
||
try:
|
||
ai_summary = _hermes_observe(event) # stub
|
||
if ai_summary:
|
||
return _compose_triaged(event, tier_label="L1 · Hermes", ai_summary=ai_summary)
|
||
except Exception as e:
|
||
sys_log.warning(f"[EventRouter] L1 Hermes 失敗,降 L0: {e}")
|
||
|
||
# Fallback:L0 模板 + 降級標記
|
||
text = _render_l0(event)
|
||
return text + "\n\n🟡 _AI 分析暫不可用,以原始資料呈現_"
|
||
|
||
|
||
def _render_l2_with_fallback(event: dict) -> str:
|
||
"""L2 NemoTron 介入(含 tool call)。Phase 1 stub — 降 L1"""
|
||
# TODO Phase 3: 呼叫 NemoTron dispatcher,允許執行 SAFE_ACTIONS 中的 tool
|
||
try:
|
||
ai_result = _nemoton_investigate(event) # stub
|
||
if ai_result:
|
||
return _compose_triaged(
|
||
event, tier_label="L2 · NemoTron",
|
||
ai_summary=ai_result.get("summary", ""),
|
||
ai_actions=ai_result.get("actions_taken", []),
|
||
)
|
||
except Exception as e:
|
||
sys_log.warning(f"[EventRouter] L2 NemoTron 失敗,降 L1: {e}")
|
||
|
||
return _render_l1_with_fallback(event)
|
||
|
||
|
||
def _compose_triaged(event: dict, tier_label: str, ai_summary: str,
|
||
ai_actions: list | None = None) -> str:
|
||
"""三層式訊息:AI 摘要 + 原始事實 + 建議行動(ADR-012 §④)"""
|
||
base = _render_l0(event)
|
||
parts = [f"🤖 *AI 摘要({tier_label}):*", ai_summary, ""]
|
||
if ai_actions:
|
||
parts.append("🛠️ *AI 已執行動作:*")
|
||
for a in ai_actions:
|
||
parts.append(f"• {a}")
|
||
parts.append("")
|
||
return base + "\n\n" + "\n".join(parts)
|
||
|
||
|
||
# =====================================================================
|
||
# AI 介入 stub(Phase 2/3 填實作)
|
||
# =====================================================================
|
||
def _hermes_observe(event: dict) -> str | None:
|
||
"""Phase 1 stub:Phase 2 會呼叫 hermes_analyst_service"""
|
||
# 不呼叫 AI,回傳 None 讓上層降級
|
||
return None
|
||
|
||
|
||
def _nemoton_investigate(event: dict) -> dict | None:
|
||
"""Phase 1 stub:Phase 3 會呼叫 nemoton_dispatcher_service"""
|
||
return None
|
||
|
||
|
||
# =====================================================================
|
||
# Telegram 發送 + Audit
|
||
# =====================================================================
|
||
def _send(text: str, admin_chat_ids: list[int] | None) -> dict:
|
||
token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||
if not token:
|
||
return {"sent": 0, "errors": ["TELEGRAM_BOT_TOKEN 未設定"]}
|
||
|
||
if admin_chat_ids is None:
|
||
admin_chat_ids = _load_admin_chat_ids()
|
||
if not admin_chat_ids:
|
||
return {"sent": 0, "errors": ["無管理員 chat_id"]}
|
||
|
||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||
sent = 0
|
||
errors = []
|
||
for cid in admin_chat_ids:
|
||
try:
|
||
r = requests.post(url, json={
|
||
"chat_id": int(cid), "text": text, "parse_mode": "Markdown",
|
||
}, timeout=10)
|
||
if r.ok:
|
||
sent += 1
|
||
else:
|
||
errors.append(f"chat={cid} status={r.status_code}")
|
||
except Exception as e:
|
||
errors.append(f"chat={cid} exc={e}")
|
||
return {"sent": sent, "errors": errors}
|
||
|
||
|
||
def _load_admin_chat_ids() -> list[int]:
|
||
"""從 DB 撈 is_admin=true 的 chat_id;fallback 到 .env TELEGRAM_CHAT_IDS"""
|
||
try:
|
||
from database.manager import get_session
|
||
from sqlalchemy import text as sa_text
|
||
session = get_session()
|
||
try:
|
||
rows = session.execute(sa_text(
|
||
"SELECT telegram_id FROM telegram_users WHERE is_active=true AND is_admin=true"
|
||
)).fetchall()
|
||
if rows:
|
||
return [int(r[0]) for r in rows]
|
||
finally:
|
||
session.close()
|
||
except Exception as e:
|
||
sys_log.warning(f"[EventRouter] 查 telegram_users 失敗,fallback .env: {e}")
|
||
|
||
# Fallback 到 env
|
||
import re
|
||
raw = os.getenv("TELEGRAM_CHAT_IDS", "")
|
||
return [int(x.strip()) for x in re.sub(r'[\[\]"\' ]', "", raw).split(",") if x.strip()]
|
||
|
||
|
||
def _audit_dispatch(event: dict, tier: Tier, result: dict) -> None:
|
||
"""每次 dispatch 都寫入 ai_insights 作為審計軌跡"""
|
||
try:
|
||
from services.openclaw_learning_service import store_insight
|
||
store_insight(
|
||
insight_type="agent_action",
|
||
content=f"dispatch tier={tier.value} event={event.get('event_type')}",
|
||
period=datetime.now().strftime("%Y-%m-%d"),
|
||
metadata={
|
||
"tier": tier.value,
|
||
"event": {k: event.get(k) for k in ("source", "event_type", "severity", "title")},
|
||
"result": {k: result.get(k) for k in ("sent", "latency_ms", "errors")},
|
||
"ts": datetime.now().isoformat(),
|
||
},
|
||
)
|
||
except Exception as e:
|
||
sys_log.error(f"[EventRouter] audit 失敗: {e}")
|