""" Terminal Service ================ Phase 19.1 - Omni-Terminal Business Logic Phase 19.3 - OpenClaw 串流整合 遵循 leWOOOgo 積木化原則: - Service 層包含業務邏輯 - 使用 Protocol 定義介面 - 依賴注入 @see ADR-031 Omni-Terminal SSE Architecture @author Claude Code (首席架構師) @version 1.1.0 @date 2026-03-28 (台北時間) """ import asyncio import uuid from enum import Enum from typing import Protocol, runtime_checkable from src.core.constants import ( ERROR_MESSAGE_DISPLAY_LENGTH, MAX_APPROVAL_DISPLAY, SSE_DELAY_SECONDS, sanitize_error_message, ) from src.core.logging import get_logger from src.core.sse import EventType, SSEEvent, get_publisher from src.models.terminal import ( SpatialContext, TerminalIntentRequest, TerminalSession, TerminalSessionStatus, ) from src.repositories.k8s_repository import get_k8s_repository from src.services.approval_db import get_approval_service from src.services.openclaw import get_openclaw logger = get_logger("awoooi.terminal") # ============================================================================= # Intent Classification # ============================================================================= class IntentType(str, Enum): """Intent 類型分類""" # 查詢類 QUERY_STATUS = "query_status" # 狀態查詢 QUERY_METRICS = "query_metrics" # 指標查詢 QUERY_LOGS = "query_logs" # 日誌查詢 # 操作類 ACTION_APPROVAL = "action_approval" # 簽核操作 ACTION_RESTART = "action_restart" # 重啟操作 ACTION_SCALE = "action_scale" # 擴容操作 # 分析類 ANALYZE_RCA = "analyze_rca" # 根因分析 ANALYZE_INCIDENT = "analyze_incident" # 事件分析 # 通用 GENERAL = "general" # 一般對話 def classify_intent(intent: str) -> IntentType: """ 分類使用者意圖 Phase 19.3: 簡單的關鍵字匹配 未來可升級為 LLM-based 意圖識別 """ intent_lower = intent.lower() # 簽核相關 if any(kw in intent_lower for kw in ["approve", "簽核", "授權", "批准", "approval"]): return IntentType.ACTION_APPROVAL # 指標相關 if any(kw in intent_lower for kw in ["metric", "指標", "cpu", "memory", "記憶體", "usage"]): return IntentType.QUERY_METRICS # 狀態相關 if any(kw in intent_lower for kw in ["status", "狀態", "pod", "health", "健康"]): return IntentType.QUERY_STATUS # 日誌相關 if any(kw in intent_lower for kw in ["log", "日誌", "error", "錯誤", "exception"]): return IntentType.QUERY_LOGS # 重啟相關 if any(kw in intent_lower for kw in ["restart", "重啟", "rollout", "重新部署"]): return IntentType.ACTION_RESTART # 擴容相關 if any(kw in intent_lower for kw in ["scale", "擴容", "replica", "副本"]): return IntentType.ACTION_SCALE # RCA 分析 if any(kw in intent_lower for kw in ["rca", "root cause", "根因", "分析", "why", "為什麼"]): return IntentType.ANALYZE_RCA # Incident 分析 if any(kw in intent_lower for kw in ["incident", "事件", "alert", "告警"]): return IntentType.ANALYZE_INCIDENT return IntentType.GENERAL # ============================================================================= # Service Interface # ============================================================================= @runtime_checkable class ITerminalService(Protocol): """Terminal Service Interface""" async def process_intent( self, request: TerminalIntentRequest, user_id: str | None = None, ) -> str: """ 處理 Intent 請求 Returns: session_id """ ... async def get_session(self, session_id: str) -> TerminalSession | None: """取得 Session""" ... async def abort_session(self, session_id: str, reason: str | None = None) -> bool: """中斷 Session""" ... # ============================================================================= # Service Implementation # ============================================================================= class TerminalService: """ Terminal Service 實作 Phase 19.1: 使用模擬串流 (asyncio.sleep) 驗證架構 Phase 19.3: 接入真實 OpenClaw """ def __init__(self) -> None: self._sessions: dict[str, TerminalSession] = {} self._tasks: dict[str, asyncio.Task] = {} async def process_intent( self, request: TerminalIntentRequest, user_id: str | None = None, ) -> str: """ 處理 Intent 請求 1. 建立 Session 2. 啟動背景任務處理 Intent 3. 透過 SSE 發送事件 Returns: session_id """ # 建立或復用 Session session_id = request.session_id or str(uuid.uuid4()) session = TerminalSession( session_id=session_id, user_id=user_id, intent=request.intent, context=request.context, status=TerminalSessionStatus.PROCESSING, ) self._sessions[session_id] = session logger.info( "terminal_intent_received", session_id=session_id, intent=request.intent[:100], current_page=request.context.current_page, ) # 啟動背景任務 task = asyncio.create_task( self._run_analysis(session_id, request.intent, request.context) ) self._tasks[session_id] = task return session_id async def get_session(self, session_id: str) -> TerminalSession | None: """取得 Session""" return self._sessions.get(session_id) async def abort_session(self, session_id: str, reason: str | None = None) -> bool: """中斷 Session""" task = self._tasks.get(session_id) if task and not task.done(): task.cancel() logger.info( "terminal_session_aborted", session_id=session_id, reason=reason, ) session = self._sessions.get(session_id) if session: session.status = TerminalSessionStatus.ABORTED return True return False async def _run_analysis( self, session_id: str, intent: str, context: SpatialContext, ) -> None: """ 執行分析 (背景任務) Phase 19.1: 模擬串流 Phase 19.3: 接入 OpenClaw + 意圖分類 """ publisher = await get_publisher() topic = f"terminal:{session_id}" try: # Step 1: 空間感知 await self._publish_thought( publisher, topic, "System", f"空間感知啟動: {context.current_page}" ) await asyncio.sleep(0.3) # Step 2: 意圖分類 intent_type = classify_intent(intent) await self._publish_thought( publisher, topic, "Investigator", f"意圖識別: {intent_type.value}" ) await asyncio.sleep(0.3) # Step 3: 根據意圖類型分派處理 if intent_type == IntentType.ANALYZE_RCA: await self._handle_rca_analysis(publisher, topic, session_id, intent, context) elif intent_type == IntentType.ACTION_APPROVAL: await self._handle_approval_action(publisher, topic, session_id, intent) elif intent_type == IntentType.QUERY_METRICS: await self._handle_metrics_query(publisher, topic, session_id, intent) elif intent_type == IntentType.QUERY_STATUS: await self._handle_status_query(publisher, topic, session_id, intent) else: # 一般對話 - 使用 OpenClaw 回應 await self._handle_general_chat(publisher, topic, session_id, intent, context) # Step 4: 完成 await publisher.publish( SSEEvent( type=EventType.TERMINAL_COMPLETE, data={ "session_id": session_id, "message": "Analysis completed", }, ), topic=topic, ) # 更新 Session 狀態 session = self._sessions.get(session_id) if session: session.status = TerminalSessionStatus.COMPLETED logger.info( "terminal_analysis_completed", session_id=session_id, intent_type=intent_type.value, ) except asyncio.CancelledError: logger.info( "terminal_analysis_cancelled", session_id=session_id, ) raise except Exception as e: logger.error( "terminal_analysis_error", session_id=session_id, error=str(e), ) await publisher.publish( SSEEvent( type=EventType.TERMINAL_ERROR, data={ "session_id": session_id, "error_code": "ANALYSIS_ERROR", "message": str(e), "recoverable": True, }, ), topic=topic, ) session = self._sessions.get(session_id) if session: session.status = TerminalSessionStatus.ERROR # ========================================================================= # SSE Helper Methods # ========================================================================= async def _publish_thought( self, publisher, topic: str, agent: str, msg: str, ) -> None: """發送思考事件""" await publisher.publish( SSEEvent( type=EventType.TERMINAL_THOUGHT, data={"agent": agent, "msg": msg}, ), topic=topic, ) async def _publish_tool_call( self, publisher, topic: str, tool: str, status: str, result: dict | None = None, ) -> None: """發送工具呼叫事件""" data = {"tool": tool, "status": status} if result: data["result"] = result await publisher.publish( SSEEvent(type=EventType.TERMINAL_TOOL_CALL, data=data), topic=topic, ) async def _publish_render_ui( self, publisher, topic: str, component: str, props: dict, position: str = "inline", ) -> None: """發送 GenUI 渲染事件""" await publisher.publish( SSEEvent( type=EventType.TERMINAL_RENDER_UI, data={ "component": component, "props": props, "position": position, }, ), topic=topic, ) # ========================================================================= # Intent Handlers - Phase 19.3 # ========================================================================= async def _handle_rca_analysis( self, publisher, topic: str, session_id: str, intent: str, _context: SpatialContext, # Reserved for future spatial-aware RCA ) -> None: """處理 RCA 根因分析 (使用 OpenClaw)""" await self._publish_thought(publisher, topic, "Strategist", "啟動根因分析模式...") await asyncio.sleep(0.3) # 呼叫 SignOz 取得指標 await self._publish_tool_call(publisher, topic, "SignOz-Metrics", "executing") await asyncio.sleep(0.5) try: openclaw = get_openclaw() # 嘗試取得 SignOz 上下文 metrics, trace_url = await openclaw.get_signoz_context( service_name="awoooi-api", namespace="awoooi", ) if metrics: await self._publish_tool_call( publisher, topic, "SignOz-Metrics", "completed", result={ "rps": round(metrics.rps, 2), "error_rate": f"{metrics.error_rate:.2%}", "p99_latency": f"{metrics.p99_latency_ms:.0f}ms", } ) else: await self._publish_tool_call( publisher, topic, "SignOz-Metrics", "completed", result={"status": "No metrics available"} ) except Exception as e: logger.warning("signoz_fetch_failed", error=str(e)) await self._publish_tool_call( publisher, topic, "SignOz-Metrics", "failed", result={"error": str(e)[:100]} ) await asyncio.sleep(0.3) # 呼叫 OpenClaw AI 分析 await self._publish_thought(publisher, topic, "OpenClaw", "正在進行 AI 分析...") await self._publish_tool_call(publisher, topic, "OpenClaw-RCA", "executing") await asyncio.sleep(0.5) try: openclaw = get_openclaw() result, provider, raw_response, _, _ = await openclaw.analyze_alert({ "alert_type": "user_query", "description": intent, "target_resource": "awoooi-api", "namespace": "awoooi", }) if result: await self._publish_tool_call( publisher, topic, "OpenClaw-RCA", "completed", result={"provider": provider, "confidence": result.confidence} ) await asyncio.sleep(0.3) # 顯示分析結果 await self._publish_thought( publisher, topic, "OpenClaw", f"[{provider}] 分析完成,信心度: {result.confidence:.0%}" ) await self._publish_thought( publisher, topic, "Strategist", f"建議動作: {result.action_title}\n理由: {result.reasoning[:200]}..." ) # 如果需要授權,顯示 ApprovalCard if result.requires_approval: await self._publish_render_ui( publisher, topic, "ApprovalCard", { "approvalId": f"APR-{session_id[:8].upper()}", "riskLevel": result.risk_level.value.upper(), "kubectl": result.kubectl_command or "kubectl get pods -n awoooi", } ) else: await self._publish_tool_call( publisher, topic, "OpenClaw-RCA", "completed", result={"provider": provider, "status": "no_action_needed"} ) await self._publish_thought( publisher, topic, "OpenClaw", "分析完成,目前系統運行正常,無需採取行動。" ) except Exception as e: logger.error("openclaw_rca_failed", error=str(e)) await self._publish_tool_call( publisher, topic, "OpenClaw-RCA", "failed", result={"error": str(e)[:100]} ) await self._publish_thought( publisher, topic, "System", f"AI 分析暫時不可用,請稍後重試。錯誤: {str(e)[:50]}" ) async def _handle_approval_action( self, publisher, topic: str, _session_id: str, # Reserved for session-specific approval tracking _intent: str, # Reserved for intent-specific approval filtering ) -> None: """ 處理簽核操作 Phase 19.4: 整合真實 Approval API @author Claude Code @date 2026-03-30 (台北時間) """ await self._publish_thought(publisher, topic, "Executor", "檢查待簽核項目...") await self._publish_tool_call(publisher, topic, "Approval-Query", "executing") try: approval_service = get_approval_service() pending_approvals = await approval_service.get_pending_approvals() await self._publish_tool_call( publisher, topic, "Approval-Query", "completed", result={"pending_count": len(pending_approvals)} ) await asyncio.sleep(SSE_DELAY_SECONDS) if not pending_approvals: await self._publish_thought( publisher, topic, "Executor", "目前沒有待簽核的項目。" ) return # 顯示待簽核清單摘要 summary_lines = [f"發現 {len(pending_approvals)} 個待簽核項目:"] for i, approval in enumerate(pending_approvals[:MAX_APPROVAL_DISPLAY], 1): risk = approval.risk_level.value.upper() if approval.risk_level else "UNKNOWN" summary_lines.append( f" {i}. [{risk}] {approval.action[:50]}" ) if len(pending_approvals) > MAX_APPROVAL_DISPLAY: remaining = len(pending_approvals) - MAX_APPROVAL_DISPLAY summary_lines.append(f" ... 還有 {remaining} 個") await self._publish_thought( publisher, topic, "Executor", "\n".join(summary_lines) ) # 渲染第一個待簽核項目的 ApprovalCard first_approval = pending_approvals[0] risk_level = first_approval.risk_level.value.upper() if first_approval.risk_level else "MEDIUM" # 從 metadata 取得 kubectl 指令 (如果有) kubectl_cmd = "kubectl get pods -n awoooi" if first_approval.metadata: kubectl_cmd = first_approval.metadata.get("kubectl_command", kubectl_cmd) await self._publish_render_ui( publisher, topic, "ApprovalCard", { "approvalId": str(first_approval.id)[:8].upper(), "riskLevel": risk_level, "kubectl": kubectl_cmd, "description": first_approval.description[:100] if first_approval.description else "", "requiredSignatures": first_approval.required_signatures, "currentSignatures": first_approval.current_signatures, } ) except Exception as e: logger.error("approval_query_failed", error=str(e)) await self._publish_tool_call( publisher, topic, "Approval-Query", "failed", result={"error": sanitize_error_message(str(e))} ) await self._publish_thought( publisher, topic, "System", f"查詢簽核項目失敗: {sanitize_error_message(str(e), ERROR_MESSAGE_DISPLAY_LENGTH)}" ) async def _handle_metrics_query( self, publisher, topic: str, _session_id: str, # Reserved for session-specific caching _intent: str, # Reserved for intent-specific metric filtering ) -> None: """處理指標查詢""" await self._publish_thought(publisher, topic, "Monitor", "擷取即時指標...") await self._publish_tool_call(publisher, topic, "SignOz-Query", "executing") await asyncio.sleep(0.5) try: openclaw = get_openclaw() metrics, _ = await openclaw.get_signoz_context( service_name="awoooi-api", namespace="awoooi", ) if metrics: await self._publish_tool_call( publisher, topic, "SignOz-Query", "completed", result={"status": "success"} ) await asyncio.sleep(0.3) # 渲染 MetricsSummaryCard await self._publish_render_ui( publisher, topic, "MetricsSummaryCard", { "rps": round(metrics.rps, 2), "errorRate": f"{metrics.error_rate:.2%}", "p99Latency": f"{metrics.p99_latency_ms:.0f}ms", "status": "healthy" if metrics.error_rate < 0.05 else "warning", } ) else: await self._publish_tool_call( publisher, topic, "SignOz-Query", "completed", result={"status": "no_data"} ) await self._publish_thought( publisher, topic, "Monitor", "目前無法取得即時指標,請確認 SignOz 服務狀態。" ) except Exception as e: logger.warning("metrics_query_failed", error=str(e)) await self._publish_tool_call( publisher, topic, "SignOz-Query", "failed", result={"error": str(e)[:100]} ) # Fallback: 顯示模擬數據 await self._publish_render_ui( publisher, topic, "MetricsSummaryCard", { "cpu": 67, "memory": 72, "pods": {"running": 11, "total": 12}, "status": "healthy", } ) async def _handle_status_query( self, publisher, topic: str, _session_id: str, # Reserved for session-specific caching _intent: str, # Reserved for intent-specific status filtering ) -> None: """ 處理狀態查詢 Phase 19.4: 整合真實 K8s API P1 改進: 使用 K8sRepository (積木化) @author Claude Code @date 2026-03-30 (台北時間) """ await self._publish_thought(publisher, topic, "Monitor", "檢查系統狀態...") await self._publish_tool_call(publisher, topic, "K8s-Status", "executing") try: # P1 改進: 使用 K8sRepository 代替直接呼叫 K8s client k8s_repo = get_k8s_repository() # 檢查 K8s 可用性 if not await k8s_repo.is_available(): raise RuntimeError("K8s API unavailable") # 取得 Pod 狀態摘要 namespace = "awoooi" pod_summary = await k8s_repo.get_pod_status_summary(namespace) deployments = await k8s_repo.list_deployments(namespace) # 統計 pods_total = pod_summary["total"] pods_running = pod_summary["running"] pods_ready = pods_running # running 已包含 ready 檢查 problem_pods = pod_summary["problem_pods"] deployments_total = len(deployments) deployments_healthy = sum( 1 for d in deployments if d["ready_replicas"] == d["replicas"] ) await self._publish_tool_call( publisher, topic, "K8s-Status", "completed", result={ "pods_running": pods_running, "pods_ready": pods_ready, "pods_total": pods_total, "deployments_healthy": deployments_healthy, "deployments_total": deployments_total, } ) await asyncio.sleep(SSE_DELAY_SECONDS) # 組合狀態訊息 status_lines = [ f"系統狀態: {pods_ready}/{pods_total} Pods 就緒", f"Deployments: {deployments_healthy}/{deployments_total} 健康", ] if problem_pods: status_lines.append(f"\n問題 Pods ({len(problem_pods)}):") for pp in problem_pods[:3]: # 最多顯示 3 個 status_lines.append(f" - {pp['name']}: {pp['phase']}") if len(problem_pods) > 3: status_lines.append(f" ... 還有 {len(problem_pods) - 3} 個") else: status_lines.append("\n所有 Pods 運行正常。") await self._publish_thought( publisher, topic, "Monitor", "\n".join(status_lines) ) except Exception as e: logger.error("k8s_status_query_failed", error=str(e)) await self._publish_tool_call( publisher, topic, "K8s-Status", "failed", result={"error": sanitize_error_message(str(e))} ) await self._publish_thought( publisher, topic, "System", f"K8s 狀態查詢失敗: {sanitize_error_message(str(e), ERROR_MESSAGE_DISPLAY_LENGTH)}\n" "請確認 K8s 連線配置。" ) async def _handle_general_chat( self, publisher, topic: str, _session_id: str, # Reserved for conversation history intent: str, context: SpatialContext, ) -> None: """處理一般對話""" await self._publish_thought(publisher, topic, "Assistant", "思考中...") await asyncio.sleep(0.5) # 簡單的規則式回應 if "你好" in intent or "hello" in intent.lower(): response = "你好!我是 AWOOOI 的 AI 助手。我可以幫你:\n" \ "• 查詢系統狀態 (輸入: status)\n" \ "• 查看指標 (輸入: metrics)\n" \ "• 進行根因分析 (輸入: 為什麼 xxx)\n" \ "• 處理簽核 (輸入: approval)" elif "help" in intent.lower() or "幫助" in intent: response = "🛠️ AWOOOI Omni-Terminal 指令:\n" \ "• `status` - 查詢 K8s Pod 狀態\n" \ "• `metrics` - 查看 SignOz 指標\n" \ "• `為什麼 [問題]` - AI 根因分析\n" \ "• `approval` - 查看待簽核項目\n" \ "• `restart [服務]` - 重啟服務" else: response = f"收到指令: '{intent}'\n" \ f"目前位置: {context.current_page}\n\n" \ "提示: 輸入 'help' 查看可用指令。" await self._publish_thought(publisher, topic, "Assistant", response) # ============================================================================= # Dependency Injection (FastAPI Depends Pattern) # ============================================================================= # Phase 19 首席架構師審查 - 修正 Singleton 反模式 # @author Claude Code (首席架構師) # @date 2026-03-28 19:30 (台北時間) # ============================================================================= async def get_terminal_service() -> TerminalService: """ FastAPI 依賴注入函數 遵循 leWOOOgo 積木化原則: - 每次請求建立新實例 (無狀態設計) - 支援測試時注入 Mock - 由 FastAPI 管理生命週期 使用方式: @router.post("/intent") async def submit_intent( request: TerminalIntentRequest, service: TerminalService = Depends(get_terminal_service), ) -> TerminalIntentResponse: ... """ return TerminalService()