feat(dispatch): B2 LLM 動態 MCP 派發安全閘 + telegram_gateway LLM 按鈕流程
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 9m10s
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 9m10s
ADR-082 §B2:dispatch_llm_action() 風險閘控 + allowlist + 模板渲染 23 tests pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -404,3 +404,165 @@ def _format_reply(
|
||||
text += "...\n<i>(已截斷)</i>"
|
||||
return f"{header}\n<pre>{text}</pre>"
|
||||
return f"{header}\n{mcp_result}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# B2: LLM Dynamic Action Dispatcher
|
||||
# 2026-04-27 Claude Sonnet 4.6: B2 — dispatch_llm_action()
|
||||
# 支援 RecommendedAction 結構化動作的風險閘控 + allowlist 驗證 + 模板渲染
|
||||
# ADR-082 §B2:LLM 動態 MCP 規格派發安全閘
|
||||
# =============================================================================
|
||||
|
||||
import re as _re
|
||||
|
||||
|
||||
def _render_llm_params(params: dict[str, str], context: dict) -> dict[str, str]:
|
||||
"""
|
||||
渲染 RecommendedAction.params 模板。
|
||||
|
||||
支援兩個命名空間:
|
||||
- {labels.xxx} → context["labels"]["xxx"]
|
||||
- {context.xxx} → context["xxx"](如 context.incident_id)
|
||||
- {incident_id} → context["incident_id"](舊式相容)
|
||||
|
||||
渲染失敗的 key → 保留原始字串,不 crash。
|
||||
"""
|
||||
def _repl(m: _re.Match) -> str:
|
||||
key = m.group(1)
|
||||
parts = key.split(".", 1)
|
||||
try:
|
||||
if parts[0] == "labels" and len(parts) == 2:
|
||||
val = (context.get("labels") or {}).get(parts[1])
|
||||
return str(val) if val is not None else m.group(0)
|
||||
if parts[0] == "context" and len(parts) == 2:
|
||||
val = context.get(parts[1])
|
||||
return str(val) if val is not None else m.group(0)
|
||||
# 舊式:直接 top-level key(如 {incident_id})
|
||||
val = context.get(key)
|
||||
return str(val) if val is not None else m.group(0)
|
||||
except Exception:
|
||||
return m.group(0)
|
||||
|
||||
rendered: dict[str, str] = {}
|
||||
for k, v in params.items():
|
||||
if isinstance(v, str) and "{" in v:
|
||||
try:
|
||||
rendered[k] = _re.sub(r"\{([a-zA-Z0-9_.]+)\}", _repl, v)
|
||||
except Exception:
|
||||
rendered[k] = v
|
||||
else:
|
||||
rendered[k] = v
|
||||
return rendered
|
||||
|
||||
|
||||
def _load_llm_tool_registry() -> dict[str, dict]:
|
||||
"""
|
||||
Lazy import _load_mcp_tool_registry from solver_agent,避免 circular import。
|
||||
失敗時返回 {} 並 log warning(不 crash)。
|
||||
"""
|
||||
try:
|
||||
from src.agents.solver_agent import _load_mcp_tool_registry # noqa: PLC0415
|
||||
return _load_mcp_tool_registry()
|
||||
except Exception as exc:
|
||||
logger.warning("llm_dispatch_registry_load_failed", error=str(exc))
|
||||
return {}
|
||||
|
||||
|
||||
def dispatch_llm_action(
|
||||
action: Any,
|
||||
context: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
B2: LLM 動態 MCP 規格派發閘控器
|
||||
|
||||
安全層次(依序執行):
|
||||
1. Risk Gating — critical 直接拒絕;high 需要 confirmed=True
|
||||
2. Allowlist — mcp_tool 必須在 registry 中
|
||||
3. Params 渲染 — 支援 {labels.xxx} / {context.xxx} / {incident_id}
|
||||
4. Nonce 生成 — medium/high 允許執行時附帶 nonce
|
||||
|
||||
Args:
|
||||
action: RecommendedAction dataclass(來自 solver_agent B1 輸出)
|
||||
context: 執行上下文 dict(含 labels / incident_id / confirmed 等)
|
||||
|
||||
Returns:
|
||||
dict — ok=True 為允許執行,ok=False 附 reason 拒絕原因
|
||||
"""
|
||||
import time as _time # noqa: PLC0415
|
||||
|
||||
risk: str = getattr(action, "risk", "medium")
|
||||
mcp_tool: str = getattr(action, "mcp_tool", "")
|
||||
mcp_provider: str = getattr(action, "mcp_provider", "")
|
||||
name: str = getattr(action, "name", "")
|
||||
params: dict = dict(getattr(action, "params", {}) or {})
|
||||
|
||||
# ── 1. Risk Gating ────────────────────────────────────────────────────────
|
||||
|
||||
if risk == "critical":
|
||||
logger.warning(
|
||||
"llm_dispatch_critical_rejected",
|
||||
mcp_tool=mcp_tool,
|
||||
name=name,
|
||||
incident_id=context.get("incident_id"),
|
||||
)
|
||||
return {"ok": False, "reason": "critical_action_rejected"}
|
||||
|
||||
if risk == "high":
|
||||
if not context.get("confirmed"):
|
||||
nonce = (
|
||||
f"{mcp_tool}:{name}:{context.get('incident_id', '?')}:{int(_time.time())}"
|
||||
)
|
||||
logger.info(
|
||||
"llm_dispatch_high_risk_pending",
|
||||
mcp_tool=mcp_tool,
|
||||
name=name,
|
||||
incident_id=context.get("incident_id"),
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "high_risk_requires_confirmation",
|
||||
"nonce": nonce,
|
||||
}
|
||||
|
||||
# ── 2. Allowlist 驗證 ─────────────────────────────────────────────────────
|
||||
|
||||
registry = _load_llm_tool_registry()
|
||||
if mcp_tool not in registry:
|
||||
logger.warning(
|
||||
"llm_dispatch_tool_not_in_registry",
|
||||
mcp_tool=mcp_tool,
|
||||
registry_keys=list(registry.keys()),
|
||||
)
|
||||
return {"ok": False, "reason": "tool_not_in_registry"}
|
||||
|
||||
# ── 3. Params 模板渲染 ────────────────────────────────────────────────────
|
||||
|
||||
rendered_params = _render_llm_params(params, context)
|
||||
|
||||
# ── 4. Nonce 生成(medium/high 允許時) ───────────────────────────────────
|
||||
|
||||
nonce: str | None = None
|
||||
if risk in ("medium", "high"):
|
||||
nonce = (
|
||||
f"{mcp_tool}:{name}:{context.get('incident_id', '?')}:{int(_time.time())}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"llm_dispatch_allowed",
|
||||
mcp_tool=mcp_tool,
|
||||
mcp_provider=mcp_provider,
|
||||
name=name,
|
||||
risk=risk,
|
||||
incident_id=context.get("incident_id"),
|
||||
has_nonce=nonce is not None,
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"mcp_provider": mcp_provider,
|
||||
"mcp_tool": mcp_tool,
|
||||
"params": rendered_params,
|
||||
"risk": risk,
|
||||
"nonce": nonce,
|
||||
"button_source": "llm",
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ POLLING_LEADER_WATCH = 30 # seconds - 非 Leader Pod 每 30s 嘗試接管
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# 2026-04-27 Claude Sonnet 4.6: B3 — LLM 動態 Telegram 按鈕 Feature Flag
|
||||
# true → 優先使用 ActionPlan.recommended_actions 動態生成按鈕
|
||||
# false → 維持現有 callback_action_spec.yaml 路徑(預設,向下相容)
|
||||
USE_LLM_DYNAMIC_BUTTONS = os.environ.get("USE_LLM_DYNAMIC_BUTTONS", "false").lower() == "true"
|
||||
|
||||
# =============================================================================
|
||||
# OTEL Tracer (Phase C P1 可觀測性)
|
||||
# 2026-03-30 Claude Code: 新增 Telegram Gateway 追蹤
|
||||
@@ -1431,12 +1436,15 @@ class TelegramGateway:
|
||||
# ADR-071-E: TYPE-3 動態按鈕 (2026-04-11 Claude Sonnet 4.6)
|
||||
alert_category: str = "",
|
||||
notification_type: str = "",
|
||||
# 2026-04-27 Claude Sonnet 4.6: B3 — LLM 動態按鈕(ActionPlan,可選)
|
||||
action_plan: object = None,
|
||||
) -> dict:
|
||||
"""
|
||||
建立 Inline Keyboard
|
||||
|
||||
ADR-050 v2.0 (2026-04-01): 六鍵佈局
|
||||
ADR-071-E (2026-04-11): TYPE-3 依 alert_category 動態組合操作按鈕
|
||||
ADR-082 B3 (2026-04-27): USE_LLM_DYNAMIC_BUTTONS → 優先使用 Solver LLM 動態按鈕
|
||||
|
||||
TYPE-3 按鈕對應 alert_category:
|
||||
k8s_workload → [重啟] [擴容] [縮容] [回滾]
|
||||
@@ -1455,7 +1463,44 @@ class TelegramGateway:
|
||||
incident_id: 關聯 Incident ID (用於 detail/reanalyze/history 按鈕)
|
||||
alert_category: 告警類別 (ADR-071-E: 決定 TYPE-3 按鈕組合)
|
||||
notification_type: 通知類型 (TYPE-1/2/3/4/4D)
|
||||
action_plan: ActionPlan dataclass(B3: 有值且 USE_LLM_DYNAMIC_BUTTONS=true 時走 LLM 路徑)
|
||||
"""
|
||||
# 產生 Nonce (防重放,用於寫操作)
|
||||
approve_nonce = self._security.generate_callback_nonce(approval_id, "approve")
|
||||
reject_nonce = self._security.generate_callback_nonce(approval_id, "reject")
|
||||
silence_nonce = self._security.generate_callback_nonce(approval_id, "silence")
|
||||
|
||||
# 第一排永遠置頂(HARD RULE,任何路徑不得改動)
|
||||
first_row: list[dict] = [
|
||||
{"text": "✅ 批准", "callback_data": approve_nonce},
|
||||
{"text": "❌ 拒絕", "callback_data": reject_nonce},
|
||||
]
|
||||
|
||||
# ── B3: LLM 動態路徑 ─────────────────────────────────────────────────
|
||||
# 2026-04-27 Claude Sonnet 4.6: B3 — USE_LLM_DYNAMIC_BUTTONS=true 且
|
||||
# action_plan.recommended_actions 非空時走此路徑,否則 fallback 到 YAML。
|
||||
_llm_actions = (
|
||||
getattr(action_plan, "recommended_actions", None)
|
||||
if action_plan is not None
|
||||
else None
|
||||
)
|
||||
if USE_LLM_DYNAMIC_BUTTONS and _llm_actions:
|
||||
llm_rows = self._build_llm_action_buttons(_llm_actions)
|
||||
buttons: list[list[dict]] = [first_row] + llm_rows
|
||||
logger.info(
|
||||
"telegram_keyboard_built",
|
||||
source="llm",
|
||||
action_count=len(_llm_actions),
|
||||
)
|
||||
|
||||
# 自動調優按鈕 (v7.0)
|
||||
if include_auto_tuning and auto_tuning_command:
|
||||
tuning_nonce = self._security.generate_callback_nonce(approval_id, "tune")
|
||||
buttons.append([{"text": "⚡ 執行自動調優", "callback_data": tuning_nonce}])
|
||||
|
||||
return {"inline_keyboard": buttons}
|
||||
|
||||
# ── YAML Fallback 路徑(原有邏輯,不改動任何行為)────────────────────
|
||||
# 2026-04-14 Claude Sonnet 4.6 (Phase 5 Sprint 5.4):
|
||||
# 從 callback_action_spec registry 動態產生按鈕(原 _CATEGORY_BUTTONS hardcode 已下架)
|
||||
# 優點:新增按鈕只需改 yaml,callback_data 格式由 spec.callback_format 決定
|
||||
@@ -1477,11 +1522,6 @@ class TelegramGateway:
|
||||
btns.append((emoji_label, cb))
|
||||
return btns
|
||||
|
||||
# 產生 Nonce (防重放,用於寫操作)
|
||||
approve_nonce = self._security.generate_callback_nonce(approval_id, "approve")
|
||||
reject_nonce = self._security.generate_callback_nonce(approval_id, "reject")
|
||||
silence_nonce = self._security.generate_callback_nonce(approval_id, "silence")
|
||||
|
||||
is_type3 = notification_type in ("TYPE-3", NotificationType.TYPE_3, "")
|
||||
|
||||
_dynamic_buttons = _build_category_buttons_for(alert_category) if alert_category else []
|
||||
@@ -1491,10 +1531,7 @@ class TelegramGateway:
|
||||
# 2026-04-17 ogt + Claude Sonnet 4.6 (BUG-C): 強制置頂批准/拒絕
|
||||
# 舊:批准/拒絕列在最後且受 requires_human_approval 控制 → K8s 按鈕蓋台 → 死卡
|
||||
# 新:[批准][拒絕] 永遠第一行,K8s 類別按鈕置後,SRE 第一眼就看到審核扳機
|
||||
rows: list[list[dict]] = [[
|
||||
{"text": "✅ 批准", "callback_data": approve_nonce},
|
||||
{"text": "❌ 拒絕", "callback_data": reject_nonce},
|
||||
]]
|
||||
rows: list[list[dict]] = [first_row]
|
||||
# K8s/DB/Host 等類別操作按鈕(每行最多 3 個)置於第二列以後
|
||||
category_btns = [
|
||||
{"text": text, "callback_data": cb_data}
|
||||
@@ -1524,6 +1561,12 @@ class TelegramGateway:
|
||||
{"text": "📊 歷史", "callback_data": f"history:{incident_id}"},
|
||||
])
|
||||
|
||||
logger.info(
|
||||
"telegram_keyboard_built",
|
||||
source="yaml_fallback",
|
||||
action_count=len(_dynamic_buttons),
|
||||
)
|
||||
|
||||
# 自動調優按鈕 (v7.0)
|
||||
if include_auto_tuning and auto_tuning_command:
|
||||
tuning_nonce = self._security.generate_callback_nonce(approval_id, "tune")
|
||||
@@ -1533,6 +1576,64 @@ class TelegramGateway:
|
||||
|
||||
return {"inline_keyboard": buttons}
|
||||
|
||||
@staticmethod
|
||||
def _build_llm_action_buttons(
|
||||
actions: list,
|
||||
) -> list[list[dict]]:
|
||||
"""
|
||||
2026-04-27 Claude Sonnet 4.6: B3 — 從 RecommendedAction list 建立 Telegram 按鈕排
|
||||
|
||||
規格:
|
||||
- 每個 RecommendedAction → 一個按鈕
|
||||
- text = f"{action.emoji} {action.label}"(risk=high 前綴 ⚠️)
|
||||
- callback_data = JSON {"t":"llm_action","name":..,"provider":..,"tool":..}(限 64 bytes)
|
||||
- 每排最多 2 個(同 YAML fallback 排版)
|
||||
- 不包含第一排 [批准][拒絕](由呼叫方負責置頂)
|
||||
|
||||
Args:
|
||||
actions: list[RecommendedAction]
|
||||
|
||||
Returns:
|
||||
list[list[dict]] — 按鈕行列(不含第一排)
|
||||
"""
|
||||
import json
|
||||
|
||||
btn_list: list[dict] = []
|
||||
for action in actions:
|
||||
name: str = getattr(action, "name", "")
|
||||
label: str = getattr(action, "label", "")
|
||||
emoji: str = getattr(action, "emoji", "")
|
||||
provider: str = getattr(action, "mcp_provider", "")
|
||||
tool: str = getattr(action, "mcp_tool", "")
|
||||
risk: str = getattr(action, "risk", "low")
|
||||
|
||||
# risk=high 前綴 ⚠️ 警示
|
||||
prefix = "⚠️ " if risk == "high" else ""
|
||||
text = f"{prefix}{emoji} {label}".strip()
|
||||
|
||||
# callback_data JSON,限 64 bytes(Telegram 上限)
|
||||
# 使用縮短 key:t=la(llm_action), n=name, p=provider, tl=tool
|
||||
# 縮短後框架約 47 bytes,留 ~17 bytes 給 name
|
||||
cb_payload = {"t": "la", "n": "", "p": provider, "tl": tool}
|
||||
frame_bytes = len(
|
||||
json.dumps(cb_payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
available = max(0, 64 - frame_bytes)
|
||||
if len(name.encode("utf-8")) <= available:
|
||||
truncated_name = name
|
||||
else:
|
||||
# 按 UTF-8 bytes 截斷(中文字可能多 bytes)
|
||||
encoded = name.encode("utf-8")[:available]
|
||||
truncated_name = encoded.decode("utf-8", errors="ignore")
|
||||
cb_payload["n"] = truncated_name
|
||||
cb_str = json.dumps(cb_payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
btn_list.append({"text": text, "callback_data": cb_str})
|
||||
|
||||
# 每排最多 2 個
|
||||
rows: list[list[dict]] = [btn_list[i:i+2] for i in range(0, len(btn_list), 2)]
|
||||
return rows
|
||||
|
||||
async def send_analyzing_placeholder(
|
||||
self,
|
||||
alert_type: str,
|
||||
|
||||
Reference in New Issue
Block a user