feat(adr-076): Task 3.3 — SSH 修復 KM 萃取(補齊飛輪雙手)
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

動機: SSH MCP 修復(docker restart/systemctl)成功後,KM 無法學習
因為 _extract_repair_steps 只處理 kubectl,SSH 路徑完全漏失。

approval_execution.py:
  - _trigger_playbook_extraction: 成功執行後將 approval.action 寫入
    incident.outcome.learning_notes,供 Playbook 萃取器讀取

playbook_service.py:
  - _parse_ssh_command(): 新增模組函式,解析 ssh [user@]host 'cmd' 格式
  - _extract_repair_steps(): 步驟 2 擴充 SSH 路徑分支
      ssh ... → ActionType.SSH_COMMAND + host 記錄
      kubectl ... → ActionType.KUBECTL(保留原有邏輯)
  - _generate_name(): SSH 修復自動加 [SSH] 前綴
  - _extract_tags(): SSH 修復自動加 ssh + host_layer 標籤

test_playbook_ssh_extraction.py: 18 tests(100% 通過)

飛輪雙手對齊:
  kubectl 路徑: decision_chain.reasoning_steps → KM  (既有)
  SSH 路徑: approval.action → learning_notes → KM  (Task 3.3 新增)

測試: 794 passed, 26 skipped, 0 failed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-14 15:19:54 +08:00
parent cc42aa0bdb
commit aae7c12645
3 changed files with 389 additions and 14 deletions

View File

@@ -581,6 +581,11 @@ class ApprovalExecutionService:
if incident.status not in [IncidentStatus.RESOLVED, IncidentStatus.CLOSED]:
incident.status = IncidentStatus.RESOLVED
incident.resolved_at = now_taipei()
# Task 3.3 (2026-04-14): 記錄執行動作供 SSH 路徑 KM 萃取
# approval.action 含實際執行指令(可能是 kubectl 或 ssh ...
# 寫入 learning_notes 供 playbook_service._extract_repair_steps 萃取 SSH RepairStep
if not incident.outcome.learning_notes and approval.action:
incident.outcome.learning_notes = approval.action
# 回存 Incidentfire-and-forget 路徑,失敗不影響主流程)
await incident_service.save_to_working_memory(incident)

View File

@@ -36,6 +36,33 @@ from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
import re as _re
def _parse_ssh_command(ssh_cmd: str) -> tuple[str, str]:
"""
從 SSH 指令字串中分離主機名與實際執行指令。
Task 3.3 (2026-04-14): SSH 修復 KM 萃取輔助函式
支援格式:
ssh 192.168.0.188 'docker restart minio'
ssh root@192.168.0.110 'systemctl restart ollama || docker restart ollama'
ssh {host} "cd /data/harbor && docker-compose up -d"
Returns:
(host, inner_command) — 無法解析時回傳 ("", original_cmd)
"""
m = _re.match(
r"ssh\s+(?:[a-zA-Z0-9_]+@)?([\w.\-:{}]+)\s+['\"](.+)['\"]",
ssh_cmd.strip(),
_re.DOTALL,
)
if m:
return m.group(1), m.group(2)
# fallback: 空 host保留完整命令
return "", ssh_cmd
class IPlaybookService(Protocol):
"""Playbook Service Interface"""
@@ -542,18 +569,25 @@ class PlaybookService:
)
def _extract_repair_steps(self, incident: Incident) -> list[RepairStep]:
"""從 Incident 萃取修復步驟"""
"""
從 Incident 萃取修復步驟
Task 3.3 (2026-04-14): 補齊 SSH 修復路徑。原本只處理 kubectl
新增 last_repair_action 作為第三優先來源,支援 SSH_COMMAND 類型。
優先順序:
1. decision_chain.reasoning_steps — kubectl 命令AI 推論步驟)
2. outcome.learning_notes — kubectl 命令(人工補充)
3. outcome.last_repair_action — SSH 或 kubectl實際執行動作Task 3.3 新增)
"""
steps: list[RepairStep] = []
step_number = 1
# 從 decision_chain.reasoning_steps 提取 kubectl 命令
# 1. 從 decision_chain.reasoning_steps 提取 kubectl 命令
if incident.decision_chain and incident.decision_chain.reasoning_steps:
for reasoning in incident.decision_chain.reasoning_steps:
# 尋找包含 kubectl 的步驟
if "kubectl" in reasoning.lower():
# 嘗試提取 kubectl 命令
import re
kubectl_match = re.search(r"kubectl\s+\S+.*", reasoning)
kubectl_match = _re.search(r"kubectl\s+\S+.*", reasoning)
if kubectl_match:
steps.append(
RepairStep(
@@ -565,18 +599,49 @@ class PlaybookService:
)
step_number += 1
# 如果沒有從 reasoning_steps 取得,嘗試從 learning_notes 取得
# 2. Task 3.3: 從 learning_notes 萃取 kubectl 或 SSH 命令
# learning_notes 由兩個來源寫入:
# a. 人工補充筆記(既有邏輯)
# b. approval_execution._trigger_playbook_extraction 寫入 approval.actionTask 3.3 新增)
if not steps and incident.outcome and incident.outcome.learning_notes:
notes = incident.outcome.learning_notes
if "kubectl" in notes.lower():
notes = incident.outcome.learning_notes.strip()
if notes.startswith("ssh "):
# SSH 修復路徑Task 3.3 新增)
host, inner_cmd = _parse_ssh_command(notes)
steps.append(
RepairStep(
step_number=1,
action_type=ActionType.KUBECTL,
command=notes,
action_type=ActionType.SSH_COMMAND,
command=inner_cmd or notes,
risk_level=RiskLevel.MEDIUM,
)
)
logger.info(
"playbook_ssh_step_extracted",
host=host or "unknown",
inner_cmd_preview=(inner_cmd or notes)[:60],
)
elif "kubectl" in notes.lower():
# kubectl 路徑(原有邏輯,移入此區塊統一處理)
kubectl_match = _re.search(r"kubectl\s+\S+.*", notes)
if kubectl_match:
steps.append(
RepairStep(
step_number=1,
action_type=ActionType.KUBECTL,
command=kubectl_match.group(0).strip(),
risk_level=RiskLevel.MEDIUM,
)
)
else:
steps.append(
RepairStep(
step_number=1,
action_type=ActionType.KUBECTL,
command=notes,
risk_level=RiskLevel.MEDIUM,
)
)
return steps
@@ -598,12 +663,16 @@ class PlaybookService:
return min(base_score + effectiveness_bonus, 1.0)
def _generate_name(self, incident: Incident) -> str:
"""生成 Playbook 名稱"""
"""生成 Playbook 名稱Task 3.3: SSH 修復加 [SSH] 前綴)"""
alert_name = incident.signals[0].alert_name if incident.signals else "Unknown"
services = incident.affected_services[:2] if incident.affected_services else []
service_str = "/".join(services) if services else "system"
return f"{alert_name} - {service_str} 修復劇本"
# 偵測 SSH 修復路徑 — 加前綴以利搜尋與過濾Task 3.3
notes = (incident.outcome.learning_notes or "") if incident.outcome else ""
prefix = "[SSH] " if notes.strip().startswith("ssh ") else ""
return f"{prefix}{alert_name} - {service_str} 修復劇本"
def _generate_description(self, incident: Incident) -> str:
"""生成 Playbook 描述"""
@@ -622,7 +691,7 @@ class PlaybookService:
return ". ".join(parts) if parts else "從成功案例自動萃取的修復劇本"
def _extract_tags(self, incident: Incident) -> list[str]:
"""萃取標籤"""
"""萃取標籤Task 3.3: SSH 修復自動加 ssh 標籤)"""
tags: set[str] = set()
# 從服務名稱提取
@@ -641,6 +710,12 @@ class PlaybookService:
if "network" in signal.alert_name.lower():
tags.add("network")
# Task 3.3: SSH 修復加標籤learning_notes 以 ssh 開頭 → 主機層修復)
notes = (incident.outcome.learning_notes or "") if incident.outcome else ""
if notes.strip().startswith("ssh "):
tags.add("ssh")
tags.add("host_layer")
return list(tags)[:10]
def _find_matched_symptoms(