diff --git a/apps/api/src/plugins/mcp/providers/__init__.py b/apps/api/src/plugins/mcp/providers/__init__.py index 795d9d6d..54701e12 100644 --- a/apps/api/src/plugins/mcp/providers/__init__.py +++ b/apps/api/src/plugins/mcp/providers/__init__.py @@ -8,10 +8,12 @@ MCP Tool Providers - ADR-015 模組化架構 - DatabaseProvider: 資料庫查詢 (Approval/Incident) - FilesystemProvider: 安全受限的文件讀取 (#82) - GrafanaProvider: Grafana Dashboard 查詢 (#83) +- RAGProvider: 維運手冊語義搜尋 (#84) @see docs/adr/ADR-015-mcp-modular-architecture.md 變更紀錄: +- 2026-03-27 v1.3: 新增 RAGProvider (#84) - Claude Code (台北時區) - 2026-03-26 v1.2: 新增 GrafanaProvider (#83) - Claude Code - 2026-03-26 v1.1: 新增 FilesystemProvider (#82) - Claude Code """ @@ -20,6 +22,7 @@ from src.plugins.mcp.providers.database_provider import DatabaseProvider from src.plugins.mcp.providers.filesystem_provider import FilesystemProvider from src.plugins.mcp.providers.grafana_provider import GrafanaProvider from src.plugins.mcp.providers.k8s_provider import K8sProvider +from src.plugins.mcp.providers.rag_provider import RAGProvider from src.plugins.mcp.providers.signoz_provider import SignOzProvider __all__ = [ @@ -28,6 +31,7 @@ __all__ = [ "DatabaseProvider", "FilesystemProvider", "GrafanaProvider", + "RAGProvider", ] @@ -44,3 +48,4 @@ def register_all_providers() -> None: register_provider(DatabaseProvider()) register_provider(FilesystemProvider()) register_provider(GrafanaProvider()) + register_provider(RAGProvider()) diff --git a/apps/api/src/plugins/mcp/providers/rag_provider.py b/apps/api/src/plugins/mcp/providers/rag_provider.py new file mode 100644 index 00000000..e8bfdfae --- /dev/null +++ b/apps/api/src/plugins/mcp/providers/rag_provider.py @@ -0,0 +1,234 @@ +""" +RAG MCP Tool Provider - ADR-015 模組化架構 +========================================== + +提供維運手冊 RAG 搜尋工具: +- search_runbook: 語義搜尋維運手冊 +- index_documents: 索引文檔 (管理用) +- get_index_stats: 取得索引統計 + +Phase 13.2 #84 - Runbook RAG Tool + +版本: v1.0 +建立日期: 2026-03-27 00:00 (台北時區) +建立者: Claude Code +""" + +import time +import uuid +from pathlib import Path +from typing import Any + +import structlog + +from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult + +logger = structlog.get_logger(__name__) + + +class RAGProvider(MCPToolProvider): + """ + RAG MCP Tool Provider + + 提供維運手冊的語義搜尋功能。 + 使用 Redis Vector Search + Ollama Embedding。 + """ + + def __init__(self) -> None: + self._rag_service = None + + @property + def name(self) -> str: + return "runbooks" + + def _get_rag_service(self): + """Lazy load RAG service""" + if self._rag_service is None: + from src.services.rag_service import get_rag_service + self._rag_service = get_rag_service() + return self._rag_service + + async def list_tools(self) -> list[MCPTool]: + return [ + MCPTool( + name="search_runbook", + description="Search runbook documentation with natural language query. Returns relevant sections from ADRs, Skills, and operation guides.", + input_schema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural language search query (e.g., '如何處理 Pod CrashLoopBackOff')", + }, + "top_k": { + "type": "integer", + "description": "Number of results to return (default: 5, max: 10)", + "default": 5, + }, + }, + "required": ["query"], + }, + server_name=self.name, + ), + MCPTool( + name="index_documents", + description="Index or re-index runbook documents. Admin operation - use when documents are updated.", + input_schema={ + "type": "object", + "properties": { + "force": { + "type": "boolean", + "description": "Force re-index even if index exists", + "default": False, + }, + }, + }, + server_name=self.name, + ), + MCPTool( + name="get_index_stats", + description="Get RAG index statistics (document count, index status).", + input_schema={ + "type": "object", + "properties": {}, + }, + server_name=self.name, + ), + ] + + async def execute( + self, + tool_name: str, + parameters: dict[str, Any], + ) -> MCPToolResult: + """執行 RAG 工具""" + execution_id = str(uuid.uuid4()) + start_time = time.time() + + try: + if tool_name == "search_runbook": + result = await self._search_runbook(parameters) + elif tool_name == "index_documents": + result = await self._index_documents(parameters) + elif tool_name == "get_index_stats": + result = await self._get_index_stats() + else: + return MCPToolResult( + success=False, + execution_id=execution_id, + error=f"Unknown tool: {tool_name}", + ) + + duration = time.time() - start_time + + return MCPToolResult( + success=True, + execution_id=execution_id, + output=result, + duration=duration, + ) + + except Exception as e: + logger.error( + "rag_tool_error", + tool=tool_name, + error=str(e), + ) + return MCPToolResult( + success=False, + execution_id=execution_id, + error=str(e), + duration=time.time() - start_time, + ) + + async def _search_runbook(self, params: dict) -> dict: + """ + 搜尋維運手冊 + + Args: + params: {query: str, top_k: int} + + Returns: + dict: 搜尋結果 + """ + query = params.get("query", "") + top_k = min(params.get("top_k", 5), 10) # 最多 10 筆 + + if not query: + return {"error": "Query is required", "results": []} + + rag_service = self._get_rag_service() + results = await rag_service.search(query, top_k) + + logger.info( + "rag_search_executed", + query=query[:50], + results_count=len(results), + ) + + return { + "query": query, + "results": results, + "count": len(results), + } + + async def _index_documents(self, params: dict) -> dict: + """ + 索引維運手冊 + + Args: + params: {force: bool} + + Returns: + dict: 索引結果 + """ + force = params.get("force", False) + rag_service = self._get_rag_service() + + # 如果強制重建,先清除 + if force: + await rag_service.clear_index() + + # 使用專案根目錄 + # 從當前檔案位置推算: providers/ -> mcp/ -> plugins/ -> src/ -> api/ -> apps/ -> awoooi/ + base_path = Path(__file__).parent.parent.parent.parent.parent.parent.parent + + chunks_indexed = await rag_service.index_documents(base_path) + + logger.info( + "rag_index_executed", + chunks=chunks_indexed, + force=force, + ) + + return { + "status": "success", + "chunks_indexed": chunks_indexed, + "force_rebuild": force, + } + + async def _get_index_stats(self) -> dict: + """ + 取得索引統計 + + Returns: + dict: 索引統計資訊 + """ + rag_service = self._get_rag_service() + stats = await rag_service.get_index_stats() + + return stats + + async def health_check(self) -> bool: + """ + 健康檢查 + + 檢查 Redis 和 Embedding Service 是否可用 + """ + try: + rag_service = self._get_rag_service() + stats = await rag_service.get_index_stats() + return "error" not in stats + except Exception as e: + logger.warning("rag_health_check_failed", error=str(e)) + return False diff --git a/k8s/awoooi-prod/04-configmap.yaml b/k8s/awoooi-prod/04-configmap.yaml index d311cdad..c471ad2e 100644 --- a/k8s/awoooi-prod/04-configmap.yaml +++ b/k8s/awoooi-prod/04-configmap.yaml @@ -1,7 +1,8 @@ # AWOOOI 正式環境 ConfigMap # 負責人: CIO -# 版本: v1.0 -# 日期: 2026-03-20 +# 版本: v1.1 +# 日期: 2026-03-27 (台北時區) +# 變更: Gemini 優先 (臨時,待 Ollama CPU 問題修復後切回) apiVersion: v1 kind: ConfigMap @@ -33,7 +34,9 @@ data: CORS_ORIGINS: '["https://awoooi.wooo.work","http://localhost:3000","http://localhost:3001"]' # AI 配置 (JSON array 格式 for pydantic-settings) - AI_FALLBACK_ORDER: '["ollama","gemini","claude"]' + # 2026-03-27: 臨時切換 Gemini 優先 (Ollama CPU 推論緩慢導致 mock_fallback) + # 預計 2026-03-27 切回 Ollama 優先 + AI_FALLBACK_ORDER: '["gemini","ollama","claude"]' AI_CACHE_TTL: "3600" # 快取 TTL (秒)