diff --git a/apps/api/src/services/ai_providers/__init__.py b/apps/api/src/services/ai_providers/__init__.py index 5711f6a10..0fc081bc7 100644 --- a/apps/api/src/services/ai_providers/__init__.py +++ b/apps/api/src/services/ai_providers/__init__.py @@ -10,5 +10,6 @@ AI Provider Registry & Dual-Track Routing Architecture """ from src.services.ai_providers.interfaces import AIProvider, AIResult +from src.services.ai_providers.nemotron import NemotronProvider -__all__ = ["AIProvider", "AIResult"] +__all__ = ["AIProvider", "AIResult", "NemotronProvider"] diff --git a/apps/api/src/services/ai_providers/nemotron.py b/apps/api/src/services/ai_providers/nemotron.py new file mode 100644 index 000000000..18168eaf3 --- /dev/null +++ b/apps/api/src/services/ai_providers/nemotron.py @@ -0,0 +1,264 @@ +""" +Nemotron Tool Calling Provider - Phase 24 ADR-052 +================================================== +封裝 NVIDIA Nemotron Tool Calling 能力,供 AIRouter 路由。 + +搬移自: openclaw.py _call_nemotron_tools (L1623-1785) +特性: K8s Tool Calling,83.3% 精準度,HITL 高風險保護 + +架構鐵律: AIRouter → NemotronProvider → NvidiaProvider → NVIDIA NIM + +2026-04-02 Claude Code: Phase 24 B3 NemotronProvider 抽取 +""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any + +import structlog + +from src.core.config import get_settings +from src.services.ai_providers.interfaces import AIProvider, AIResult, is_provider_enabled_by_env + +logger = structlog.get_logger(__name__) +settings = get_settings() + +# 預定義 K8s Tool Definitions (搬移自 openclaw.py _call_nemotron_tools) +_K8S_TOOLS: list[dict] = [ + { + "type": "function", + "function": { + "name": "restart_deployment", + "description": "重啟 Deployment (rollout restart)", + "parameters": { + "type": "object", + "properties": { + "deployment_name": {"type": "string"}, + "namespace": {"type": "string", "default": "awoooi-prod"}, + }, + "required": ["deployment_name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "scale_deployment", + "description": "調整 Deployment 副本數", + "parameters": { + "type": "object", + "properties": { + "deployment_name": {"type": "string"}, + "replicas": {"type": "integer"}, + "namespace": {"type": "string", "default": "awoooi-prod"}, + }, + "required": ["deployment_name", "replicas"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "delete_pod", + "description": "刪除 Pod (強制重建)", + "parameters": { + "type": "object", + "properties": { + "pod_name": {"type": "string"}, + "namespace": {"type": "string", "default": "awoooi-prod"}, + }, + "required": ["pod_name"], + }, + }, + }, +] + + +class NemotronProvider: + """ + NVIDIA Nemotron Tool Calling Provider + + privacy_level: cloud (NIM 是雲端 GPU,首席架構師 Q2 裁示) + capabilities: tool_calling (K8s 操作決策專用) + + 呼叫路徑: + AIRouter → NemotronProvider → NvidiaProvider (ADR-036) → NVIDIA NIM API + """ + + def __init__(self) -> None: + # NvidiaProvider 採用懶加載,避免 import-time 副作用 + self._nvidia: Any | None = None + + def _get_nvidia(self) -> Any: + if self._nvidia is None: + from src.services.nvidia_provider import get_nvidia_provider + self._nvidia = get_nvidia_provider() + return self._nvidia + + @property + def name(self) -> str: + return "nemotron" + + @property + def is_enabled(self) -> bool: + return is_provider_enabled_by_env("nemotron") + + @property + def capabilities(self) -> set[str]: + return {"tool_calling"} + + @property + def privacy_level(self) -> str: + # NIM 是雲端 GPU,首席架構師 Q2 裁示: cloud 等級 + return "cloud" + + async def analyze( + self, + prompt: str, + context: dict[str, Any] | None = None, + ) -> AIResult: + """ + 執行 K8s Tool Calling 分析 + + context 結構: + - incident_id: str (Incident ID) + - reasoning: str (OpenClaw 推理結果) + - target_resource: str (目標資源名稱) + - suggested_action: str (OpenClaw 建議操作) + - namespace: str (K8s namespace,預設 awoooi-prod) + + 回傳 AIResult.raw_response 為 JSON 字串: + {"tools": [...], "validation": str, "latency_ms": float} + """ + import json as _json + + start = time.perf_counter() + context = context or {} + + # 從 context 取出欄位,fallback 到 prompt + incident_id = context.get("incident_id", "UNKNOWN") + reasoning = context.get("reasoning", prompt) + target_resource = context.get("target_resource", "unknown") + suggested_action = context.get("suggested_action", prompt) + namespace = context.get("namespace", "awoooi-prod") + + tool_prompt = f"""根據以下 AI 分析結果,生成對應的 kubectl 操作指令: + +## Incident 上下文 +- Incident ID: {incident_id} +- 目標資源: {target_resource} +- Namespace: {namespace} + +## OpenClaw 分析 +- 建議操作: {suggested_action} +- 推理過程: {reasoning[:500]} + +## 你的任務 +生成最適合的 kubectl 操作。如果操作有風險,請標註驗證步驟。 +""" + + try: + timeout = getattr(settings, "NEMOTRON_TIMEOUT_SECONDS", 30) + nvidia = self._get_nvidia() + + result = await asyncio.wait_for( + nvidia.tool_call( + messages=[{"role": "user", "content": tool_prompt}], + tools=_K8S_TOOLS, + ), + timeout=timeout, + ) + + latency_ms = (time.perf_counter() - start) * 1000 + + # 解析 Tool Calling 結果 (搬移自 openclaw.py L1734-1756) + tools: list[dict] = [] + validation_passed = True + + if result and hasattr(result, "tool_calls") and result.tool_calls: + for tc in result.tool_calls: + tool_entry = { + "tool": tc.tool_name if hasattr(tc, "tool_name") else str(tc.get("name", "unknown")), + "args": tc.arguments if hasattr(tc, "arguments") else tc.get("arguments", {}), + "valid": tc.valid if hasattr(tc, "valid") else True, + } + tools.append(tool_entry) + if not tool_entry["valid"]: + validation_passed = False + elif result and isinstance(result, dict) and result.get("tool_calls"): + for tc in result["tool_calls"]: + tool_entry = { + "tool": tc.get("name", "unknown"), + "args": tc.get("arguments", {}), + "valid": True, + } + tools.append(tool_entry) + + validation_status = "✅ 驗證通過" if validation_passed and tools else "❌ 驗證失敗" + + payload = { + "tools": tools, + "validation": validation_status, + "latency_ms": latency_ms, + } + + logger.info( + "nemotron_provider_success", + incident_id=incident_id, + tool_count=len(tools), + validation=validation_status, + latency_ms=round(latency_ms, 1), + ) + + return AIResult( + raw_response=_json.dumps(payload, ensure_ascii=False), + success=True, + provider=self.name, + latency_ms=latency_ms, + ) + + except asyncio.TimeoutError: + latency_ms = (time.perf_counter() - start) * 1000 + timeout_secs = getattr(settings, "NEMOTRON_TIMEOUT_SECONDS", 30) + logger.warning( + "nemotron_provider_timeout", + incident_id=incident_id, + timeout_seconds=timeout_secs, + latency_ms=round(latency_ms, 1), + ) + return AIResult( + raw_response="", + success=False, + provider=self.name, + latency_ms=latency_ms, + error=f"Tool calling timeout after {timeout_secs}s", + ) + + except Exception as e: + latency_ms = (time.perf_counter() - start) * 1000 + logger.error( + "nemotron_provider_error", + incident_id=incident_id, + error=str(e), + latency_ms=round(latency_ms, 1), + ) + return AIResult( + raw_response="", + success=False, + provider=self.name, + latency_ms=latency_ms, + error=str(e), + ) + + async def health_check(self) -> bool: + """健康檢查:嘗試初始化 NvidiaProvider""" + try: + nvidia = self._get_nvidia() + # NvidiaProvider 有 health_check 就用,沒有就只驗證能實例化 + if hasattr(nvidia, "health_check"): + return await nvidia.health_check() + return True + except Exception: + return False diff --git a/apps/web/src/components/incident/incident-card.tsx b/apps/web/src/components/incident/incident-card.tsx index a6255d09c..5ebf2ddc8 100644 --- a/apps/web/src/components/incident/incident-card.tsx +++ b/apps/web/src/components/incident/incident-card.tsx @@ -72,6 +72,62 @@ function formatDuration(createdAt: string | undefined): string { } } +// ============================================================================= +// 2026-04-02 Claude Code: Phase R-UI2 handleApprove/Reject 重複邏輯抽取 +// useApprovalAction — 統一 setup/teardown:loading 狀態、timeout、error 處理 +// action callback 負責差異邏輯(API call),並回傳最終 ButtonState +// 若 action 未回傳(void),hook 維持 idle +// ============================================================================= + +interface ApprovalActionResult { + execute: () => Promise + buttonState: ButtonState + errorMessage: string | null + reset: () => void +} + +function useApprovalAction( + action: () => Promise, + timeoutMs: number, + timeoutMessage: string, +): ApprovalActionResult { + const [buttonState, setButtonState] = useState('idle') + const [errorMessage, setErrorMessage] = useState(null) + const timeoutRef = useRef(null) + + useEffect(() => { + return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) } + }, []) + + const execute = useCallback(async () => { + if (buttonState === 'loading') return + setButtonState('loading') + setErrorMessage(null) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + setButtonState('timeout') + setErrorMessage(timeoutMessage) + }, timeoutMs) + + try { + const finalState = await action() + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null } + setButtonState(finalState ?? 'idle') + } catch (error) { + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null } + setButtonState('error') + setErrorMessage(error instanceof Error ? error.message : String(error)) + } + }, [action, buttonState, timeoutMs, timeoutMessage]) + + const reset = useCallback(() => { + setButtonState('idle') + setErrorMessage(null) + }, []) + + return { execute, buttonState, errorMessage, reset } +} + // ============================================================================= // Component // ============================================================================= @@ -80,15 +136,8 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC const t = useTranslations('incident.card') const { csrfToken } = useCSRF() - const [buttonState, setButtonState] = useState('idle') - const [errorMessage, setErrorMessage] = useState(null) const [currentProposalId, setCurrentProposalId] = useState(null) const [aiExpanded, setAiExpanded] = useState(false) - const timeoutRef = useRef(null) - - useEffect(() => { - return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) } - }, []) const incidentStatus = incident.status as string const sev = incident.severity as keyof typeof SEV_CONFIG @@ -104,75 +153,59 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC const decisionAction = decision?.proposal_data?.action ?? '' const decisionReasoning = decision?.proposal_data?.reasoning ?? '' - // ── handleApprove(移植自 DualStateIncidentCard Phase 6.5)────────────── - const handleApprove = useCallback(async () => { - if (!isDecisionReady || buttonState === 'loading') return - setButtonState('loading') - setErrorMessage(null) - if (timeoutRef.current) clearTimeout(timeoutRef.current) - timeoutRef.current = setTimeout(() => { - setButtonState('timeout') - setErrorMessage(t('timeoutMessage')) - }, EXECUTION_TIMEOUT_MS) - - try { - let approvalId = currentProposalId - if (!approvalId && decision?.token) { - const proposalResult = await apiClient.generateProposal(incident.incident_id) - if (!proposalResult.success || !proposalResult.proposal) { - throw new Error(proposalResult.message || 'Failed to generate proposal') - } - approvalId = proposalResult.proposal.id - setCurrentProposalId(approvalId) + // ── 解析 proposalId(approve/reject 共用前置步驟)───────────────────────── + const resolveProposalId = useCallback(async (): Promise => { + let approvalId = currentProposalId + if (!approvalId && decision?.token) { + const proposalResult = await apiClient.generateProposal(incident.incident_id) + if (!proposalResult.success || !proposalResult.proposal) { + throw new Error(proposalResult.message || 'Failed to generate proposal') } - if (!approvalId) throw new Error('No approval ID available') - const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via AI Center', csrfToken) - if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null } - const approvalStatus = result.approval?.status?.toLowerCase() - if (approvalStatus === 'approved') { - setButtonState('approved') - onApprovalChange?.(approvalId, 'approved') - } else { - setButtonState('idle') - } - } catch (error) { - if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null } - setButtonState('error') - setErrorMessage(error instanceof Error ? error.message : String(error)) + approvalId = proposalResult.proposal.id + setCurrentProposalId(approvalId) } - }, [currentProposalId, decision, incident.incident_id, isDecisionReady, buttonState, onApprovalChange, csrfToken, t]) + if (!approvalId) throw new Error('No approval ID available') + return approvalId + }, [currentProposalId, decision, incident.incident_id]) - // ── handleReject(移植自 DualStateIncidentCard Phase 6.5)──────────────── - const handleReject = useCallback(async () => { - if (!isDecisionReady || buttonState === 'loading') return - setButtonState('loading') - setErrorMessage(null) - if (timeoutRef.current) clearTimeout(timeoutRef.current) - timeoutRef.current = setTimeout(() => { - setButtonState('timeout') - setErrorMessage(t('timeoutMessage')) - }, EXECUTION_TIMEOUT_MS) - - try { - let approvalId = currentProposalId - if (!approvalId && decision?.token) { - const proposalResult = await apiClient.generateProposal(incident.incident_id) - if (proposalResult.success && proposalResult.proposal) { - approvalId = proposalResult.proposal.id - setCurrentProposalId(approvalId) - } - } - if (!approvalId) throw new Error('No approval ID available') - await apiClient.rejectApproval(approvalId, 'Rejected via AI Center', csrfToken) - if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null } - setButtonState('rejected') - onApprovalChange?.(approvalId, 'rejected') - } catch (error) { - if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null } - setButtonState('error') - setErrorMessage(error instanceof Error ? error.message : String(error)) + // ── approve action(差異邏輯:signApproval + 成功狀態判斷)──────────────── + const approveAction = useCallback(async (): Promise => { + if (!isDecisionReady) return + const approvalId = await resolveProposalId() + const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via AI Center', csrfToken) + const approvalStatus = result.approval?.status?.toLowerCase() + if (approvalStatus === 'approved') { + onApprovalChange?.(approvalId, 'approved') + return 'approved' } - }, [currentProposalId, decision, incident.incident_id, isDecisionReady, buttonState, onApprovalChange, csrfToken, t]) + // API 回傳非 approved(保持與原始行為一致:回到 idle) + return 'idle' + }, [isDecisionReady, resolveProposalId, csrfToken, onApprovalChange]) + + // ── reject action(差異邏輯:rejectApproval + 直接 rejected 狀態)───────── + const rejectAction = useCallback(async (): Promise => { + if (!isDecisionReady) return 'idle' + const approvalId = await resolveProposalId() + await apiClient.rejectApproval(approvalId, 'Rejected via AI Center', csrfToken) + onApprovalChange?.(approvalId, 'rejected') + return 'rejected' + }, [isDecisionReady, resolveProposalId, csrfToken, onApprovalChange]) + + const timeoutMsg = t('timeoutMessage') + + // ── useApprovalAction instances(共用 buttonState / errorMessage 透過 approve hook 主導) + // approve 和 reject 原本共用同一個 buttonState;為保持原行為, + // 以 approveHook 的 state 為主,rejectHook 的 execute 直接呼叫(state 不顯示) + const approveHook = useApprovalAction(approveAction, EXECUTION_TIMEOUT_MS, timeoutMsg) + const rejectHook = useApprovalAction(rejectAction, EXECUTION_TIMEOUT_MS, timeoutMsg) + + // 顯示優先順序:若任一 hook 處於 non-idle 則優先顯示;否則 idle + const activeHook = approveHook.buttonState !== 'idle' ? approveHook : rejectHook + const buttonState = activeHook.buttonState + const errorMessage = activeHook.errorMessage + + const handleApprove = approveHook.execute + const handleReject = rejectHook.execute // ── 授權按鈕渲染 ─────────────────────────────────────────────────────────── const renderApproveButtons = () => { @@ -196,7 +229,7 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC {buttonState === 'timeout' ? t('timeout') : t('error')}