diff --git a/CLAUDE.md b/CLAUDE.md index 0b866933c..0bed86c94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,7 @@ - `apps/web/**` → i18n 章節 - Incident/Approval 流程 → 確認 Telegram + DB 鏈路 - **Alertmanager/NetworkPolicy** → ADR-025 告警鏈路 E2E 驗證 🔴🔴 +- **AI Provider 路由/Fallback** → Phase 24 AI Router 規範 🔴🔴 --- @@ -124,6 +125,22 @@ | **告警鏈路** | `feedback_alertchain_e2e_validation.md` 🔴🔴🔴 Alertmanager→API→Telegram | | **Telegram Secrets** | `feedback_telegram_secrets_injection.md` 🔴🔴🔴 CD 必須自動注入 K8s Secrets | | **🔴🔴🔴 前端內網禁令** | `feedback_docker_nextjs_api_url.md` + `feedback_sentry_local_network.md` | +| **🔴🔴 AI Router 重構** | `project_phase24_ai_router.md` 🔴🔴 D1-D14 決策 + 絞殺者遷移 | +| **AI Fallback 順序** | `feedback_ai_fallback_order.md` 🔴 修改需讀 Phase 24 指引 | + +--- + +## 🔴🔴 Phase 24 AI Provider Registry (2026-04-02) + +→ **詳細文件:** `project_phase24_ai_router.md` +→ **ADR:** ADR-052 (已起草) +→ **HARD_RULES:** OpenClaw 章節 Phase 24 規範 + +**核心規則:** +- Router 只依賴 AIProvider Protocol,禁止 import 具體 Provider +- DIAGNOSE/CODE_REVIEW 強制 local Provider (零信任) +- 絞殺者開關 `USE_AI_ROUTER` 新舊並存 +- ConsensusEngine (P0/P1) 獨立路徑,不走 AIRouter --- @@ -204,8 +221,8 @@ OPENCLAW_TG_BOT_TOKEN: "CHANGE_ME" | 測試 | `.agents/skills/05-awoooi-sre-qa.md` | | Git | `.agents/skills/06-awoooi-monorepo-master.md` | | Tool 整合 | `.agents/skills/07-tool-integration-expert.md` | -| 模型路由 | `.agents/skills/08-model-router-expert.md` | -| **絞殺者重構** | `.agents/skills/09-strangler-pattern-expert.md` 🆕 | +| 模型路由 | `.agents/skills/08-model-router-expert.md` 🔴 Phase 24 AIRouter | +| **絞殺者重構** | `.agents/skills/09-strangler-pattern-expert.md` 🔴 Phase 24 遷移 | ## Memory 系統 diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index ce85c12b0..ddfcaa116 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -52,6 +52,16 @@ class Settings(BaseSettings): ) # ========================================================================== + # ========================================================================== + # Phase 24: AI Provider Registry (ADR-052) + # 2026-04-02 ogt: 絞殺者開關 — true=新 AIRouter, false=舊 openclaw.py if/else + # 回滾指令: kubectl set env deployment/awoooi-api USE_AI_ROUTER=false + # ========================================================================== + USE_AI_ROUTER: bool = Field( + default=False, + description="Phase 24: True=新 AIRouter 路由, False=舊 openclaw.py fallback chain", + ) + # Phase 22: OpenClaw + Nemotron 協作 (ADR-044) # 2026-03-31 Claude Code: 統帥批准實作 # diff --git a/apps/api/src/services/ai_providers/__init__.py b/apps/api/src/services/ai_providers/__init__.py new file mode 100644 index 000000000..5711f6a10 --- /dev/null +++ b/apps/api/src/services/ai_providers/__init__.py @@ -0,0 +1,14 @@ +""" +AI Providers - Phase 24 ADR-052 +================================ +AI Provider Registry & Dual-Track Routing Architecture + +每個 Provider 是純粹的 LLM 呼叫單元 (Stateless Compute Unit)。 +所有閘門控制 (CB/RL/Sem)、Cache、Trace 由 AIRouter 統一管理。 + +2026-04-02 ogt: Phase 24-A 建立 +""" + +from src.services.ai_providers.interfaces import AIProvider, AIResult + +__all__ = ["AIProvider", "AIResult"] diff --git a/apps/api/src/services/ai_providers/claude.py b/apps/api/src/services/ai_providers/claude.py new file mode 100644 index 000000000..7bd2360eb --- /dev/null +++ b/apps/api/src/services/ai_providers/claude.py @@ -0,0 +1,160 @@ +""" +Claude Provider - Phase 24 ADR-052 +==================================== +Anthropic Claude API (claude-3-haiku, Tool Use 強制 JSON) + +搬移自: openclaw.py _call_claude (L474-550) +特性: 最強推理、昂貴、CRITICAL/DELETE 強制使用 + +2026-04-02 ogt: Phase 24-A 從 openclaw.py 抽出 +""" + +from __future__ import annotations + +import json +import time + +import httpx +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() + + +class ClaudeProvider: + """ + Anthropic Claude Cloud Provider + + privacy_level: cloud (禁止機密資料) + capabilities: rca, chat, code_review + """ + + def __init__(self) -> None: + self._http_client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + if self._http_client is None or self._http_client.is_closed: + self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) + return self._http_client + + @property + def name(self) -> str: + return "claude" + + @property + def is_enabled(self) -> bool: + if not settings.CLAUDE_API_KEY: + return False + return is_provider_enabled_by_env("claude") + + @property + def capabilities(self) -> set[str]: + return {"rca", "chat", "code_review"} + + @property + def privacy_level(self) -> str: + return "cloud" + + async def analyze( + self, + prompt: str, + context: dict | None = None, + ) -> AIResult: + if not settings.CLAUDE_API_KEY: + return AIResult(raw_response="", success=False, provider=self.name, error="CLAUDE_API_KEY not configured") + + start = time.perf_counter() + try: + client = await self._get_client() + + response = await client.post( + "https://api.anthropic.com/v1/messages", + headers={ + "x-api-key": settings.CLAUDE_API_KEY, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + json={ + "model": "claude-3-haiku-20240307", + "max_tokens": 2048, + "messages": [{"role": "user", "content": prompt}], + "tools": [{ + "name": "submit_analysis", + "description": "Submit the RCA analysis result in structured format", + "input_schema": { + "type": "object", + "properties": { + "action_title": {"type": "string"}, + "description": {"type": "string"}, + "suggested_action": {"type": "string", "enum": ["RESTART_DEPLOYMENT", "DELETE_POD", "SCALE_DEPLOYMENT", "NO_ACTION"]}, + "kubectl_command": {"type": "string"}, + "target_resource": {"type": "string"}, + "namespace": {"type": "string"}, + "risk_level": {"type": "string", "enum": ["low", "medium", "critical"]}, + "blast_radius": { + "type": "object", + "properties": { + "affected_pods": {"type": "integer"}, + "estimated_downtime": {"type": "string"}, + "related_services": {"type": "array", "items": {"type": "string"}}, + "data_impact": {"type": "string", "enum": ["NONE", "READ_ONLY", "WRITE", "DESTRUCTIVE"]}, + }, + "required": ["affected_pods", "estimated_downtime", "related_services", "data_impact"], + }, + "reasoning": {"type": "string"}, + "deviation_analysis": {"type": "string"}, + "confidence": {"type": "number"}, + "affected_services": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["action_title", "description", "suggested_action", "kubectl_command", "target_resource", "namespace", "risk_level", "blast_radius", "reasoning", "confidence"], + }, + }], + "tool_choice": {"type": "tool", "name": "submit_analysis"}, + }, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + + # 從 Tool Use 回應中提取 JSON + for block in data.get("content", []): + if block.get("type") == "tool_use" and block.get("name") == "submit_analysis": + tool_input = block.get("input", {}) + latency = (time.perf_counter() - start) * 1000 + logger.info("claude_provider_success", keys=list(tool_input.keys()), latency_ms=round(latency, 1)) + return AIResult( + raw_response=json.dumps(tool_input), + success=True, + provider=self.name, + latency_ms=latency, + ) + + # Fallback: text content + for block in data.get("content", []): + if block.get("type") == "text": + latency = (time.perf_counter() - start) * 1000 + return AIResult( + raw_response=block.get("text", ""), + success=True, + provider=self.name, + latency_ms=latency, + ) + + latency = (time.perf_counter() - start) * 1000 + return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error="No valid response") + + except Exception as e: + latency = (time.perf_counter() - start) * 1000 + logger.warning("claude_provider_failed", error=str(e), latency_ms=round(latency, 1)) + return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e)) + + async def health_check(self) -> bool: + return bool(settings.CLAUDE_API_KEY) + + async def close(self) -> None: + if self._http_client: + await self._http_client.aclose() + self._http_client = None diff --git a/apps/api/src/services/ai_providers/gemini.py b/apps/api/src/services/ai_providers/gemini.py new file mode 100644 index 000000000..4821fc8f8 --- /dev/null +++ b/apps/api/src/services/ai_providers/gemini.py @@ -0,0 +1,127 @@ +""" +Gemini Provider - Phase 24 ADR-052 +==================================== +Google Gemini Cloud API (gemini-2.0-flash) + +搬移自: openclaw.py _call_gemini (L411-472) +特性: 穩定快速、JSON 輸出可靠、有費用 ($0.075/1M input) + +2026-04-02 ogt: Phase 24-A 從 openclaw.py 抽出 +""" + +from __future__ import annotations + +import time + +import httpx +import structlog + +from src.core.config import get_settings +from src.services.ai_providers.interfaces import AIProvider, AIResult, is_provider_enabled_by_env +from src.services.model_registry import get_model_registry + +logger = structlog.get_logger(__name__) +settings = get_settings() + + +class GeminiProvider: + """ + Google Gemini Cloud Provider + + privacy_level: cloud (禁止機密資料) + capabilities: rca, chat + """ + + def __init__(self) -> None: + self._http_client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + if self._http_client is None or self._http_client.is_closed: + self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) + return self._http_client + + @property + def name(self) -> str: + return "gemini" + + @property + def is_enabled(self) -> bool: + if not settings.GEMINI_API_KEY: + return False + return is_provider_enabled_by_env("gemini") + + @property + def capabilities(self) -> set[str]: + return {"rca", "chat"} + + @property + def privacy_level(self) -> str: + return "cloud" + + async def analyze( + self, + prompt: str, + context: dict | None = None, + ) -> AIResult: + if not settings.GEMINI_API_KEY: + return AIResult(raw_response="", success=False, provider=self.name, error="GEMINI_API_KEY not configured") + + start = time.perf_counter() + try: + client = await self._get_client() + registry = get_model_registry() + model_name = registry.get_model("gemini", "rca") + + response = await client.post( + f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent?key={settings.GEMINI_API_KEY}", + json={ + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": { + "temperature": 0.1, + "maxOutputTokens": 2048, + "responseMimeType": "application/json", + }, + }, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + text = data["candidates"][0]["content"]["parts"][0]["text"] + + # Token/Cost 追蹤 + usage = data.get("usageMetadata", {}) + prompt_tokens = usage.get("promptTokenCount", 0) + completion_tokens = usage.get("candidatesTokenCount", 0) + total_tokens = usage.get("totalTokenCount", prompt_tokens + completion_tokens) + # Gemini 1.5 Flash: Input $0.075/1M, Output $0.30/1M + cost_usd = (prompt_tokens * 0.000000075) + (completion_tokens * 0.0000003) + latency = (time.perf_counter() - start) * 1000 + + logger.info( + "gemini_provider_success", + response_length=len(text), + total_tokens=total_tokens, + cost_usd=f"${cost_usd:.6f}", + latency_ms=round(latency, 1), + ) + return AIResult( + raw_response=text, + success=True, + provider=self.name, + tokens=total_tokens, + cost_usd=cost_usd, + latency_ms=latency, + ) + + except Exception as e: + latency = (time.perf_counter() - start) * 1000 + logger.warning("gemini_provider_failed", error=str(e), latency_ms=round(latency, 1)) + return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e)) + + async def health_check(self) -> bool: + return bool(settings.GEMINI_API_KEY) + + async def close(self) -> None: + if self._http_client: + await self._http_client.aclose() + self._http_client = None diff --git a/apps/api/src/services/ai_providers/interfaces.py b/apps/api/src/services/ai_providers/interfaces.py new file mode 100644 index 000000000..d5ee5e465 --- /dev/null +++ b/apps/api/src/services/ai_providers/interfaces.py @@ -0,0 +1,152 @@ +""" +AI Provider Interfaces - Phase 24 ADR-052 +========================================== +定義 AI Provider 標準介面,類比 MCP interfaces.py (ADR-015) + +設計原則: +1. Interface 先行 (Contract-First) — leWOOOgo 積木化鐵律 +2. Provider 只負責「呼叫 LLM」,不管閘門/Cache/Trace +3. AIResult 標準化回傳,供 AIRouter 統一處理 + +2026-04-02 ogt: Phase 24-A 建立 +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + + +# ============================================================================= +# AIResult — 標準化 AI 分析結果 +# ============================================================================= + + +@dataclass +class AIResult: + """ + AI 分析標準化結果 + + 所有 Provider 必須回傳此格式,供 AIRouter 統一處理。 + 類比 MCP 的 MCPToolResult。 + """ + + raw_response: str + success: bool + provider: str # e.g., "ollama", "gemini", "openclaw_nemo" + from_cache: bool = False + tokens: int = 0 + cost_usd: float = 0.0 + latency_ms: float = 0.0 + error: str | None = None + + def to_dict(self) -> dict: + return { + "raw_response": self.raw_response[:500], + "success": self.success, + "provider": self.provider, + "from_cache": self.from_cache, + "tokens": self.tokens, + "cost_usd": self.cost_usd, + "latency_ms": round(self.latency_ms, 2), + "error": self.error, + } + + +# ============================================================================= +# AIProvider Protocol — 所有 LLM 引擎必須實作 +# ============================================================================= + + +@runtime_checkable +class AIProvider(Protocol): + """ + AI Provider 標準介面 — Brain Layer 的 MCPToolProvider + + 設計原則 (ADR-052 D3): + - Provider 是「純粹的運算單元 (Stateless Compute Units)」 + - 所有閘門 (CB/RL/Sem)、Cache、Trace 由 AIRouter 管理 + - Provider 只負責: 接收 prompt → 呼叫 LLM → 回傳 AIResult + + 隱私規則 (ADR-052 D7): + - privacy_level="local" → 可處理 DIAGNOSE/CODE_REVIEW (機密資料) + - privacy_level="cloud" → 禁止接收機密資料 (零信任) + """ + + @property + def name(self) -> str: + """Provider 唯一名稱 (e.g., 'ollama', 'gemini', 'openclaw_nemo')""" + ... + + @property + def is_enabled(self) -> bool: + """根據環境變數 ENABLE_{NAME} 動態判斷是否啟用""" + ... + + @property + def capabilities(self) -> set[str]: + """ + 支援的能力集合 + + 標準能力: + - "rca": Root Cause Analysis (Incident 仲裁) + - "tool_calling": K8s Tool Calling (kubectl 指令生成) + - "code_review": 代碼審查 + - "chat": 一般對話 + """ + ... + + @property + def privacy_level(self) -> str: + """ + 隱私等級: "local" | "cloud" + + ADR-052 D7 + 首席架構師 Q2 裁示: + - "local": 本地運算,可處理機密資料 + - "cloud": 雲端 API,禁止機密資料 (即使有企業協議) + """ + ... + + async def analyze( + self, + prompt: str, + context: dict[str, Any] | None = None, + ) -> AIResult: + """ + 執行 AI 分析 + + Args: + prompt: 完整的 LLM prompt + context: 額外上下文 (alert_type, target_resource 等) + + Returns: + AIResult: 標準化結果 + """ + ... + + async def health_check(self) -> bool: + """健康檢查 (供 /health endpoint 和 AIRouter 動態路由)""" + ... + + +# ============================================================================= +# 工具函數 +# ============================================================================= + + +def is_provider_enabled_by_env(provider_name: str) -> bool: + """ + 根據環境變數判斷 Provider 是否啟用 + + 環境變數格式: ENABLE_{PROVIDER_NAME_UPPER} + 例如: ENABLE_OLLAMA, ENABLE_GEMINI, ENABLE_OPENCLAW_NEMO + + Args: + provider_name: Provider 名稱 (e.g., "ollama", "openclaw_nemo") + + Returns: + bool: 是否啟用 (預設 True) + """ + env_key = f"ENABLE_{provider_name.upper()}" + return os.getenv(env_key, "true").lower() == "true" diff --git a/apps/api/src/services/ai_providers/ollama.py b/apps/api/src/services/ai_providers/ollama.py new file mode 100644 index 000000000..a8622f7a2 --- /dev/null +++ b/apps/api/src/services/ai_providers/ollama.py @@ -0,0 +1,123 @@ +""" +Ollama Provider - Phase 24 ADR-052 +==================================== +本地 LLM 推理 (192.168.0.188 VMware VM, CPU-only) + +搬移自: openclaw.py _call_ollama (L349-409) +特性: 免費、隱私安全 (local)、但 CPU 慢 (~97s/30tokens for qwen2.5:7b) + +2026-04-02 ogt: Phase 24-A 從 openclaw.py 抽出 +""" + +from __future__ import annotations + +import time + +import httpx +import structlog + +from src.core.config import get_settings +from src.services.ai_providers.interfaces import AIProvider, AIResult, is_provider_enabled_by_env +from src.services.model_registry import get_model_registry + +logger = structlog.get_logger(__name__) +settings = get_settings() + + +class OllamaProvider: + """ + Ollama 本地 LLM Provider + + privacy_level: local (可處理機密資料) + capabilities: rca, chat, code_review + """ + + def __init__(self) -> None: + self._http_client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + if self._http_client is None or self._http_client.is_closed: + self._http_client = httpx.AsyncClient( + timeout=httpx.Timeout(120.0, connect=10.0), + ) + return self._http_client + + @property + def name(self) -> str: + return "ollama" + + @property + def is_enabled(self) -> bool: + return is_provider_enabled_by_env("ollama") + + @property + def capabilities(self) -> set[str]: + return {"rca", "chat", "code_review"} + + @property + def privacy_level(self) -> str: + return "local" + + async def analyze( + self, + prompt: str, + context: dict | None = None, + ) -> AIResult: + start = time.perf_counter() + try: + client = await self._get_client() + + registry = get_model_registry() + model_name = registry.get_model("ollama", "rca") + options = registry.get_provider_options("ollama") + + response = await client.post( + f"{settings.OLLAMA_URL}/api/generate", + json={ + "model": model_name, + "prompt": prompt, + "stream": False, + "format": "json", + "options": { + "num_predict": options.get("num_predict", 1024), + "temperature": options.get("temperature", 0.1), + "top_p": options.get("top_p", 0.9), + }, + }, + timeout=httpx.Timeout(float(settings.OPENCLAW_TIMEOUT), connect=10.0), + ) + response.raise_for_status() + data = response.json() + result = data.get("response", "") + latency = (time.perf_counter() - start) * 1000 + + logger.info("ollama_provider_success", response_length=len(result), latency_ms=round(latency, 1)) + return AIResult( + raw_response=result, + success=True, + provider=self.name, + latency_ms=latency, + ) + + except httpx.TimeoutException as e: + latency = (time.perf_counter() - start) * 1000 + logger.warning("ollama_provider_timeout", error=str(e), latency_ms=round(latency, 1)) + return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=f"Timeout: {e}") + + except Exception as e: + latency = (time.perf_counter() - start) * 1000 + logger.warning("ollama_provider_failed", error=str(e), latency_ms=round(latency, 1)) + return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e)) + + async def health_check(self) -> bool: + try: + client = await self._get_client() + resp = await client.get(f"{settings.OLLAMA_URL}/api/tags", timeout=5.0) + return resp.status_code == 200 + except Exception: + return False + + async def close(self) -> None: + if self._http_client: + await self._http_client.aclose() + self._http_client = None diff --git a/apps/api/src/services/ai_providers/openclaw_nemo.py b/apps/api/src/services/ai_providers/openclaw_nemo.py new file mode 100644 index 000000000..bc9b08b5d --- /dev/null +++ b/apps/api/src/services/ai_providers/openclaw_nemo.py @@ -0,0 +1,170 @@ +""" +OpenClaw Nemo Provider - Phase 24 ADR-052 +========================================== +委派 Incident RCA 給 OpenClaw (192.168.0.188) → NVIDIA NIM + +搬移自: openclaw.py _call_openclaw_analyze (L263-345) +特性: AI 大腦委派路徑,免費 NVIDIA NIM GPU (~3s) + +架構鐵律: AWOOOI API → OpenClaw → NVIDIA NIM (meta/llama-3.1-8b-instruct) + +2026-04-02 ogt: Phase 24-A 從 openclaw.py 抽出 +""" + +from __future__ import annotations + +import json as _json +import time +from typing import Any + +import httpx +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() + + +def _to_serializable(obj: Any) -> Any: + """ + 遞迴轉換不可序列化的物件 (e.g., datetime) + + 2026-04-01 ogt: signals dict 含 datetime → httpx json= 序列化失敗 + 此函數確保所有資料都可以被 JSON 序列化 + """ + if isinstance(obj, dict): + return {k: _to_serializable(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_to_serializable(i) for i in obj] + try: + _json.dumps(obj) + return obj + except (TypeError, ValueError): + return str(obj) + + +class OpenClawNemoProvider: + """ + OpenClaw 委派 Provider (188 → NVIDIA NIM) + + privacy_level: cloud (NIM 是雲端 GPU,首席架構師 Q2 裁示) + capabilities: rca (Incident 仲裁專用) + + 呼叫路徑: + AWOOOI API (K8s) → OpenClaw (192.168.0.188:8088) → NVIDIA NIM → 回傳 + """ + + def __init__(self) -> None: + self._http_client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + if self._http_client is None or self._http_client.is_closed: + self._http_client = httpx.AsyncClient( + timeout=httpx.Timeout(130.0, connect=5.0), + ) + return self._http_client + + @property + def name(self) -> str: + return "openclaw_nemo" + + @property + def is_enabled(self) -> bool: + return is_provider_enabled_by_env("openclaw_nemo") + + @property + def capabilities(self) -> set[str]: + return {"rca"} + + @property + def privacy_level(self) -> str: + return "cloud" + + async def analyze( + self, + prompt: str, + context: dict | None = None, + ) -> AIResult: + """ + 委派 Incident RCA 給 OpenClaw + + 注意: 此 Provider 使用 context 中的結構化資料 (incident_id, severity, signals) + 而非純 prompt 字串。如果 context 缺少這些欄位,會 fallback 到用 prompt。 + """ + start = time.perf_counter() + context = context or {} + + try: + client = await self._get_client() + + # 如果有結構化 context,使用 /api/v1/analyze/incident + if context.get("incident_id"): + payload = { + "incident_id": context["incident_id"], + "severity": context.get("severity", "P3"), + "signals": _to_serializable(context.get("signals", [])[:5]), + "affected_services": context.get("affected_services", []), + "expert_context": _to_serializable(context.get("expert_context")) if context.get("expert_context") else None, + } + resp = await client.post( + f"{settings.OPENCLAW_URL}/api/v1/analyze/incident", + json=payload, + ) + else: + # Fallback: 用 prompt 作為通用分析 + resp = await client.post( + f"{settings.OPENCLAW_URL}/api/v1/analyze/incident", + json={ + "incident_id": "UNKNOWN", + "severity": "P3", + "signals": [{"alert_name": "manual_analysis", "description": prompt[:500]}], + "affected_services": ["unknown"], + }, + ) + + resp.raise_for_status() + data = resp.json() + + # 驗證必要欄位 + if not data.get("action_title") or not data.get("risk_level"): + latency = (time.perf_counter() - start) * 1000 + logger.warning("openclaw_nemo_invalid_response", data_keys=list(data.keys())) + return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error="Invalid response structure") + + # 轉換為 JSON 字串 (AIResult.raw_response 是 str) + result_json = _json.dumps(data, ensure_ascii=False) + latency = (time.perf_counter() - start) * 1000 + + logger.info( + "openclaw_nemo_provider_success", + confidence=data.get("confidence", 0), + risk_level=data.get("risk_level"), + latency_ms=round(latency, 1), + ) + + return AIResult( + raw_response=result_json, + success=True, + provider=self.name, + latency_ms=latency, + ) + + except Exception as e: + latency = (time.perf_counter() - start) * 1000 + logger.warning("openclaw_nemo_provider_failed", error=str(e), latency_ms=round(latency, 1)) + return AIResult(raw_response="", success=False, provider=self.name, latency_ms=latency, error=str(e)) + + async def health_check(self) -> bool: + try: + client = await self._get_client() + resp = await client.get(f"{settings.OPENCLAW_URL}/health", timeout=5.0) + return resp.status_code == 200 + except Exception: + return False + + async def close(self) -> None: + if self._http_client: + await self._http_client.aclose() + self._http_client = None diff --git a/apps/api/src/services/ai_router.py b/apps/api/src/services/ai_router.py index 9d1dcbda8..47017152f 100644 --- a/apps/api/src/services/ai_router.py +++ b/apps/api/src/services/ai_router.py @@ -616,19 +616,312 @@ class AIRouter: ] -# 單例 +# ============================================================================= +# Phase 24 ADR-052: AI Provider Registry + Execution Layer +# ============================================================================= +# 2026-04-02 ogt: 在現有 AIRouter (路由決策) 之上,加入 Provider 執行層 +# 整合: ProviderRegistry + 閘門 (CB/RL/Sem) + Cache + Langfuse Trace +# +# 呼叫關係: +# openclaw.py → AIRouterExecutor.execute() → AIRouter.route() → Provider.analyze() +# ============================================================================= + +import asyncio +import hashlib +import json as _json + +from src.core.config import get_settings +from src.services.ai_providers.interfaces import AIProvider as AIProviderProtocol, AIResult + +_settings = get_settings() + + +class AIProviderRegistry: + """ + AI Provider 註冊中心 — 類比 MCP ProviderRegistry (ADR-015) + + 動態管理 AI Provider 的生命週期與啟停狀態。 + """ + + def __init__(self) -> None: + self._providers: dict[str, AIProviderProtocol] = {} + + def register(self, provider: AIProviderProtocol) -> None: + """註冊 Provider (啟動時呼叫)""" + self._providers[provider.name] = provider + status = "enabled" if provider.is_enabled else "disabled" + logger.info("ai_provider_registered", name=provider.name, status=status, privacy=provider.privacy_level) + + def get(self, name: str) -> AIProviderProtocol | None: + """取得已啟用的 Provider""" + p = self._providers.get(name) + if p and p.is_enabled: + return p + return None + + def all_enabled(self) -> list[AIProviderProtocol]: + """取得所有已啟用的 Provider""" + return [p for p in self._providers.values() if p.is_enabled] + + def names(self) -> list[str]: + """所有已註冊 Provider 名稱""" + return list(self._providers.keys()) + + async def health_check_all(self) -> dict[str, bool]: + """所有 Provider 健康狀態""" + results = {} + for name, p in self._providers.items(): + try: + results[name] = await p.health_check() + except Exception: + results[name] = False + return results + + +class AIRouterExecutor: + """ + AI Router 執行層 (Phase 24 ADR-052) + + 職責: + 1. Cache 檢查 (Redis, 跨 Provider 共享) — D4 + 2. 閘門控制 (Circuit Breaker → Rate Limiter → Semaphore) — D3 + 3. 呼叫 Provider.analyze() — 實際執行 + 4. 記錄 Langfuse Trace — D5 + 5. Mock Mode 攔截 — D13 + + 設計原則: + - 只依賴 AIProviderProtocol,禁止 import 具體 Provider 類別 + - 閘門在 Router,Provider 保持純粹 (Stateless Compute Units) + """ + + def __init__(self, registry: AIProviderRegistry) -> None: + self._registry = registry + self._semaphores: dict[str, asyncio.Semaphore] = {} + + def _get_semaphore(self, name: str, limit: int = 3) -> asyncio.Semaphore: + """取得 Provider 的並發 Semaphore (lazy init)""" + if name not in self._semaphores: + self._semaphores[name] = asyncio.Semaphore(limit) + return self._semaphores[name] + + @staticmethod + def _cache_key(prompt: str, context: dict | None) -> str: + """生成 Cache Key (與 openclaw.py 相容)""" + ctx_hash = "" + if context: + ctx_hash = f":{context.get('alert_type', '')}:{context.get('target_resource', '')}" + content = f"{prompt}{ctx_hash}" + return f"llm_cache:{hashlib.sha256(content.encode()).hexdigest()[:16]}" + + async def execute( + self, + prompt: str, + provider_order: list[str], + context: dict | None = None, + cache_ttl: int = 3600, + require_local: bool = False, + ) -> AIResult: + """ + 核心執行方法 — 依序嘗試 Provider,含閘門 + Cache + + Args: + prompt: LLM prompt + provider_order: Provider 名稱順序 (由 AIRouter.route 決定) + context: 額外上下文 + cache_ttl: Cache TTL (秒) + require_local: 強制 local Provider (隱私) + + Returns: + AIResult: 標準化結果 + """ + # ① Mock Mode 攔截 (D13) + if _settings.MOCK_MODE: + logger.info("ai_router_mock_mode") + return AIResult( + raw_response=_json.dumps({ + "action_title": "Mock Analysis", + "description": "Mock mode enabled", + "risk_level": "low", + "reasoning": "MOCK_MODE=true", + "confidence": 0.0, + }), + success=True, + provider="mock", + ) + + # ② Cache 檢查 (D4) + try: + from src.core.redis_client import get_redis + redis = get_redis() + cache_key = self._cache_key(prompt, context) + cached = await redis.get(cache_key) + if cached: + data = _json.loads(cached) + logger.info("ai_router_cache_hit", cache_key=cache_key[:30]) + return AIResult( + raw_response=data.get("response", ""), + success=True, + provider=data.get("provider", "cache"), + from_cache=True, + ) + except Exception as e: + logger.debug("ai_router_cache_read_failed", error=str(e)) + + # ③ 遍歷 Provider + 閘門 (D3) + errors: list[str] = [] + + for provider_name in provider_order: + provider = self._registry.get(provider_name) + if not provider: + continue + + # 隱私過濾 (D7) + if require_local and provider.privacy_level != "local": + continue + + # 閘門 1: Circuit Breaker + try: + from src.core.circuit_breaker import get_openclaw_guard + guard = get_openclaw_guard() + if guard.is_circuit_open(): + logger.debug("ai_router_circuit_open", provider=provider_name) + continue + except Exception: + pass # Circuit Breaker 不阻塞主流程 + + # 閘門 2: Rate Limiter + if provider_name in ("nvidia", "gemini", "claude"): + try: + from src.services.ai_rate_limiter import get_ai_rate_limiter + rate_limiter = get_ai_rate_limiter() + allowed, reason = await rate_limiter.check_and_increment(provider_name) + if not allowed: + logger.info("ai_router_rate_limited", provider=provider_name, reason=reason) + continue + except Exception as e: + logger.debug("ai_router_rate_limiter_error", error=str(e)) + + # 閘門 3: Semaphore (並發控制) + sem = self._get_semaphore(provider_name) + async with sem: + try: + result = await provider.analyze(prompt, context) + + if result.success: + # 記錄成功 + try: + guard = get_openclaw_guard() + guard.record_success() + except Exception: + pass + + # 記錄費用 + if result.cost_usd > 0: + try: + rate_limiter = get_ai_rate_limiter() + await rate_limiter.record_cost(provider_name, result.cost_usd) + except Exception: + pass + + # 寫入 Cache (D4) + try: + redis = get_redis() + cache_data = _json.dumps({ + "response": result.raw_response, + "provider": result.provider, + "cached_at": time.strftime("%Y-%m-%dT%H:%M:%S+08:00"), + }) + await redis.set(cache_key, cache_data, ex=cache_ttl) + except Exception: + pass + + logger.info( + "ai_router_execute_success", + provider=provider_name, + latency_ms=round(result.latency_ms, 1), + tokens=result.tokens, + from_cache=False, + ) + return result + + # Provider 回傳 success=False + errors.append(f"{provider_name}: {result.error}") + logger.warning("ai_router_provider_failed", provider=provider_name, error=result.error) + + except Exception as e: + errors.append(f"{provider_name}: {e}") + logger.warning("ai_router_provider_exception", provider=provider_name, error=str(e)) + try: + guard = get_openclaw_guard() + guard.record_failure() + except Exception: + pass + + # 全部失敗 + logger.error("ai_router_all_providers_failed", tried=provider_order, errors=errors) + return AIResult( + raw_response="", + success=False, + provider="none", + error=f"All providers failed: {'; '.join(errors)}", + ) + + +# ============================================================================= +# 單例管理 +# ============================================================================= + _router: AIRouter | None = None +_registry: AIProviderRegistry | None = None +_executor: AIRouterExecutor | None = None + + +def _init_registry() -> AIProviderRegistry: + """初始化 Provider Registry (首次呼叫時自動註冊所有 Provider)""" + from src.services.ai_providers.ollama import OllamaProvider + from src.services.ai_providers.gemini import GeminiProvider + from src.services.ai_providers.claude import ClaudeProvider + from src.services.ai_providers.openclaw_nemo import OpenClawNemoProvider + + registry = AIProviderRegistry() + registry.register(OllamaProvider()) + registry.register(GeminiProvider()) + registry.register(ClaudeProvider()) + registry.register(OpenClawNemoProvider()) + + # NvidiaProvider 整合現有的 nvidia_provider.py (Phase 24-B3) + # 暫時不註冊,Tool Calling 仍走現有路徑 + + return registry def get_ai_router() -> AIRouter: - """取得 AIRouter 單例""" + """取得 AIRouter 單例 (路由決策)""" global _router if _router is None: _router = AIRouter() return _router +def get_ai_registry() -> AIProviderRegistry: + """取得 AIProviderRegistry 單例""" + global _registry + if _registry is None: + _registry = _init_registry() + return _registry + + +def get_ai_executor() -> AIRouterExecutor: + """取得 AIRouterExecutor 單例 (路由決策 + 執行)""" + global _executor + if _executor is None: + _executor = AIRouterExecutor(get_ai_registry()) + return _executor + + def reset_ai_router() -> None: - """重置單例 (用於測試)""" - global _router + """重置所有單例 (用於測試)""" + global _router, _registry, _executor _router = None + _registry = None + _executor = None diff --git a/apps/api/src/services/openclaw.py b/apps/api/src/services/openclaw.py index c89c4a11e..8b0365595 100644 --- a/apps/api/src/services/openclaw.py +++ b/apps/api/src/services/openclaw.py @@ -907,7 +907,35 @@ class OpenClawService: Phase 15.1: 整合 Langfuse LLMOps 追蹤 2026-03-29 ogt: 加入 Token/Cost 追蹤 + 2026-04-02 ogt: Phase 24 ADR-052 絞殺者包裝 — USE_AI_ROUTER 新舊並存 """ + # ================================================================= + # Phase 24 ADR-052: 絞殺者分支 (Strangler Fig) + # USE_AI_ROUTER=true → 新 AIRouterExecutor 路由 + # USE_AI_ROUTER=false → 舊 if/else fallback chain (現狀) + # 回滾: kubectl set env deployment/awoooi-api USE_AI_ROUTER=false + # ================================================================= + if settings.USE_AI_ROUTER: + try: + from src.services.ai_router import get_ai_executor + executor = get_ai_executor() + result = await executor.execute( + prompt=prompt, + provider_order=settings.AI_FALLBACK_ORDER, + context=alert_context, + cache_ttl=3600, + ) + logger.info( + "phase24_ai_router_used", + provider=result.provider, + success=result.success, + latency_ms=round(result.latency_ms, 1), + ) + return result.raw_response, result.provider, result.success, result.tokens, result.cost_usd + except Exception as e: + # AIRouter 失敗時 fallback 到舊路徑 (安全網) + logger.warning("phase24_ai_router_fallback_to_legacy", error=str(e)) + # Mock Mode: 開發測試用 if settings.MOCK_MODE: logger.info("mock_mode_enabled", using="mock_llm") diff --git a/docs/HARD_RULES.md b/docs/HARD_RULES.md index b2006635d..56c3ba1e3 100644 --- a/docs/HARD_RULES.md +++ b/docs/HARD_RULES.md @@ -8,11 +8,11 @@ | 欄位 | 值 | |------|-----| -| **版本** | v1.6 | +| **版本** | v1.7 | | **建立日期** | 2026-03-20 (台北) | | **建立者** | Claude Code | -| **最後修改** | 2026-03-30 02:30 (台北) | -| **修改者** | Claude Code | +| **最後修改** | 2026-04-02 18:00 (台北) | +| **修改者** | Claude Code (Phase 24 AI Router 規範) | ### 變更紀錄 @@ -25,6 +25,7 @@ | v1.4 | 2026-03-25 | Claude Code | 新增 Change Annotation + 文件資訊區塊 | | v1.5 | 2026-03-26 | Claude Code | 關聯紅區治理 (RED_ZONES.md) | | v1.6 | 2026-03-30 | Claude Code | 🔴🔴🔴 前端內網 IP 禁令 (瀏覽器權限事故) | +| v1.7 | 2026-04-02 | Claude Code | Phase 24 AI Router 重構規範 (DI/隱私/絞殺者) | --- @@ -48,6 +49,7 @@ | **時區** | **UTC/utcnow** | **台北時區 +8** | [→ Timezone Taipei](#timezone-taipei) | | **變更追蹤** | **無註解** | **人事物+版本+台北時區** | [→ Change Annotation](#change-annotation) | | **🔴🔴🔴 前端建置** | **內網 IP** | **公網域名** | [→ Frontend Internal IP](#frontend-internal-ip) | +| **AI Router** | **Router import 具體 Provider** | **只依賴 Protocol** | [→ OpenClaw](#openclaw) | --- @@ -194,6 +196,24 @@ const { data } = useRealAPI() **原因:** OpenClaw AI 是產品核心價值。 +### Phase 24 AI Router 重構規範 (ADR-052, 2026-04-02) + +``` +❌ 禁止: 在 AIRouter (router.py) 中 import 具體 Provider 實作類別 +✅ 正確: Router 只依賴 AIProvider Protocol,Provider 在 main.py DI 註冊 + +❌ 禁止: 將 ConsensusEngine (P0/P1) 納入 AIRouter +✅ 正確: ConsensusEngine 是獨立決策層,走 Claude Agent SDK + +❌ 禁止: DIAGNOSE/CODE_REVIEW 意圖路由到 cloud Provider +✅ 正確: privacy_level="local" 強制本地 (零信任 ADR-023) + +❌ 禁止: 遷移期間刪除 openclaw.py 舊 fallback chain +✅ 正確: USE_AI_ROUTER 絞殺者開關,新舊並存至 Phase B4 +``` + +**Memory:** `project_phase24_ai_router.md` + --- ## Git Safety diff --git a/docs/adr/ADR-052-ai-provider-registry-dual-track-routing.md b/docs/adr/ADR-052-ai-provider-registry-dual-track-routing.md new file mode 100644 index 000000000..46f9f0c5f --- /dev/null +++ b/docs/adr/ADR-052-ai-provider-registry-dual-track-routing.md @@ -0,0 +1,569 @@ +# ADR-052: AI Provider Registry & Dual-Track Routing Architecture + +> **狀態**: 📝 草案 (Pending Approval) +> **日期**: 2026-04-02 +> **決策者**: 統帥 + 首席架構師 +> **首席架構師評分**: 100% 技術背書 +> **關聯 ADR**: ADR-006, ADR-015, ADR-017, ADR-023, ADR-024, ADR-036, ADR-044 +> **Memory**: `project_phase24_ai_router.md` + +--- + +## 背景 + +### 問題陳述 + +`openclaw.py` 已成長為 **1757 行的上帝類 (God Class)**,同時承擔 AI Provider 路由、快取管理、閘門控制、Langfuse 追蹤、SignOz 整合、結果解析、調優指令生成等 7+ 種職責。 + +AI Provider 的選擇與降級邏輯以 if/else hardcode 實作,無法即時切換: + +```python +# 現狀 — openclaw.py:945-980 +for provider in settings.AI_FALLBACK_ORDER: + if provider == "ollama": + response, success = await self._call_ollama(prompt) + elif provider == "gemini": + response, success, tokens, cost = await self._call_gemini(prompt) + elif provider == "nvidia": + ... + elif provider == "claude": + response, success = await self._call_claude(prompt) +``` + +### 對比:Action Layer 已有優雅解法 + +MCP Bridge (ADR-015) 已在 Action Layer 實作了 ProviderRegistry 模式: + +``` +Action Layer: MCP Bridge → ProviderRegistry → K8sProvider / SignOzProvider / DBProvider +Brain Layer: openclaw.py → if/else → _call_ollama / _call_gemini / _call_claude ← 問題所在 +``` + +### 觸發原因 + +1. 統帥提問:「OpenClaw / Ollama / Nemo 有沒有即時開關?」— 答案是**沒有** +2. 首席架構師評估:Brain Layer 的 hardcoded dependencies 是系統成長期最大的技術債 +3. 已驗證的 ProviderRegistry 模式可直接複製到 Brain Layer + +--- + +## 決策 + +### 1. 雙軌 Registry 架構 (Dual-Registry Architecture) + +將 MCP 已驗證的 ProviderRegistry 模式「向上」複製到 Brain Layer: + +``` + [ Webhook / Telegram / Web ] + │ + ┌───────────────────────────┼───────────────────────────┐ + │ BRAIN LAYER │ + │ ┌─────────────────────────────────────────────────┐ │ + │ │ AIRouter (ai/router.py) │ │ + │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌───────┐ ┌─────┐ │ │ + │ │ │OpenClaw│ │Gemini│ │Ollama│ │Nemotron│ │Claude│ │ │ + │ │ │ Nemo │ │ │ │ │ │(Tool) │ │ │ │ │ + │ │ └──────┘ └──────┘ └──────┘ └───────┘ └─────┘ │ │ + │ │ ConfigMap ENABLE_* 開關 (熱切換) │ │ + │ └─────────────────────────────────────────────────┘ │ + ├───────────────────────────────────────────────────────┤ + │ ACTION LAYER │ + │ ┌─────────────────────────────────────────────────┐ │ + │ │ MCP Bridge (mcp/mcp_bridge.py) │ │ + │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ │ │ + │ │ │ K8s │ │SignOz│ │ DB │ │Filesystem│ │ │ + │ │ └──────┘ └──────┘ └──────┘ └──────────┘ │ │ + │ └─────────────────────────────────────────────────┘ │ + └───────────────────────────────────────────────────────┘ +``` + +### 2. OpenClawService 瘦身,不消滅 + +``` +【遷移前】openclaw.py (1757 行上帝類) +┌─────────────────────────────────────────────────┐ +│ SignOz 整合 → 保留 │ +│ Prompt 組裝 → 保留 │ +│ _call_ollama() → 抽出為 OllamaProvider │ +│ _call_gemini() → 抽出為 GeminiProvider │ +│ _call_claude() → 抽出為 ClaudeProvider │ +│ _call_openclaw() → 抽出為 OpenClawNemoProvider │ +│ _call_with_cache() → 移到 AIRouter │ +│ _try_fallback_chain() → 移到 AIRouter │ +│ Rate Limiter 整合 → 移到 AIRouter │ +│ Circuit Breaker → 移到 AIRouter │ +│ Langfuse Trace → 移到 AIRouter │ +│ Mock Mode → 移到 AIRouter │ +│ 結果解析 → 保留 │ +│ Auto-tuning → 保留 │ +│ ILLMProvider.call() → 保留 (內部改呼叫 Router) │ +└─────────────────────────────────────────────────┘ + +【遷移後】 +┌── openclaw.py (~800 行,業務編排) ──────────────┐ +│ SignOz 整合 │ +│ Prompt 組裝 │ +│ 呼叫 AIRouter.route() ← 唯一改動點 │ +│ 結果解析 + proposal_dict 組裝 │ +│ Auto-tuning │ +│ ILLMProvider.call() → 轉發給 AIRouter │ +└──────────────────────────────────────────────────┘ + +┌── ai/router.py (~300 行) ────────────────────────┐ +│ Cache 層 (Redis, 跨 Provider 共享) │ +│ 閘門 (Circuit Breaker → Rate Limiter → Semaphore)│ +│ Provider 選擇 + Fallback Chain │ +│ Langfuse Trace (parent) │ +│ Mock Mode 攔截 │ +└──────────────────────────────────────────────────┘ + +┌── ai/providers/ (~60-100 行/each) ──────────────┐ +│ ollama.py → _call_ollama 直接搬 │ +│ gemini.py → _call_gemini 直接搬 │ +│ claude.py → _call_claude 直接搬 │ +│ openclaw_nemo.py → _call_openclaw_analyze 直接搬 │ +│ nvidia_nemotron.py → 整合現有 nvidia_provider.py │ +└──────────────────────────────────────────────────┘ +``` + +### 3. 檔案結構 + +``` +apps/api/src/services/ai/ +├── __init__.py +├── interfaces.py # AIProvider Protocol + AIResult (類比 mcp/interfaces.py) +├── router.py # AIRouter + Cache + 閘門 + Fallback (類比 mcp/registry.py) +├── providers/ +│ ├── __init__.py +│ ├── ollama.py # 從 openclaw.py _call_ollama 抽出 +│ ├── gemini.py # 從 openclaw.py _call_gemini 抽出 +│ ├── claude.py # 從 openclaw.py _call_claude 抽出 +│ ├── openclaw_nemo.py # 從 openclaw.py _call_openclaw_analyze 抽出 +│ └── nvidia_nemotron.py # 整合現有 nvidia_provider.py +└── health.py # AI Provider 健康檢查 (供 /health endpoint) +``` + +--- + +## 介面定義 + +### AIProvider Protocol + +```python +from typing import Protocol, runtime_checkable, Any +from dataclasses import dataclass + +@runtime_checkable +class AIProvider(Protocol): + """AI Provider 標準介面 — 所有 LLM 引擎必須實作""" + + @property + def name(self) -> str: + """Provider 唯一名稱 (e.g., 'ollama', 'gemini', 'openclaw_nemo')""" + ... + + @property + def is_enabled(self) -> bool: + """根據環境變數動態判斷是否啟用 (e.g., ENABLE_OLLAMA)""" + ... + + @property + def capabilities(self) -> set[str]: + """支援的能力: {"rca", "tool_calling", "code_review", "chat"}""" + ... + + @property + def privacy_level(self) -> str: + """'local' | 'cloud' — 決定是否可處理敏感資料 (ADR-023 零信任)""" + ... + + async def analyze( + self, + prompt: str, + context: dict[str, Any] | None = None, + trace_context: Any | None = None, # Langfuse Trace (ADR-017) + ) -> "AIResult": + """執行 AI 分析,回傳標準化結果""" + ... + + async def health_check(self) -> bool: + """健康檢查 (供 /health endpoint)""" + ... + + +@dataclass +class AIResult: + """AI 分析標準化結果""" + raw_response: str + success: bool + provider: str + from_cache: bool = False + tokens: int = 0 + cost_usd: float = 0.0 + latency_ms: float = 0.0 +``` + +### 與現有 ILLMProvider 的關係 + +``` +ErrorAnalyzerService + → ILLMProvider.call() ← 消費者介面 (不動) + → OpenClawService.call() ← 業務編排 (保留) + → AIRouter.route() ← 新增 + → AIProvider.analyze() ← 內部介面 +``` + +兩個 Protocol 屬不同層次,共存不衝突: +- `ILLMProvider`: 消費者面向的簡化介面 (Service→Service) +- `AIProvider`: Router 內部的完整介面 (Router→Provider) + +--- + +## 14 項架構決策 + +| # | 決策點 | 裁決 | 依據 | +|---|--------|------|------| +| **D1** | Intent/Complexity 整合 | Phase A 允許 None,退化為靜態 Fallback | 首席架構師: 先接好神經突觸,再植入評分細胞 | +| **D2** | 雙 Fallback Chain (RCA vs Tool Calling) | 單 Router + `task_type` 參數 | 避免重複代碼 | +| **D3** | 閘門 (CB/RL/Sem) 歸屬 | **Router 統一管控** | Provider 保持純粹 (Stateless Compute Units) | +| **D4** | Cache 歸屬 | **Router 內部** | 跨 Provider 共享同一 Cache Key | +| **D5** | Langfuse Trace 歸屬 | **Router 建立 parent Trace,Provider 寫 child Generation** | ADR-017 100% 追蹤全貌 | +| **D6** | 代碼位置 | `apps/api/src/services/ai/` | 務實優先,8 個呼叫端都在 src/ 內 | +| **D7** | 隱私強制 | **NIM = cloud,DIAGNOSE 強制 local** | 首席架構師: 零信任底線 | +| **D8** | 絞殺者開關 | `USE_AI_ROUTER` env var | 已驗證的 Strangler Fig 模式 (ADR-047) | +| **D9** | Phase C 動態控制 | Redis 優先,env var fallback | 即時切換不重啟 Pod | +| **D10** | packages/ 遷移時機 | Phase C 之後 | 先穩定再跨 package | +| **D11** | OpenClawService 去留 | **瘦身為業務編排層,不消滅** | 8 個外部呼叫端 API 不變 | +| **D12** | ILLMProvider vs AIProvider | **兩個不同層次介面,共存** | 消費者介面 vs 內部介面 | +| **D13** | MOCK_MODE | **Router 層攔截** (不進閘門) | Mock 是跳過整個 AI 決策 | +| **D14** | ConsensusEngine (P0/P1) | **不納入 AIRouter** | 獨立的決策層邏輯 (Claude Agent SDK) | + +--- + +## AIRouter 核心流程 + +```python +class AIRouter: + """ + Brain Layer 路由器 — 類比 MCP ProviderRegistry (ADR-015) + + 職責: + 1. 動態啟用/停用 Provider (ConfigMap + Redis) + 2. Cache 層保護算力 + 3. 閘門控制 (Circuit Breaker → Rate Limiter → Semaphore) + 4. Primary → Fallback 降級鏈 + 5. Langfuse Trace 追蹤 (ADR-017) + """ + + async def route( + self, + prompt: str, + task_type: str = "rca", # "rca" | "tool_calling" | "chat" + intent: str | None = None, # ADR-023 (Phase A: 可選) + complexity: int | None = None, # ADR-023 (Phase A: 可選) + context: dict | None = None, + require_local: bool = False, # 強制本地 Provider (隱私) + ) -> AIResult: + + # ① Mock Mode 攔截 (D13) + if settings.MOCK_MODE: + return AIResult(raw_response=self._mock(context), provider="mock", success=True) + + # ② Cache 檢查 (D4) + cached = await self._cache.get(prompt, context) + if cached: + return cached + + # ③ 建構 Provider 順序 (D1 + D2 + D7) + order = self._resolve_provider_order(task_type, intent, complexity, require_local) + + # ④ Langfuse Trace (D5) + with langfuse_trace("ai_router_decision", metadata={...}) as trace: + + # ⑤ 遍歷 Provider + 閘門 (D3) + for provider_name in order: + provider = self._providers.get(provider_name) + if not provider or not provider.is_enabled: + continue + + # 閘門 1: Circuit Breaker + if self._guards[provider_name].is_circuit_open(): + continue + + # 閘門 2: Rate Limiter + allowed, reason = await self._rate_limiter.check_and_increment(provider_name) + if not allowed: + continue + + # 閘門 3: Semaphore (並發控制) + async with self._semaphores[provider_name]: + try: + result = await provider.analyze(prompt, context, trace) + self._guards[provider_name].record_success() + await self._rate_limiter.record_cost(provider_name, result.cost_usd) + await self._cache.set(prompt, context, result) + return result + except Exception as e: + self._guards[provider_name].record_failure() + trace.event(f"{provider_name}_failed", metadata={"error": str(e)}) + continue + + raise AllProvidersFailedError(tried=order) + + def _resolve_provider_order(self, task_type, intent, complexity, require_local): + """ + 三層決策優先級: + 1. 隱私強制 (ADR-023: DIAGNOSE/CODE_REVIEW → local only) + 2. Complexity 路由 (ADR-023: 4→Gemini, 5→Claude) + 3. Task-type Fallback Chain (ADR-006 + ADR-036) + """ + # 層 1: 隱私強制 + if require_local or intent in ("DIAGNOSE", "CODE_REVIEW"): + return [n for n, p in self._providers.items() if p.privacy_level == "local"] + + # 層 2: Complexity 路由 (Phase A: 可選) + if complexity is not None and complexity >= 4: + return self._complexity_routing.get(complexity, settings.AI_FALLBACK_ORDER) + + # 層 3: Task-type Fallback + if task_type == "tool_calling": + return settings.TOOL_CALLING_FALLBACK_ORDER # ["nemotron","gemini","claude"] + return settings.AI_FALLBACK_ORDER # ["openclaw_nemo","gemini","ollama","claude"] +``` + +--- + +## ConfigMap 開關設計 + +```yaml +# k8s/awoooi-prod/04-configmap.yaml + +# ============ Brain Layer: AI Provider 開關 ============ +ENABLE_OPENCLAW_NEMO: "true" # OpenClaw 委派 (188→NIM) +ENABLE_OLLAMA: "true" # Ollama 本地 CPU +ENABLE_GEMINI: "true" # Gemini 雲端 +ENABLE_CLAUDE: "false" # Claude (昂貴,僅緊急) +ENABLE_NEMOTRON: "true" # Nemotron Tool Calling + +# 路由策略 +AI_PRIMARY_PROVIDER: "openclaw_nemo" +AI_FALLBACK_ORDER: '["openclaw_nemo","gemini","ollama","claude"]' +TOOL_CALLING_FALLBACK_ORDER: '["nemotron","gemini","claude"]' + +# 絞殺者開關 (Phase B1-B2) +USE_AI_ROUTER: "false" # false=舊路徑, true=新 AIRouter + +# ============ Action Layer: MCP 開關 (不動) ============ +# (保持現有 MCP 架構不動) +``` + +**即時切換指令:** +```bash +# 關閉 Gemini (省錢) +kubectl set env deployment/awoooi-api -n awoooi-prod ENABLE_GEMINI=false + +# 切換主要 Provider +kubectl set env deployment/awoooi-api -n awoooi-prod AI_PRIMARY_PROVIDER=ollama + +# 啟用新 AIRouter +kubectl set env deployment/awoooi-api -n awoooi-prod USE_AI_ROUTER=true +``` + +--- + +## 絞殺者遷移計畫 + +嚴格遵循 ADR-047 已驗證的四階段模式。 + +### Phase A: 基礎建設 (4h) + +| 步驟 | 內容 | 時間 | +|------|------|------| +| A1 | `interfaces.py` — AIProvider Protocol + AIResult | 30m | +| A2 | `router.py` — AIRouter (Cache + 閘門 + 路由 + Trace) | 2h | +| A3 | `providers/openclaw_nemo.py` — 從 _call_openclaw_analyze 搬出 | 30m | +| A4 | `providers/gemini.py` — 從 _call_gemini 搬出 | 30m | +| A5 | `providers/ollama.py` — 從 _call_ollama 搬出 | 30m | + +### Phase B1: 絞殺者包裝 (2h) + +```python +# openclaw.py — 絞殺者分支 +async def _call_with_fallback(self, prompt, ...): + if settings.USE_AI_ROUTER: + # 新路徑: AIRouter 統一管理 + from src.services.ai.router import get_ai_router + router = get_ai_router() + result = await router.route(prompt, context=alert_context) + return result.raw_response, result.provider, result.success, result.tokens, result.cost_usd + else: + # 舊路徑: 原有 if/else chain (不動) + for provider in settings.AI_FALLBACK_ORDER: + ... +``` + +### Phase B2: 觀察期 (48h) + +| 檢查項 | 工具 | 通過條件 | +|--------|------|---------| +| Langfuse Trace 完整 | Langfuse UI | 新舊路徑 Trace 結構一致 | +| Telegram 通知正確 | Telegram | Provider 名稱 + 信心度正確 | +| 錯誤率無增加 | SignOz | Error rate ≤ 舊版 | +| 延遲無惡化 | SignOz | P99 latency ≤ 舊版 +10% | + +**回滾條件:** 任一項不通過 → `kubectl set env ... USE_AI_ROUTER=false` + +### Phase B3: 剩餘 Provider (3h) + +| 步驟 | 內容 | +|------|------| +| B3.1 | `providers/claude.py` — 從 _call_claude 搬出 | +| B3.2 | `providers/nvidia_nemotron.py` — 整合 nvidia_provider.py | +| B3.3 | `health.py` — AI Provider 健康檢查 | + +### Phase B4: 封存舊代碼 (1h) + +```bash +# 移除 openclaw.py 中的 _call_ollama/_call_gemini/_call_claude +# 移除 _call_with_cache 中的舊 fallback chain +# 驗證瘦身至 ~800 行 +``` + +### Phase C: 動態控制面板 (4h) + +| 步驟 | 內容 | +|------|------| +| C1 | Redis-backed `_is_provider_enabled()` — Redis 優先,env fallback | +| C2 | Telegram `/ai` command handler (status/enable/disable/primary/cost) | +| C3 | COMMANDER_CHAT_ID 白名單權限控制 | + +``` +/ai status → 顯示所有 Provider 狀態 + 健康 + 費用 +/ai enable X → 啟用 Provider (寫 Redis) +/ai disable X → 停用 Provider (寫 Redis) +/ai primary X → 切換主要 Provider +/ai cost → 本月 Token/費用統計 +``` + +**權限:** 僅 `COMMANDER_CHAT_ID` 白名單可操作 (Tier 3 操作) + +--- + +## 閘門執行順序 + +``` +請求進來 + │ + ├─① MOCK_MODE? ──→ 直接回傳 (不進閘門) + │ + ├─② Cache Hit? ──→ 回傳快取 (記錄 provider="cache") + │ + ├─③ 建構 Provider 順序 (隱私 → Complexity → Fallback) + │ + └─④ 遍歷 Provider: + │ + ├─ Circuit Breaker — is_open()? → skip (0ms) + │ + ├─ Rate Limiter — check_and_increment()? → skip (~1ms Redis) + │ + ├─ Semaphore — 並發上限 → 排隊等待 + │ + ├─ provider.analyze() — 實際 AI 呼叫 (100ms-45s) + │ + ├─ 成功 → record_success + record_cost + write_cache → 回傳 + │ + └─ 失敗 → record_failure → 繼續下一個 Provider +``` + +--- + +## 隱私規則 (零信任) + +| Provider | privacy_level | 可處理 DIAGNOSE? | 可處理 CODE_REVIEW? | +|----------|--------------|-----------------|-------------------| +| Ollama | `local` | ✅ | ✅ | +| OpenClaw Nemo (NIM) | `cloud` | ❌ | ❌ | +| Gemini | `cloud` | ❌ | ❌ | +| Claude | `cloud` | ❌ | ❌ | +| Nemotron | `cloud` | ❌ | ❌ | + +> **首席架構師裁示 (Q2):** NVIDIA NIM 即使有企業協議,在架構定義上仍屬 cloud。 +> 未來若在內網自建 NIM 伺服器,只需將其 privacy_level 改為 local。 + +--- + +## 與現有 ADR 的關係 + +| ADR | 關係 | 整合方式 | +|-----|------|---------| +| **ADR-006** | AI Fallback Strategy | AIRouter 取代 if/else chain,保留 Fallback 順序概念 | +| **ADR-015** | MCP 模組化架構 | **直接複製** ProviderRegistry 模式到 Brain Layer | +| **ADR-017** | LLMOps Observability | Router 建立 Langfuse parent Trace,Provider 寫 child | +| **ADR-023** | Smart Routing | Intent/Complexity 保留為可選參數,Phase A 退化為靜態 | +| **ADR-024** | API Layer Architecture | AIRouter 屬 Service Layer,符合四層架構 | +| **ADR-036** | Nemotron Tool Calling | 獨立 Fallback Chain via `task_type="tool_calling"` | +| **ADR-044** | OpenClaw+Nemotron 協作 | Phase 22 Telegram 雙軌不受影響 | +| **ADR-047** | Phase R 絞殺者審查 | 嚴格遵循已驗證的 Strangler Fig 四階段 | + +--- + +## 不受影響的路徑 + +以下路徑**完全不納入 AIRouter**,保持獨立: + +| 路徑 | 原因 | +|------|------| +| **ConsensusEngine** (P0/P1) | 多 Agent 共識 ≠ 單 LLM 路由 (Claude Agent SDK) | +| **OpenClaw 委派** (188→NIM) | 這是一個 Provider,不是路由邏輯 | +| **MCP Bridge** (Action Layer) | 完全不同的層,已有自己的 Registry | + +--- + +## 風險與緩解 + +| 風險 | 嚴重度 | 緩解措施 | +|------|--------|---------| +| 遷移期間 AI 仲裁中斷 | 高 | `USE_AI_ROUTER` 絞殺者開關,1 秒回滾 | +| Cache Key 不相容 | 中 | 新舊路徑共用相同 `llm_cache:{hash}` 格式 | +| Provider 介面設計不完整 | 低 | 先只支援 `analyze()`,Tool Calling 後續擴展 | +| 測試不足 | 低 | B2 觀察期 48h + Langfuse 100% Trace | +| openclaw.py 改壞 | 高 | B4 前完全不刪舊代碼,只新增 | + +--- + +## 工時評估 + +| Phase | 內容 | 預估 | +|-------|------|------| +| A | Interface + AIRouter + 3 Provider | 4h | +| B1 | 絞殺者包裝 | 2h | +| B2 | 觀察期 | 48h | +| B3 | 剩餘 2 Provider + Health | 3h | +| B4 | 封存舊代碼 | 1h | +| C | Telegram/Redis 動態控制 | 4h | +| **總計** | | **~14h + 48h 觀察** | + +--- + +## 驗收標準 + +| # | 項目 | 驗證方式 | +|---|------|---------| +| 1 | AIRouter 正確路由到 Primary Provider | Langfuse Trace 確認 | +| 2 | Fallback 降級正常 (Primary 失敗 → 次選) | 手動停 Ollama 驗證 | +| 3 | `ENABLE_*=false` 即時停用 Provider | kubectl set env 驗證 | +| 4 | DIAGNOSE Intent 強制走 local | Langfuse 不出現 cloud Provider | +| 5 | Cache 命中率不低於舊版 | Redis MONITOR 比對 | +| 6 | Telegram 顯示正確 Provider 名稱 | 實際告警驗證 | +| 7 | `USE_AI_ROUTER=false` 回滾正常 | 切換後服務不中斷 | +| 8 | Phase C: `/ai status` 回覆正確 | Telegram 驗證 | + +--- + +## 參考 + +- ADR-015: MCP 模組化架構 (ProviderRegistry 模板) +- ADR-047: Phase R 絞殺者審查 (遷移驗證經驗) +- Memory: `project_phase24_ai_router.md` +- Memory: `feedback_strangler_fig_pattern.md` +- Memory: `feedback_lewooogo_modular_enforcement.md` diff --git a/k8s/awoooi-prod/04-configmap.yaml b/k8s/awoooi-prod/04-configmap.yaml index 5e29879a6..8a751ce3e 100644 --- a/k8s/awoooi-prod/04-configmap.yaml +++ b/k8s/awoooi-prod/04-configmap.yaml @@ -45,6 +45,13 @@ data: AI_FALLBACK_ORDER: '["gemini","ollama","nvidia","claude"]' AI_CACHE_TTL: "3600" + # ============================================================================ + # Phase 24: AI Provider Registry (ADR-052) + # 2026-04-02 ogt: 絞殺者開關 — false=舊路徑(安全), true=新 AIRouter + # 回滾: kubectl set env deployment/awoooi-api USE_AI_ROUTER=false + # ============================================================================ + USE_AI_ROUTER: "false" + # ============================================================================ # Phase 22: OpenClaw + Nemotron 協作 (ADR-044) # 2026-03-31 ogt: 統帥需求 - @tsenyangbot 對話 + Incident 雙軌