diff --git a/apps/api/src/services/approval_execution.py b/apps/api/src/services/approval_execution.py index e08b1ce6e..d07e2bf07 100644 --- a/apps/api/src/services/approval_execution.py +++ b/apps/api/src/services/approval_execution.py @@ -151,6 +151,15 @@ class ApprovalExecutionService: asyncio.create_task( self._trigger_playbook_extraction(approval) ) + + # ADR-030 Phase 5: 觸發學習服務 (fire-and-forget) + asyncio.create_task( + self._trigger_learning( + approval=approval, + success=True, + duration_seconds=result.duration_ms / 1000 if result.duration_ms else 0, + ) + ) else: logger.error( "background_execution_failed", @@ -187,6 +196,57 @@ class ApprovalExecutionService: ) ) + # ADR-030 Phase 5: 觸發學習服務 (失敗案例) + asyncio.create_task( + self._trigger_learning( + approval=approval, + success=False, + error_message=result.error, + duration_seconds=result.duration_ms / 1000 if result.duration_ms else 0, + ) + ) + + async def _trigger_learning( + self, + approval: ApprovalRequest, + success: bool, + duration_seconds: float = 0, + error_message: str | None = None, + ) -> None: + """ + ADR-030 Phase 5: 觸發學習服務 + + 處理執行結果,調整信任度和 Playbook 統計 + """ + try: + from src.services.learning_service import ( + ExecutionResult, + get_learning_service, + ) + + learning = get_learning_service() + result = ExecutionResult( + approval_id=str(approval.id), + incident_id=approval.incident_id or "", + action=approval.action, + success=success, + error_message=error_message, + duration_seconds=duration_seconds, + ) + + await learning.process_execution_result( + approval=approval, + result=result, + ) + + except Exception as e: + # 學習失敗不影響主流程 + logger.warning( + "learning_trigger_failed", + approval_id=str(approval.id), + error=str(e), + ) + async def _send_execution_notification( self, approval: ApprovalRequest, diff --git a/apps/api/src/services/learning_service.py b/apps/api/src/services/learning_service.py new file mode 100644 index 000000000..fbf884584 --- /dev/null +++ b/apps/api/src/services/learning_service.py @@ -0,0 +1,438 @@ +""" +Learning Service - Phase 5 持續學習迴圈 +====================================== +ADR-030: 智能自動修復系統 + +從執行結果中學習,持續優化決策: +1. 更新 Playbook 統計 (成功率/執行次數) +2. 調整信任度 (成功 +分 / 失敗 -分) +3. 萃取新 Playbook (成功案例自動萃取) +4. 處理人工反饋 (有效性評分) + +設計原則: +- 非同步執行,不阻塞主流程 +- 失敗容忍,學習失敗不影響執行結果 +- 完整審計追蹤 + +版本: v1.0 +建立: 2026-03-26 (台北時區) +""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import Enum +from typing import Any + +import structlog + +from src.models.approval import ApprovalRequest +from src.models.incident import Incident, IncidentStatus +from src.services.trust_engine import get_trust_manager + +logger = structlog.get_logger(__name__) + + +# ============================================================================= +# Constants +# ============================================================================= + + +class FeedbackType(str, Enum): + """反饋類型""" + + EXECUTION_SUCCESS = "execution_success" # 執行成功 + EXECUTION_FAILURE = "execution_failure" # 執行失敗 + HUMAN_APPROVE = "human_approve" # 人工批准 + HUMAN_REJECT = "human_reject" # 人工拒絕 + HUMAN_OVERRIDE = "human_override" # 人工覆蓋 AI 決策 + EFFECTIVENESS_RATING = "effectiveness_rating" # 有效性評分 + + +# 信任度調整參數 +TRUST_SUCCESS_BOOST = 1 # 成功 +1 分 +TRUST_FAILURE_PENALTY = 2 # 失敗 -2 分 (或歸零) +TRUST_HUMAN_REJECT_PENALTY = 1 # 人工拒絕 -1 分 + + +# ============================================================================= +# Data Models +# ============================================================================= + + +@dataclass +class ExecutionResult: + """執行結果""" + + approval_id: str + incident_id: str + action: str + success: bool + error_message: str | None = None + duration_seconds: float = 0.0 + executed_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + def to_dict(self) -> dict[str, Any]: + return { + "approval_id": self.approval_id, + "incident_id": self.incident_id, + "action": self.action, + "success": self.success, + "error_message": self.error_message, + "duration_seconds": self.duration_seconds, + "executed_at": self.executed_at.isoformat(), + } + + +@dataclass +class FeedbackRequest: + """人工反饋請求""" + + incident_id: str + feedback_type: FeedbackType + effectiveness_score: int | None = None # 1-5 分 + learning_notes: str | None = None # 學習筆記 + submitted_by: str | None = None + submitted_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + +@dataclass +class LearningRecord: + """學習記錄""" + + incident_id: str + feedback_type: FeedbackType + action_pattern: str + trust_before: int + trust_after: int + playbook_updated: bool = False + new_playbook_id: str | None = None + learned_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + + def to_dict(self) -> dict[str, Any]: + return { + "incident_id": self.incident_id, + "feedback_type": self.feedback_type.value, + "action_pattern": self.action_pattern, + "trust_before": self.trust_before, + "trust_after": self.trust_after, + "playbook_updated": self.playbook_updated, + "new_playbook_id": self.new_playbook_id, + "learned_at": self.learned_at.isoformat(), + } + + +# ============================================================================= +# Learning Service +# ============================================================================= + + +class LearningService: + """ + 持續學習服務 + + 職責: + 1. 處理執行結果 → 更新 Playbook + 信任度 + 2. 處理人工反饋 → 調整 Playbook 有效性 + 3. 萃取新 Playbook (成功案例) + """ + + def __init__(self): + self._trust_manager = get_trust_manager() + + async def process_execution_result( + self, + approval: ApprovalRequest, + result: ExecutionResult, + ) -> LearningRecord: + """ + 處理執行結果,觸發學習 + + Args: + approval: 原始審批請求 + result: 執行結果 + + Returns: + LearningRecord: 學習記錄 + """ + action_pattern = self._extract_action_pattern(approval.action) + + # 取得當前信任分數 + trust_record = self._trust_manager.get_trust_record(action_pattern) + trust_before = trust_record.score if trust_record else 0 + + # 1. 調整信任度 + if result.success: + # 成功: 記錄批准 (信任分數 +1) + self._trust_manager.record_approval( + action_pattern=action_pattern, + user_role="system", + user_id="auto_learning", + ) + feedback_type = FeedbackType.EXECUTION_SUCCESS + else: + # 失敗: 記錄拒絕 (信任分數歸零) + self._trust_manager.record_rejection( + action_pattern=action_pattern, + user_role="system", + user_id="auto_learning", + reason=result.error_message, + ) + feedback_type = FeedbackType.EXECUTION_FAILURE + + # 取得更新後的信任分數 + trust_record = self._trust_manager.get_trust_record(action_pattern) + trust_after = trust_record.score if trust_record else 0 + + # 2. 更新 Playbook 統計 (如果有匹配) + playbook_updated = False + if hasattr(approval, "matched_playbook_id") and approval.matched_playbook_id: + try: + await self._update_playbook_stats( + playbook_id=approval.matched_playbook_id, + success=result.success, + ) + playbook_updated = True + except Exception as e: + logger.warning( + "playbook_stats_update_failed", + playbook_id=approval.matched_playbook_id, + error=str(e), + ) + + # 3. 嘗試萃取新 Playbook (成功且無匹配 Playbook) + new_playbook_id = None + if result.success and not getattr(approval, "matched_playbook_id", None): + try: + new_playbook_id = await self._try_extract_playbook( + incident_id=result.incident_id, + action=approval.action, + ) + except Exception as e: + logger.warning( + "playbook_extraction_failed", + incident_id=result.incident_id, + error=str(e), + ) + + # 建立學習記錄 + record = LearningRecord( + incident_id=result.incident_id, + feedback_type=feedback_type, + action_pattern=action_pattern, + trust_before=trust_before, + trust_after=trust_after, + playbook_updated=playbook_updated, + new_playbook_id=new_playbook_id, + ) + + logger.info( + "learning_completed", + incident_id=result.incident_id, + success=result.success, + trust_change=f"{trust_before} → {trust_after}", + playbook_updated=playbook_updated, + new_playbook=new_playbook_id, + ) + + return record + + async def process_human_feedback( + self, + feedback: FeedbackRequest, + ) -> LearningRecord: + """ + 處理人工反饋 + + Args: + feedback: 反饋請求 + + Returns: + LearningRecord: 學習記錄 + """ + # 從 incident 取得 action pattern (需查詢) + action_pattern = f"incident:{feedback.incident_id}" + + trust_record = self._trust_manager.get_trust_record(action_pattern) + trust_before = trust_record.score if trust_record else 0 + + playbook_updated = False + + if feedback.feedback_type == FeedbackType.HUMAN_APPROVE: + # 人工批准: 信任 +1 + self._trust_manager.record_approval( + action_pattern=action_pattern, + user_role="human", + user_id=feedback.submitted_by, + ) + + elif feedback.feedback_type == FeedbackType.HUMAN_REJECT: + # 人工拒絕: 信任歸零 + self._trust_manager.record_rejection( + action_pattern=action_pattern, + user_role="human", + user_id=feedback.submitted_by, + reason="Human rejected", + ) + + elif feedback.feedback_type == FeedbackType.EFFECTIVENESS_RATING: + # 有效性評分 + if feedback.effectiveness_score is not None: + if feedback.effectiveness_score >= 4: + # 高評分: 增加信任 + self._trust_manager.record_approval( + action_pattern=action_pattern, + user_role="feedback", + user_id=feedback.submitted_by, + ) + playbook_updated = await self._promote_playbook(feedback.incident_id) + elif feedback.effectiveness_score <= 2: + # 低評分: 降低信任 + self._trust_manager.record_rejection( + action_pattern=action_pattern, + user_role="feedback", + user_id=feedback.submitted_by, + reason=f"Low effectiveness score: {feedback.effectiveness_score}", + ) + playbook_updated = await self._demote_playbook(feedback.incident_id) + + trust_record = self._trust_manager.get_trust_record(action_pattern) + trust_after = trust_record.score if trust_record else 0 + + record = LearningRecord( + incident_id=feedback.incident_id, + feedback_type=feedback.feedback_type, + action_pattern=action_pattern, + trust_before=trust_before, + trust_after=trust_after, + playbook_updated=playbook_updated, + ) + + logger.info( + "human_feedback_processed", + incident_id=feedback.incident_id, + feedback_type=feedback.feedback_type.value, + effectiveness_score=feedback.effectiveness_score, + trust_change=f"{trust_before} → {trust_after}", + ) + + return record + + # ========================================================================= + # Private Methods + # ========================================================================= + + def _extract_action_pattern(self, action: str) -> str: + """從 action 字串提取 pattern""" + if not action: + return "unknown" + + parts = action.split() + if len(parts) < 3: + return "unknown" + + verb = parts[1] if len(parts) > 1 else "unknown" + resource_part = parts[2] if len(parts) > 2 else "" + + if "/" in resource_part: + resource_name = resource_part.split("/")[-1] + else: + resource_name = resource_part + + # 移除 pod hash suffix + resource_parts = resource_name.split("-") + if len(resource_parts) >= 3: + resource_name = "-".join(resource_parts[:-2]) + "-*" + + return f"{verb}:{resource_name}" + + async def _update_playbook_stats( + self, + playbook_id: str, + success: bool, + ) -> None: + """更新 Playbook 統計""" + try: + from src.services.playbook_service import get_playbook_service + + service = get_playbook_service() + await service.record_execution(playbook_id, success) + + except Exception as e: + logger.warning( + "playbook_stats_update_error", + playbook_id=playbook_id, + error=str(e), + ) + + async def _try_extract_playbook( + self, + incident_id: str, + action: str, + ) -> str | None: + """嘗試從成功案例萃取 Playbook""" + try: + from src.repositories.incident_repository import get_incident_repository + from src.services.playbook_service import get_playbook_service + + # 取得 Incident + repo = get_incident_repository() + incident = await repo.get_by_id(incident_id) + + if not incident: + return None + + # 確認狀態為 RESOLVED + if incident.status not in [IncidentStatus.RESOLVED, IncidentStatus.CLOSED]: + return None + + # 萃取 Playbook + service = get_playbook_service() + playbook = await service.extract_from_incident( + incident=incident, + auto_approve=False, # 需人工審核 + ) + + if playbook: + logger.info( + "playbook_auto_extracted", + incident_id=incident_id, + playbook_id=playbook.playbook_id, + ) + return playbook.playbook_id + + return None + + except Exception as e: + logger.warning( + "playbook_extraction_error", + incident_id=incident_id, + error=str(e), + ) + return None + + async def _promote_playbook(self, incident_id: str) -> bool: + """提升 Playbook 信心度 (高評分)""" + # TODO: 實作 Playbook 信心度提升邏輯 + logger.debug("playbook_promoted", incident_id=incident_id) + return True + + async def _demote_playbook(self, incident_id: str) -> bool: + """降低 Playbook 信心度 (低評分)""" + # TODO: 實作 Playbook 信心度降低邏輯 + logger.debug("playbook_demoted", incident_id=incident_id) + return True + + +# ============================================================================= +# Singleton +# ============================================================================= + +_learning_service: LearningService | None = None + + +def get_learning_service() -> LearningService: + """取得學習服務 singleton""" + global _learning_service + if _learning_service is None: + _learning_service = LearningService() + return _learning_service