Files
ewoooc/services/event_router.py

303 lines
13 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.
import json
import logging
import os
import time
from typing import Any, Dict, Optional
import requests
from services.ai_orchestrator import AIOrchestrator
from services.auto_heal_service import auto_heal_service
from services.logger_manager import SystemLogger
from services.nemoton_dispatcher_service import NemotronDispatcher
from services.openclaw_strategist_service import generate_weekly_strategy_report
from services.telegram_templates import alert, report, success, warning, info as tpl_info
sys_log = SystemLogger("EventRouter").get_logger()
# ─── 環境 ────────────────────────────────────────────────────
HERMES_URL = os.getenv("HERMES_URL", "http://192.168.0.111:11434")
HERMES_MODEL = os.getenv("HERMES_MODEL", "hermes3:latest")
HERMES_TIMEOUT = int(os.getenv("HERMES_TIMEOUT", "180")) or None
HERMES_KEEP_ALIVE = os.getenv("HERMES_KEEP_ALIVE", "24h")
NEMOTRON_URL = os.getenv("NEMOTRON_URL", "http://192.168.0.111:1144")
NEMOTRON_TIMEOUT = int(os.getenv("NEMOTRON_TIMEOUT", "60"))
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_IDS_RAW = os.getenv("TELEGRAM_CHAT_IDS", "[]")
try:
TELEGRAM_CHAT_IDS = json.loads(TELEGRAM_CHAT_IDS_RAW)
except json.JSONDecodeError:
TELEGRAM_CHAT_IDS = []
SILENCE_DURATION_MIN = int(os.getenv("SILENCE_DURATION_MIN", "30"))
# ─── 分類規則(與 watcher_agent.py 保持一致) ────────────────────
def _classify(event: Dict[str, Any]) -> str:
sev = event.get("severity", "info")
has_trace = bool(event.get("trace"))
event_type = event.get("event_type", "")
if sev in ("info", "success"):
return "L0"
if sev == "warning":
return "L1" if has_trace else "L0"
if sev == "alert":
if event_type in {"price_threat", "db_connection_error", "crawler_timeout",
"nim_quota_exhausted", "embedding_failure"}:
return "L2"
return "L1"
return "L0"
# ─── 主入口 ───────────────────────────────────────────────────
def dispatch(event: Dict[str, Any], admin_chat_ids: Optional[list] = None) -> Dict[str, Any]:
"""
輸入 event 格式(與 watcher payload 對齊):
{
"source": "watcher",
"event_type": "sales_anomaly",
"severity": "alert",
"title": "...",
"summary": "...",
"payload": {...},
"trace": "...", # 可選
"suggested_actions": [...]
}
回傳:
{"tier": "L0|L1|L2|L3", "sent": int, "errors": [...], "latency_ms": float}
"""
t0 = time.time()
tier = _classify(event)
sys_log.info(f"[EventRouter] route {event.get('event_type')}{tier}")
errors = []
sent = 0
try:
if tier == "L0":
text = _render_l0(event)
elif tier == "L1":
text = _render_l1(event)
elif tier == "L2":
text = _render_l2(event)
else:
text = _render_l0(event)
sent = _send_telegram(text, admin_chat_ids)
except Exception as e:
sys_log.error(f"[EventRouter] 渲染失敗,降級 L0: {e}")
text = _render_l0(event) + "\n\n🟡 <i>AI 分析暫不可用,以原始資料呈現</i>"
try:
sent = _send_telegram(text, admin_chat_ids)
except Exception:
sent = 0
errors.append("L0 fallback send failed")
latency = (time.time() - t0) * 1000
sys_log.info(f"[EventRouter] dispatched tier={tier} sent={sent} errors={len(errors)} latency={latency:.0f}ms")
return {"tier": tier, "sent": sent, "errors": errors, "latency_ms": latency}
# ─── L0 直出 ─────────────────────────────────────────────────
def _render_l0(event: Dict[str, Any]) -> str:
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 == "success":
return success(title=title, module=module, stats=summary)
if sev == "info":
return info(title=title, module=module, content=summary)
if sev == "warning":
return warning(title=title, module=module, summary=summary, details=details)
return alert(
title=title, module=module,
status=event.get("status", "未知"),
impact=event.get("impact", "未評估"),
summary=summary,
actions=event.get("suggested_actions"),
trace=event.get("trace"),
)
# ─── L1Hermes 翻譯 ────────────────────────────────────────
def _render_l1(event: Dict[str, Any]) -> str:
try:
parsed = _hermes_observe_parsed(event)
if parsed and parsed.get("summary"):
return report.triaged_alert(
base_event=_event_base(event),
tier_label="L1 · Hermes",
ai_summary=parsed.get("summary", ""),
ai_cause=parsed.get("probable_cause"),
ai_actions=parsed.get("actions") or [],
)
except Exception as e:
sys_log.warning(f"[EventRouter] L1 Hermes 失敗,降 L0: {e}")
return _render_l0(event) + "\n\n🟡 <i>AI 分析暫不可用,以原始資料呈現</i>"
# ─── L2NemoTron 規劃 + 審核閘 ─────────────────────────────
def _render_l2(event: Dict[str, Any]) -> str:
try:
ai_result = _nemoton_investigate(event)
if ai_result:
parsed = _hermes_observe_parsed(event) # 補齊摘要
return report.triaged_alert(
base_event=_event_base(event),
tier_label="L2 · NemoTron",
ai_summary=(parsed or {}).get("summary", "") or ai_result.get("summary", ""),
ai_cause=(parsed or {}).get("probable_cause"),
ai_actions=(parsed or {}).get("actions", []),
ai_executed=ai_result.get("actions_taken", []),
)
except Exception as e:
sys_log.warning(f"[EventRouter] L2 NemoTron 失敗,降 L1: {e}")
return _render_l1(event)
# ─── L3OpenClaw 策略師(週報/分析) ───────────────────────
def _render_l3(event: Dict[str, Any]) -> str:
"""週報或 Meta-Analysis 類型交由 OpenClaw"""
# 範例:週日週報
if event.get("event_type") == "weekly_meta":
return generate_weekly_strategy_report()
return _render_l2(event)
# ─── Hermes ObserverOllama ────────────────────────────────
_HERMES_OBSERVE_PROMPT = """你是一個 SRE 助手,將技術錯誤翻譯成人類可理解的摘要。
請根據以下事件產出**繁體中文**分析,嚴格以下列 JSON 格式輸出(不要加 markdown 代碼塊、不要加說明):
{"summary": "一句話技術根因(中文,<60 字)", "probable_cause": "最可能的原因(中文,<40 字)", "actions": ["建議動作1", "建議動作2"]}
限制:
- summary 翻譯英文錯誤為中文,去除技術 jargon
- probable_cause 推測根因(基於 stack trace 和事件類型)
- actions 最多 3 個,具體可執行
- 若資訊不足summary 填 "資訊不足"、actions 填 ["請檢查原始 trace"]
"""
def _hermes_observe_parsed(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""呼叫 Ollamahermes3翻譯 stack trace回傳結構化 dict"""
try:
user_prompt = (
f"事件類型:{event.get('event_type', 'unknown')}\n"
f"來源模組:{event.get('source', 'unknown')}\n"
f"標題:{event.get('title', '')}\n"
f"簡述:{event.get('summary', '')}\n"
f"技術 trace\n{(event.get('trace') or '')[-800:]}"
)
resp = requests.post(
f"{HERMES_URL}/api/generate",
json={
"model": HERMES_MODEL,
"system": _HERMES_OBSERVE_PROMPT,
"prompt": user_prompt,
"stream": False,
"keep_alive": HERMES_KEEP_ALIVE,
"options": {"temperature": 0.1, "num_predict": 300},
},
timeout=HERMES_TIMEOUT,
)
if not resp.ok:
sys_log.warning(f"[EventRouter.L1] Hermes HTTP {resp.status_code}")
return None
raw = (resp.json().get("response") or "").strip()
parsed = json.loads(raw) if raw.startswith("{") else None
if not parsed or not parsed.get("summary"):
return None
return {
"summary": str(parsed.get("summary", "")).strip(),
"probable_cause": str(parsed.get("probable_cause") or "").strip() or None,
"actions": [str(a).strip() for a in (parsed.get("actions") or []) if a][:5],
}
except Exception as e:
sys_log.warning(f"[EventRouter.L1] Hermes 呼叫失敗,降級:{type(e).__name__}: {str(e)[:120]}")
return None
# ─── NemoTron Investigator規則式 L2不呼叫 NIM ────────────
_L2_RULES: dict[str, list] = {
"db_connection_error": [
("query_km", {"query": "DNS resolve 失敗 momo-postgres"}),
("retry_task", {"task_name": "<auto>", "backoff_sec": 60}),
],
"crawler_timeout": [
("silence_alert", {"duration_min": SILENCE_DURATION_MIN}),
("retry_task", {"task_name": "<auto>", "backoff_sec": 300}),
],
"nim_quota_exhausted": [
("silence_alert", {"duration_min": 720}),
],
"embedding_failure": [
("silence_alert", {"duration_min": 10}),
],
}
def _nemoton_investigate(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""規則式 L2依 event_type 查 _L2_RULES執行安全 actions"""
event_type = event.get("event_type", "")
rules = _L2_RULES.get(event_type)
if not rules:
return None
actions_taken = []
for action_name, params in rules:
action_fn = getattr(agent_actions.SAFE_ACTIONS.get(action_name), None)
if not action_fn:
continue
p = dict(params)
if p.get("task_name") == "<auto>":
p["task_name"] = event.get("payload", {}).get("task_name", "") or event.get("source", "").split(".")[-1]
if action_name == "silence_alert" and "event_key" not in p:
p["event_key"] = f"{event.get('source', '?')}:{event_type}"
try:
result = action_fn(**p)
status = result.get("status", "unknown")
actions_taken.append(f"{action_name}{status}")
except Exception as e:
actions_taken.append(f"{action_name} → error: {str(e)[:80]}")
sys_log.error(f"[EventRouter.L2] action {action_name} 例外: {e}")
summary = f"依規則 _L2_RULES[{event_type}] 執行 {len(actions_taken)} 個安全動作"
return {"summary": summary, "actions_taken": actions_taken}
# ─── 工具:構建 event 基礎 ─────────────────────────────────────
def _event_base(event: Dict[str, Any]) -> Dict[str, Any]:
return {
"severity": event.get("severity", "warning"),
"title": event.get("title", "未命名事件"),
"module": event.get("source", "unknown"),
"status": event.get("status"),
"impact": event.get("impact"),
"summary": event.get("summary", ""),
"details": event.get("payload"),
"trace": event.get("trace"),
}
# ─── 工具Telegram 發送 ───────────────────────────────────────
def _send_telegram(text: str, admin_chat_ids: Optional[list] = None) -> int:
if not TELEGRAM_BOT_TOKEN:
sys_log.warning("[EventRouter] TELEGRAM_BOT_TOKEN 未設定")
return 0
if admin_chat_ids is None:
admin_chat_ids = TELEGRAM_CHAT_IDS
if not admin_chat_ids:
admin_chat_ids = [-1003940688311] # fallback
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
sent = 0
for cid in admin_chat_ids:
try:
r = requests.post(url, json={
"chat_id": int(cid),
"text": text,
"parse_mode": "HTML",
}, timeout=10)
if r.ok:
sent += 1
except Exception as e:
sys_log.error(f"[EventRouter] Telegram 發送失敗: {e}")
return sent