feat(ai-ops): ADR-012 Phase 2/3/4 完整實作
All checks were successful
CD Pipeline / deploy (push) Successful in 1m11s

Phase 2 — Hermes L1 Observer 真實接入:
- services/event_router.py::_hermes_observe() 呼叫 hermes3:latest
  @192.168.0.111:11434/api/generate,做 stack trace 翻譯
- 輸出 JSON {summary, probable_cause, actions},容錯 markdown fence
- scheduler.py run_auto_import_task / run_momo_task 兩個 outer
  except 改走 event_router.dispatch(),帶完整 trace

Phase 3 — NemoTron L2 Investigator 規則式實作:
- event_router._L2_RULES: event_type → [(action, params)] 規則表
  • db_connection_error → query_km + retry_task(60s backoff)
  • crawler_timeout    → silence_alert(30min) + retry_task(300s)
  • nim_quota_exhausted → silence_alert(720min)
  • embedding_failure   → silence_alert(10min)
- agent_actions.retry_task 真實實作: threading.Timer + exponential
  backoff (60→120→240s) + _retry_state 追蹤 + ALLOWED_RETRY_TASKS
  白名單 + 非 scheduler 容器回 'deferred'

Phase 4 — L3 HITL Ops 擴充:
- agent_actions: pause_task / resume_task / force_retry_now / is_task_paused
- OPS_ACTIONS 白名單與 SAFE_ACTIONS 嚴格分離(L2 不可呼叫 L3)
- telegram_templates.ops_action_request(): 4 按鈕 inline keyboard
  (暫停1h / 暫停6h / 立即重試 / 解除暫停)
- telegram_bot_service._handle_ops_callback(): 接 momo:ops:<action>:<task>
- scheduler.py run_momo_task + run_auto_import_task 開頭加
  is_task_paused() 檢查(Phase 4 暫停機制生效)

安全邊界(ADR-012 §①):
- L1 Hermes 只讀 → 失敗降 L0 + 🟡 標記
- L2 NemoTron 只碰 ai_insights + 發 Telegram + SAFE_ACTIONS
- L3 OpenClaw 任意動作必經 HITL inline keyboard 批准
- 不做容器重啟按鈕(需 docker socket,風險過高)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ogt
2026-04-19 13:26:51 +08:00
parent 0b4f80ee8a
commit bda4edd23b
5 changed files with 472 additions and 43 deletions

View File

@@ -200,17 +200,133 @@ def _compose_triaged(event: dict, tier_label: str, ai_summary: str,
# =====================================================================
# AI 介入 stubPhase 2/3 填實作)
# L1 Hermes ObserverPhase 2 實作)
# =====================================================================
_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", "30"))
_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(event: dict) -> str | None:
"""Phase 1 stubPhase 2 會呼叫 hermes_analyst_service"""
# 不呼叫 AI回傳 None 讓上層降級
return None
"""呼叫 HermesOllama翻譯 stack trace 為人類摘要。失敗回 None 讓上層降級。"""
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,
"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()
# 容錯Hermes 可能多出 markdown fence
if "```" in raw:
parts = raw.split("```")
for p in parts:
if p.strip().startswith("{"):
raw = p.strip()
break
import json as _json
parsed = _json.loads(raw)
summary = parsed.get("summary", "").strip()
cause = parsed.get("probable_cause", "").strip()
actions = parsed.get("actions", []) or []
out = [summary]
if cause:
out.append(f"\n*可能根因:* {cause}")
if actions:
out.append("\n*建議動作:*")
for a in actions[:3]:
out.append(f"{a}")
return "\n".join(out)
except Exception as e:
sys_log.warning(f"[EventRouter.L1] Hermes 呼叫失敗,降級:{type(e).__name__}: {str(e)[:120]}")
return None
# =====================================================================
# L2 NemoTron InvestigatorPhase 3 規則式實作,不呼 NIM
# =====================================================================
# event_type → [(action_name, params)] 規則表
_L2_RULES: dict[str, list[tuple[str, dict]]] = {
"db_connection_error": [
("query_km", {"query": "DNS resolve 失敗 momo-postgres"}),
("retry_task", {"task_name": "<auto>", "backoff_sec": 60}),
],
"crawler_timeout": [
("silence_alert", {"duration_min": 30}),
("retry_task", {"task_name": "<auto>", "backoff_sec": 300}),
],
"nim_quota_exhausted": [
("silence_alert", {"duration_min": 720}), # 12 小時,等 quota 重置
],
"embedding_failure": [
("silence_alert", {"duration_min": 10}), # 已有 retry queue 處理
],
}
def _nemoton_investigate(event: dict) -> dict | None:
"""Phase 1 stubPhase 3 會呼叫 nemoton_dispatcher_service"""
return None
"""
Phase 3 規則式 L2根據 event_type 查 _L2_RULES執行對應 safe actions。
Phase 5+ 可改接 NIM 讓 LLM 決定 action。
"""
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 = agent_actions.SAFE_ACTIONS.get(action_name)
if not action_fn:
continue
# 動態參數:<auto> 代入事件本身的欄位
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 說明決策邏輯
summary = f"依規則 _L2_RULES[{event_type}] 執行 {len(actions_taken)} 個安全動作"
return {"summary": summary, "actions_taken": actions_taken}
# =====================================================================