feat(ai-ops): ADR-012 Phase 2/3/4 完整實作
All checks were successful
CD Pipeline / deploy (push) Successful in 1m11s
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:
@@ -200,17 +200,133 @@ def _compose_triaged(event: dict, tier_label: str, ai_summary: str,
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# AI 介入 stub(Phase 2/3 填實作)
|
||||
# L1 Hermes Observer(Phase 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 stub:Phase 2 會呼叫 hermes_analyst_service"""
|
||||
# 不呼叫 AI,回傳 None 讓上層降級
|
||||
return None
|
||||
"""呼叫 Hermes(Ollama)翻譯 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 Investigator(Phase 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 stub:Phase 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}
|
||||
|
||||
|
||||
# =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user