From 47da481c23f95a97bd04950d201212ccd5fc7413 Mon Sep 17 00:00:00 2001 From: lb1038678031 <1038678031@qq.com> Date: Fri, 28 Aug 2026 08:15:03 +0800 Subject: [PATCH] fix(ai): stop advertising semantic_search when no vector search plugin Fixes #1532. The semantic_search MCP tool was always advertised to the model even when no VectorSearch plugin was enabled, so the model could select a capability that always failed with 'no vector search plugin is enabled' - polluting logs and wasting a tool-call round trip. - EmbeddingService.Available() reports whether a VectorSearch plugin is currently enabled. - MCPController.SemanticSearchAvailable() exposes that to the AI chat. - getMCPTools() omits the semantic_search tool when unavailable; other MCP tools keep working as before. - The default AI prompts (zh/en, and custom prompts) drop the semantic_search instructions in the same situation, so the model is no longer nudged toward the missing capability. --- internal/controller/ai_controller.go | 55 ++++++++++-- internal/controller/ai_tools_test.go | 90 +++++++++++++++++++ internal/controller/mcp_controller.go | 7 ++ .../service/embedding/embedding_service.go | 11 +++ 4 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 internal/controller/ai_tools_test.go diff --git a/internal/controller/ai_controller.go b/internal/controller/ai_controller.go index e7495253b..c2fcc8733 100644 --- a/internal/controller/ai_controller.go +++ b/internal/controller/ai_controller.go @@ -324,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 @@ -699,10 +725,25 @@ 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) - for _, mcpTool := range mcp_tools.MCPToolsList { + 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) openaiTools = append(openaiTools, openaiTool) } 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..abefbbee9 100644 --- a/internal/service/embedding/embedding_service.go +++ b/internal/service/embedding/embedding_service.go @@ -35,6 +35,17 @@ 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 { + found := false + _ = plugin.CallVectorSearch(func(vs plugin.VectorSearch) error { + found = true + return nil + }) + return found +} + // 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) {