diff --git a/internal/controller/ai_controller.go b/internal/controller/ai_controller.go index 4c5c4cdde..c2fcc8733 100644 --- a/internal/controller/ai_controller.go +++ b/internal/controller/ai_controller.go @@ -43,7 +43,6 @@ import ( tagcommonser "github.com/apache/answer/internal/service/tag_common" usercommon "github.com/apache/answer/internal/service/user_common" "github.com/apache/answer/pkg/token" - "github.com/apache/answer/plugin" "github.com/gin-gonic/gin" "github.com/mark3labs/mcp-go/mcp" "github.com/sashabaranov/go-openai" @@ -325,19 +324,45 @@ func (c *AIController) getPromptByLanguage(language i18n.Language, question stri return c.getDefaultPrompt(language, question) } - return fmt.Sprintf(promptTemplate, question) + return c.adaptPromptToCapabilities(fmt.Sprintf(promptTemplate, question)) } // getDefaultPrompt prompt func (c *AIController) getDefaultPrompt(language i18n.Language, question string) string { + var prompt string switch language { case i18n.LanguageChinese: - return fmt.Sprintf(constant.DefaultAIPromptConfigZhCN, question) + prompt = fmt.Sprintf(constant.DefaultAIPromptConfigZhCN, question) case i18n.LanguageEnglish: - return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) + prompt = fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) default: - return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) + prompt = fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) } + return c.adaptPromptToCapabilities(prompt) +} + +// adaptPromptToCapabilities removes instructions for tools the current +// deployment cannot serve, so the model is never prompted to call a missing +// capability. +func (c *AIController) adaptPromptToCapabilities(prompt string) string { + if c.mcpController.SemanticSearchAvailable() { + return prompt + } + return stripSemanticSearchLine(prompt) +} + +// stripSemanticSearchLine drops every prompt line that references the +// semantic_search tool. +func stripSemanticSearchLine(prompt string) string { + lines := strings.Split(prompt, "\n") + kept := make([]string, 0, len(lines)) + for _, line := range lines { + if strings.Contains(line, semanticSearchToolName) { + continue + } + kept = append(kept, line) + } + return strings.Join(kept, "\n") } // initializeConversationContext @@ -700,12 +725,23 @@ func (c *AIController) sendErrorResponse(w http.ResponseWriter, id, model, error sendStreamData(w, errorResponse) } -// getMCPTools +// semanticSearchToolName is the MCP tool backed by the optional VectorSearch +// plugin. It must not be advertised when no such plugin is enabled. +const semanticSearchToolName = "semantic_search" + +// getMCPTools builds the tool list advertised to the model. The +// semantic_search tool is omitted when no VectorSearch plugin is enabled, +// otherwise the model can select a capability that always fails. func (c *AIController) getMCPTools() []openai.Tool { - openaiTools := make([]openai.Tool, 0) - vectorSearchEnabled := plugin.IsVectorSearchEnabled() - for _, mcpTool := range mcp_tools.MCPToolsList { - if mcpTool.Name == "semantic_search" && !vectorSearchEnabled { + return c.buildOpenAITools(mcp_tools.MCPToolsList, c.mcpController.SemanticSearchAvailable()) +} + +// buildOpenAITools converts MCP tools into OpenAI tool definitions, optionally +// excluding the semantic_search tool. +func (c *AIController) buildOpenAITools(tools []mcp.Tool, includeSemanticSearch bool) []openai.Tool { + openaiTools := make([]openai.Tool, 0, len(tools)) + for _, mcpTool := range tools { + if !includeSemanticSearch && mcpTool.Name == semanticSearchToolName { continue } openaiTool := c.convertMCPToolToOpenAI(mcpTool) diff --git a/internal/controller/ai_tools_test.go b/internal/controller/ai_tools_test.go new file mode 100644 index 000000000..363ca04ba --- /dev/null +++ b/internal/controller/ai_tools_test.go @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "strings" + "testing" + + "github.com/apache/answer/internal/schema/mcp_tools" + "github.com/apache/answer/internal/service/embedding" +) + +func toolNames(c *AIController, includeSemanticSearch bool) map[string]bool { + tools := c.buildOpenAITools(mcp_tools.MCPToolsList, includeSemanticSearch) + names := make(map[string]bool, len(tools)) + for _, t := range tools { + names[t.Function.Name] = true + } + return names +} + +func TestBuildOpenAIToolsIncludesSemanticSearch(t *testing.T) { + c := &AIController{} + names := toolNames(c, true) + if !names[semanticSearchToolName] { + t.Fatalf("semantic_search should be advertised when a vector search plugin is available: %v", names) + } + if len(names) != len(mcp_tools.MCPToolsList) { + t.Fatalf("expect %d tools, got %d", len(mcp_tools.MCPToolsList), len(names)) + } +} + +func TestBuildOpenAIToolsExcludesSemanticSearch(t *testing.T) { + c := &AIController{} + names := toolNames(c, false) + if names[semanticSearchToolName] { + t.Fatalf("semantic_search must not be advertised without a vector search plugin") + } + if len(names) != len(mcp_tools.MCPToolsList)-1 { + t.Fatalf("expect %d tools, got %d", len(mcp_tools.MCPToolsList)-1, len(names)) + } + if !names["get_questions"] || !names["get_user"] { + t.Fatalf("other MCP tools must remain advertised: %v", names) + } +} + +func TestStripSemanticSearchLine(t *testing.T) { + prompt := "You are an assistant.\n- get_questions: search questions\n- semantic_search: search by meaning\n- get_user: search users\n" + got := stripSemanticSearchLine(prompt) + if strings.Contains(got, "semantic_search") { + t.Fatalf("semantic_search line not stripped: %q", got) + } + if !strings.Contains(got, "get_questions") || !strings.Contains(got, "get_user") { + t.Fatalf("unrelated lines were dropped: %q", got) + } + if !strings.HasPrefix(got, "You are an assistant.\n") { + t.Fatalf("leading lines must be kept: %q", got) + } +} + +func TestAdaptPromptToCapabilitiesStripsWhenUnavailable(t *testing.T) { + // In tests no VectorSearch plugin is registered, so semantic search is + // unavailable and the prompt must be adapted. + c := &AIController{mcpController: &MCPController{embeddingService: &embedding.EmbeddingService{}}} + prompt := "intro\n- semantic_search: search by meaning\noutro\n" + got := c.adaptPromptToCapabilities(prompt) + if strings.Contains(got, "semantic_search") { + t.Fatalf("expected semantic_search line removed: %q", got) + } + if !strings.Contains(got, "intro") || !strings.Contains(got, "outro") { + t.Fatalf("other content must be preserved: %q", got) + } +} diff --git a/internal/controller/mcp_controller.go b/internal/controller/mcp_controller.go index e24c1a546..942117f43 100644 --- a/internal/controller/mcp_controller.go +++ b/internal/controller/mcp_controller.go @@ -488,3 +488,10 @@ func (c *MCPController) MCPSemanticSearchHandler() func(ctx context.Context, req return mcp.NewToolResultText(string(data)), nil } } + +// SemanticSearchAvailable reports whether a VectorSearch plugin is currently +// enabled, so the AI chat can omit the semantic_search tool entirely instead +// of letting the model call into a missing capability. +func (c *MCPController) SemanticSearchAvailable() bool { + return c.embeddingService.Available() +} diff --git a/internal/service/embedding/embedding_service.go b/internal/service/embedding/embedding_service.go index c69d60d8e..62fccb427 100644 --- a/internal/service/embedding/embedding_service.go +++ b/internal/service/embedding/embedding_service.go @@ -35,6 +35,12 @@ func NewEmbeddingService() *EmbeddingService { return &EmbeddingService{} } +// Available reports whether a VectorSearch plugin is currently enabled, so +// callers can hide semantic search capabilities instead of failing at call time. +func (s *EmbeddingService) Available() bool { + return plugin.IsVectorSearchEnabled() +} + // SearchSimilar delegates to the VectorSearch plugin. // Returns an error if no plugin is enabled. func (s *EmbeddingService) SearchSimilar(ctx context.Context, query string, topK int) ([]plugin.VectorSearchResult, error) {