Files
ewoooc/services/event_router.py
ogt 0b4f80ee8a
All checks were successful
CD Pipeline / deploy (push) Successful in 1m14s
feat(ai-ops): Agent Action Ladder 骨幹(ADR-012 Phase 1)+ 週報套模板
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>
2026-04-19 12:46:51 +08:00

286 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}")
# FallbackL0 模板 + 降級標記
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 介入 stubPhase 2/3 填實作)
# =====================================================================
def _hermes_observe(event: dict) -> str | None:
"""Phase 1 stubPhase 2 會呼叫 hermes_analyst_service"""
# 不呼叫 AI回傳 None 讓上層降級
return None
def _nemoton_investigate(event: dict) -> dict | None:
"""Phase 1 stubPhase 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_idfallback 到 .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}")