Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 46 additions & 10 deletions internal/controller/ai_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions internal/controller/ai_tools_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 7 additions & 0 deletions internal/controller/mcp_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
6 changes: 6 additions & 0 deletions internal/service/embedding/embedding_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down