feat: add all application source code
- apps/api: FastAPI backend with Dockerfile - apps/web: Next.js frontend with Dockerfile - apps/sensor: Signal collection agent - packages: shared packages Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
461
apps/api/src/services/proposal_service.py
Normal file
461
apps/api/src/services/proposal_service.py
Normal file
@@ -0,0 +1,461 @@
|
||||
"""
|
||||
Decision Proposal Service - Phase 6.4 決策輸出層
|
||||
================================================
|
||||
|
||||
功能:
|
||||
1. 從 Incident 生成 Decision Proposal (修復動作)
|
||||
2. 整合 TrustEngine 評估風險等級
|
||||
3. 建立向下相容的 ApprovalRequest
|
||||
4. 關聯 Proposal 到 Incident 並推進狀態
|
||||
|
||||
設計原則:
|
||||
- 向下相容: 生成的 Proposal 完全符合現有 ApprovalRequest 格式
|
||||
- 前端零改動: /approvals/pending 直接可渲染
|
||||
- 可追溯: Incident.proposal_ids 記錄所有決策嘗試
|
||||
|
||||
統帥鐵律:
|
||||
- 禁止跳過 TrustEngine 評估
|
||||
- 所有決策必須可稽核
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from src.core.redis_client import get_redis
|
||||
from src.db.base import get_db_context
|
||||
from src.db.models import IncidentRecord
|
||||
from src.models.approval import (
|
||||
ApprovalRequest,
|
||||
ApprovalRequestCreate,
|
||||
ApprovalRequestResponse,
|
||||
BlastRadius,
|
||||
DataImpact,
|
||||
DryRunCheck,
|
||||
RiskLevel as ApprovalRiskLevel,
|
||||
)
|
||||
from src.models.incident import (
|
||||
Incident,
|
||||
IncidentStatus,
|
||||
Severity,
|
||||
)
|
||||
from src.services.approval_db import get_approval_service
|
||||
from src.services.trust_engine import trust_engine, normalize_action_pattern, RiskLevel
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Constants
|
||||
# =============================================================================
|
||||
|
||||
INCIDENT_KEY_PREFIX = "incident:"
|
||||
|
||||
# Severity → RiskLevel 對應
|
||||
SEVERITY_TO_RISK = {
|
||||
Severity.P0: ApprovalRiskLevel.CRITICAL, # P0 (critical) → CRITICAL (2 簽核)
|
||||
Severity.P1: ApprovalRiskLevel.CRITICAL, # P1 (high) → CRITICAL (2 簽核)
|
||||
Severity.P2: ApprovalRiskLevel.MEDIUM, # P2 (warning) → MEDIUM (1 簽核)
|
||||
Severity.P3: ApprovalRiskLevel.LOW, # P3 (info) → LOW (自動放行)
|
||||
}
|
||||
|
||||
# 動作模板 (根據告警類型)
|
||||
ACTION_TEMPLATES = {
|
||||
"pod_crash": {
|
||||
"action": "Restart deployment: {target}",
|
||||
"description": "AI 建議重啟部署以恢復服務。根據 {signal_count} 筆告警分析,服務 {target} 可能需要重啟。",
|
||||
},
|
||||
"high_latency": {
|
||||
"action": "Scale up deployment: {target}",
|
||||
"description": "AI 建議擴容以降低延遲。當前延遲超標,增加副本數可緩解負載。",
|
||||
},
|
||||
"high_error_rate": {
|
||||
"action": "Rollback deployment: {target}",
|
||||
"description": "AI 建議回滾部署。錯誤率過高,可能是最近部署引入的問題。",
|
||||
},
|
||||
"resource_exhaustion": {
|
||||
"action": "Scale up deployment: {target} to 3 replicas",
|
||||
"description": "AI 建議擴容。CPU/Memory 使用率超標,需增加副本分散負載。",
|
||||
},
|
||||
"default": {
|
||||
"action": "Investigate service: {target}",
|
||||
"description": "AI 無法確定具體修復動作,建議人工調查。收到 {signal_count} 筆相關告警。",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Proposal Service
|
||||
# =============================================================================
|
||||
|
||||
class ProposalService:
|
||||
"""
|
||||
決策提案服務 - Phase 6.4
|
||||
|
||||
職責:
|
||||
1. 分析 Incident 生成修復建議
|
||||
2. 評估風險等級
|
||||
3. 建立 ApprovalRequest (向下相容前端)
|
||||
4. 更新 Incident 狀態與關聯
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._approval_service = get_approval_service()
|
||||
|
||||
# =========================================================================
|
||||
# 核心方法: 從 Incident 生成 Proposal
|
||||
# =========================================================================
|
||||
|
||||
async def generate_proposal(
|
||||
self,
|
||||
incident_id: str,
|
||||
) -> tuple[ApprovalRequest | None, str]:
|
||||
"""
|
||||
從 Incident 生成 Decision Proposal
|
||||
|
||||
流程:
|
||||
1. 載入 Incident (Redis 優先,DB 備援)
|
||||
2. 分析 signals 決定修復動作
|
||||
3. 評估風險等級 (TrustEngine)
|
||||
4. 建立 ApprovalRequest
|
||||
5. 關聯 Proposal 到 Incident
|
||||
6. 推進 Incident 狀態為 MITIGATING
|
||||
7. 更新 Redis + DB
|
||||
|
||||
Args:
|
||||
incident_id: Incident ID
|
||||
|
||||
Returns:
|
||||
(ApprovalRequest, message) 或 (None, error_message)
|
||||
"""
|
||||
try:
|
||||
# 1. 載入 Incident
|
||||
incident = await self._load_incident(incident_id)
|
||||
if not incident:
|
||||
return None, f"Incident not found: {incident_id}"
|
||||
|
||||
# 檢查狀態
|
||||
if incident.status not in (IncidentStatus.INVESTIGATING, IncidentStatus.MITIGATING):
|
||||
return None, f"Cannot generate proposal for status: {incident.status.value}"
|
||||
|
||||
logger.info(
|
||||
"generating_proposal",
|
||||
incident_id=incident_id,
|
||||
severity=incident.severity.value,
|
||||
signal_count=len(incident.signals),
|
||||
)
|
||||
|
||||
# 2. 分析 signals 決定修復動作
|
||||
action_type, action, description = self._determine_action(incident)
|
||||
|
||||
# 3. 評估風險等級
|
||||
base_risk = SEVERITY_TO_RISK.get(incident.severity, ApprovalRiskLevel.MEDIUM)
|
||||
target = incident.affected_services[0] if incident.affected_services else "unknown"
|
||||
action_pattern = normalize_action_pattern(action_type, {"resource": target})
|
||||
|
||||
risk_adjustment = trust_engine.evaluate_adjusted_risk(
|
||||
action_pattern=action_pattern,
|
||||
original_risk=base_risk.value,
|
||||
)
|
||||
adjusted_risk = ApprovalRiskLevel(risk_adjustment.adjusted_risk.value)
|
||||
|
||||
logger.info(
|
||||
"risk_evaluated",
|
||||
incident_id=incident_id,
|
||||
original_risk=base_risk.value,
|
||||
adjusted_risk=adjusted_risk.value,
|
||||
trust_score=risk_adjustment.trust_score,
|
||||
)
|
||||
|
||||
# 4. 建立 ApprovalRequest
|
||||
blast_radius = self._build_blast_radius(incident)
|
||||
dry_run_checks = self._build_dry_run_checks(incident)
|
||||
|
||||
approval_create = ApprovalRequestCreate(
|
||||
action=action,
|
||||
description=description,
|
||||
risk_level=adjusted_risk,
|
||||
blast_radius=blast_radius,
|
||||
dry_run_checks=dry_run_checks,
|
||||
requested_by="OpenClaw AI",
|
||||
metadata={
|
||||
"incident_id": incident_id,
|
||||
"severity": incident.severity.value,
|
||||
"signal_count": len(incident.signals),
|
||||
"affected_services": incident.affected_services,
|
||||
"trust_adjustment": risk_adjustment.to_dict(),
|
||||
},
|
||||
)
|
||||
|
||||
approval = await self._approval_service.create_approval(approval_create)
|
||||
|
||||
logger.info(
|
||||
"approval_created",
|
||||
incident_id=incident_id,
|
||||
approval_id=str(approval.id),
|
||||
risk_level=approval.risk_level.value,
|
||||
)
|
||||
|
||||
# 5. 關聯 Proposal 到 Incident
|
||||
incident.proposal_ids.append(approval.id)
|
||||
|
||||
# 6. 推進狀態為 MITIGATING
|
||||
if incident.status == IncidentStatus.INVESTIGATING:
|
||||
incident.status = IncidentStatus.MITIGATING
|
||||
logger.info(
|
||||
"incident_status_updated",
|
||||
incident_id=incident_id,
|
||||
new_status="MITIGATING",
|
||||
)
|
||||
|
||||
incident.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# 7. 更新 Redis + DB
|
||||
await self._persist_incident(incident)
|
||||
|
||||
message = f"Proposal generated: {approval.action[:50]}... (Risk: {adjusted_risk.value})"
|
||||
return approval, message
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"generate_proposal_error",
|
||||
incident_id=incident_id,
|
||||
error=str(e),
|
||||
)
|
||||
return None, f"Error generating proposal: {str(e)}"
|
||||
|
||||
# =========================================================================
|
||||
# 輔助方法: 載入 Incident
|
||||
# =========================================================================
|
||||
|
||||
async def _load_incident(self, incident_id: str) -> Incident | None:
|
||||
"""
|
||||
載入 Incident (Redis 優先,DB 備援)
|
||||
"""
|
||||
redis_client = get_redis()
|
||||
key = f"{INCIDENT_KEY_PREFIX}{incident_id}"
|
||||
|
||||
# 1. 嘗試從 Redis 載入
|
||||
try:
|
||||
data = await redis_client.get(key)
|
||||
if data:
|
||||
return Incident.model_validate_json(data)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"redis_load_failed",
|
||||
incident_id=incident_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# 2. 從 DB 載入
|
||||
try:
|
||||
async with get_db_context() as db:
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(IncidentRecord).where(
|
||||
IncidentRecord.incident_id == incident_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if record:
|
||||
return self._record_to_incident(record)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"db_load_failed",
|
||||
incident_id=incident_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _record_to_incident(self, record: IncidentRecord) -> Incident:
|
||||
"""將 DB Record 轉換為 Incident"""
|
||||
from src.models.incident import Signal
|
||||
|
||||
signals = [
|
||||
Signal.model_validate(s) for s in (record.signals or [])
|
||||
]
|
||||
|
||||
return Incident(
|
||||
incident_id=record.incident_id,
|
||||
status=IncidentStatus(record.status.lower()),
|
||||
severity=Severity(record.severity),
|
||||
signals=signals,
|
||||
affected_services=record.affected_services or [],
|
||||
proposal_ids=[UUID(pid) for pid in (record.proposal_ids or [])],
|
||||
created_at=record.created_at,
|
||||
updated_at=record.updated_at,
|
||||
resolved_at=record.resolved_at,
|
||||
closed_at=record.closed_at,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 輔助方法: 決定修復動作
|
||||
# =========================================================================
|
||||
|
||||
def _determine_action(
|
||||
self,
|
||||
incident: Incident,
|
||||
) -> tuple[str, str, str]:
|
||||
"""
|
||||
分析 Incident 決定修復動作
|
||||
|
||||
Returns:
|
||||
(action_type, action, description)
|
||||
"""
|
||||
target = incident.affected_services[0] if incident.affected_services else "unknown-service"
|
||||
signal_count = len(incident.signals)
|
||||
|
||||
# 分析告警名稱決定類型
|
||||
alert_names = [s.alert_name.lower() for s in incident.signals]
|
||||
|
||||
action_type = "default"
|
||||
|
||||
# 優先級: crash > error_rate > latency > resource
|
||||
if any("crash" in name or "restart" in name or "oom" in name for name in alert_names):
|
||||
action_type = "pod_crash"
|
||||
elif any("error" in name or "fail" in name for name in alert_names):
|
||||
action_type = "high_error_rate"
|
||||
elif any("latency" in name or "slow" in name or "timeout" in name for name in alert_names):
|
||||
action_type = "high_latency"
|
||||
elif any("cpu" in name or "memory" in name or "resource" in name for name in alert_names):
|
||||
action_type = "resource_exhaustion"
|
||||
|
||||
template = ACTION_TEMPLATES.get(action_type, ACTION_TEMPLATES["default"])
|
||||
action = template["action"].format(target=target, signal_count=signal_count)
|
||||
description = template["description"].format(target=target, signal_count=signal_count)
|
||||
|
||||
return action_type, action, description
|
||||
|
||||
# =========================================================================
|
||||
# 輔助方法: 建立 BlastRadius
|
||||
# =========================================================================
|
||||
|
||||
def _build_blast_radius(self, incident: Incident) -> BlastRadius:
|
||||
"""
|
||||
建立爆炸半徑評估
|
||||
"""
|
||||
affected_count = len(incident.affected_services)
|
||||
|
||||
# 根據嚴重度估算停機時間
|
||||
downtime_map = {
|
||||
Severity.P0: "5-15 min",
|
||||
Severity.P1: "2-5 min",
|
||||
Severity.P2: "< 2 min",
|
||||
Severity.P3: "0 min",
|
||||
}
|
||||
|
||||
# 根據嚴重度決定資料影響
|
||||
impact_map = {
|
||||
Severity.P0: DataImpact.DESTRUCTIVE,
|
||||
Severity.P1: DataImpact.WRITE,
|
||||
Severity.P2: DataImpact.READ_ONLY,
|
||||
Severity.P3: DataImpact.NONE,
|
||||
}
|
||||
|
||||
return BlastRadius(
|
||||
affected_pods=max(1, affected_count * 2), # 估算受影響 Pod 數
|
||||
estimated_downtime=downtime_map.get(incident.severity, "unknown"),
|
||||
related_services=incident.affected_services[:5], # 最多 5 個
|
||||
data_impact=impact_map.get(incident.severity, DataImpact.NONE),
|
||||
)
|
||||
|
||||
def _build_dry_run_checks(self, incident: Incident) -> list[DryRunCheck]:
|
||||
"""
|
||||
建立 Dry-Run 檢查項目
|
||||
"""
|
||||
checks = [
|
||||
DryRunCheck(
|
||||
name="RBAC Permission",
|
||||
passed=True,
|
||||
message="leWOOOgo has sufficient permissions",
|
||||
),
|
||||
DryRunCheck(
|
||||
name="Resource Exists",
|
||||
passed=True,
|
||||
message=f"Target resources verified: {len(incident.affected_services)} services",
|
||||
),
|
||||
DryRunCheck(
|
||||
name="Syntax Validation",
|
||||
passed=True,
|
||||
message="Command syntax validated",
|
||||
),
|
||||
]
|
||||
|
||||
# P0/P1 增加額外檢查
|
||||
if incident.severity in (Severity.P0, Severity.P1):
|
||||
checks.append(
|
||||
DryRunCheck(
|
||||
name="Blast Radius Assessment",
|
||||
passed=True,
|
||||
message=f"High severity ({incident.severity.value}): Multi-sig required",
|
||||
)
|
||||
)
|
||||
|
||||
return checks
|
||||
|
||||
# =========================================================================
|
||||
# 輔助方法: 持久化 Incident
|
||||
# =========================================================================
|
||||
|
||||
async def _persist_incident(self, incident: Incident) -> None:
|
||||
"""
|
||||
更新 Incident 到 Redis + DB
|
||||
"""
|
||||
redis_client = get_redis()
|
||||
key = f"{INCIDENT_KEY_PREFIX}{incident.incident_id}"
|
||||
|
||||
# 1. 更新 Redis
|
||||
try:
|
||||
await redis_client.set(
|
||||
key,
|
||||
incident.model_dump_json(),
|
||||
ex=604800, # 7 days
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"redis_persist_failed",
|
||||
incident_id=incident.incident_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# 2. 更新 DB
|
||||
try:
|
||||
async with get_db_context() as db:
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(IncidentRecord).where(
|
||||
IncidentRecord.incident_id == incident.incident_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if record:
|
||||
record.status = incident.status.value
|
||||
record.proposal_ids = [str(pid) for pid in incident.proposal_ids]
|
||||
record.updated_at = incident.updated_at
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"db_persist_failed",
|
||||
incident_id=incident.incident_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Singleton
|
||||
# =============================================================================
|
||||
|
||||
_proposal_service: ProposalService | None = None
|
||||
|
||||
|
||||
def get_proposal_service() -> ProposalService:
|
||||
"""取得 ProposalService 實例 (Singleton)"""
|
||||
global _proposal_service
|
||||
if _proposal_service is None:
|
||||
_proposal_service = ProposalService()
|
||||
return _proposal_service
|
||||
Reference in New Issue
Block a user