yao/agent/assistant/search.go
Max 7877797549 fix(tests): temporarily disable KB/DB search in tests
- Skipped tests related to KB/DB search functionality due to temporary unavailability.
- Updated test cases in chat_test.go, search_auth_integration_test.go, search_auto_full_test.go, and others to reflect this change.
- Adjusted search handling in search.go to limit search types to "web" only until KB/DB search is re-enabled.
2026-05-03 10:26:06 +08:00

1306 lines
37 KiB
Go

package assistant
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/search"
"github.com/yaoapp/yao/agent/search/nlp/keyword"
searchTypes "github.com/yaoapp/yao/agent/search/types"
storeTypes "github.com/yaoapp/yao/agent/store/types"
traceTypes "github.com/yaoapp/yao/trace/types"
)
// shouldAutoSearch determines if auto search should be executed
// Returns nil if search should be skipped, otherwise returns SearchIntent with types to search
// Search is skipped if:
// - opts.Skip.Search is true
// - createResponse.Search is false
// - uses.search is "disabled"
// - assistant has no search configuration
// - needsearch intent detection returns false
func (ast *Assistant) shouldAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, opts *context.Options) *SearchIntent {
// Check if search is skipped via options
if opts != nil && opts.Skip != nil && opts.Skip.Search {
ctx.Logger.Debug("Auto search skipped by opts.Skip.Search")
return nil
}
// Check if search is skipped via ctx.Metadata["__disable_search"]
if ctx != nil && ctx.Metadata != nil {
disableSearch := getBool(ctx.Metadata, "__disable_search")
if disableSearch {
ctx.Logger.Debug("Auto search skipped by ctx.Metadata['__disable_search']")
return nil
}
}
// Check createResponse.Search field (highest priority from Create hook)
// Supports: bool | SearchIntent | nil
if createResponse != nil && createResponse.Search != nil {
intent := parseSearchField(createResponse.Search)
if intent != nil {
if !intent.NeedSearch {
ctx.Logger.Info("Auto search disabled by createResponse.Search")
return nil
}
ctx.Logger.Info("Auto search controlled by createResponse.Search: types=%v", intent.SearchTypes)
return intent
}
}
// Get merged uses configuration
uses := ast.getMergedSearchUses(createResponse, opts)
// Check if search is explicitly disabled
if uses != nil && uses.Search == "disabled" {
ctx.Logger.Info("Auto search disabled by uses.search=disabled")
return nil
}
// Check if assistant has search configuration
if ast.Search == nil && (uses == nil || uses.Search == "") {
return nil
}
// Check search intent using __yao.needsearch agent
intent := ast.checkSearchIntent(ctx, messages)
if intent == nil || !intent.NeedSearch {
ctx.Logger.Info("Auto search skipped: intent detection returned false")
return nil
}
return intent
}
// parseSearchField parses the Search field from HookCreateResponse
// Supports: bool | SearchIntent | map[string]any | nil
func parseSearchField(search any) *SearchIntent {
if search == nil {
return nil
}
switch v := search.(type) {
case bool:
// bool: true = enable all, false = disable all
if v {
return &SearchIntent{
NeedSearch: true,
SearchTypes: []string{"web"}, // TODO: 恢复 KB/DB 搜索时改回 []string{"web", "kb", "db"}
Confidence: 1.0,
Reason: "enabled by hook",
}
}
return &SearchIntent{
NeedSearch: false,
SearchTypes: []string{},
Confidence: 1.0,
Reason: "disabled by hook",
}
case *SearchIntent:
// SearchIntent is alias for context.SearchIntent, so this covers both
return v
case SearchIntent:
return &v
case map[string]any:
// Parse from map (e.g., from JSON)
intent := &SearchIntent{
NeedSearch: false,
SearchTypes: []string{},
Confidence: 0.5,
}
if needSearch, ok := v["need_search"].(bool); ok {
intent.NeedSearch = needSearch
}
if types, ok := v["search_types"].([]any); ok {
for _, t := range types {
if typeStr, ok := t.(string); ok {
intent.SearchTypes = append(intent.SearchTypes, typeStr)
}
}
}
if confidence, ok := v["confidence"].(float64); ok {
intent.Confidence = confidence
}
if reason, ok := v["reason"].(string); ok {
intent.Reason = reason
}
return intent
default:
return nil
}
}
// checkSearchIntent uses __yao.needsearch agent to determine if search is needed
// Returns SearchIntent with search types and confidence
func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context.Message) *SearchIntent {
// Default intent: no search needed (fallback when agent unavailable or fails)
defaultIntent := &SearchIntent{
NeedSearch: false,
SearchTypes: []string{},
Confidence: 0,
}
// Build a single text message with conversation context
intentMessages := buildContextMessage(messages)
if len(intentMessages) == 0 {
return defaultIntent // No messages, skip search
}
// Try to get __yao.needsearch agent
needsearchAst, err := Get("__yao.needsearch")
if err != nil {
ctx.Logger.Debug("__yao.needsearch agent not available: %v, skipping search", err)
return defaultIntent // Agent not available, skip search
}
// === Output: Send loading message ===
loadingID := ast.sendIntentLoading(ctx)
// Call the needsearch agent (Stack will auto-track)
// IMPORTANT: Skip search to prevent infinite loop, skip output to prevent JSON showing in UI
opts := &context.Options{
Skip: &context.Skip{
History: true, // Don't save to history
Search: true, // Skip search to prevent infinite loop
Output: true, // Skip output to prevent JSON showing in UI
},
}
result, err := needsearchAst.Stream(ctx, intentMessages, opts)
if err != nil {
ctx.Logger.Debug("__yao.needsearch failed: %v, skipping search", err)
// === Output: Send done (error case, skip search) ===
ast.sendIntentDone(ctx, loadingID, false, "")
return defaultIntent // On error, skip search
}
// Parse the result
// Next hook returns {data: {need_search: bool, search_types: [], confidence: float}}
// First try to get from Next hook response
if result.Next != nil {
if nextData, ok := result.Next.(map[string]interface{}); ok {
// Check for data field (from Next hook's {data: result})
var intentData map[string]interface{}
if data, ok := nextData["data"].(map[string]interface{}); ok {
intentData = data
} else {
intentData = nextData
}
intent := parseSearchIntent(intentData)
if intent != nil {
ctx.Logger.Debug("Search intent (from Next): need_search=%v, types=%v, confidence=%.2f, reason=%s",
intent.NeedSearch, intent.SearchTypes, intent.Confidence, intent.Reason)
ast.sendIntentDone(ctx, loadingID, intent.NeedSearch, intent.Reason)
return intent
}
}
}
// Fallback: parse from Completion.Content if Next hook didn't process
if result.Completion != nil {
content, ok := result.Completion.Content.(string)
if !ok || content == "" {
ast.sendIntentDone(ctx, loadingID, false, "")
return defaultIntent
}
intent := parseSearchIntentFromContent(content)
ctx.Logger.Debug("Search intent (from Content): need_search=%v, types=%v, confidence=%.2f, reason=%s",
intent.NeedSearch, intent.SearchTypes, intent.Confidence, intent.Reason)
ast.sendIntentDone(ctx, loadingID, intent.NeedSearch, intent.Reason)
return intent
}
// Default: skip search if we can't parse the result
// === Output: Send done (default case) ===
ast.sendIntentDone(ctx, loadingID, false, "")
return defaultIntent
}
// parseSearchIntent parses SearchIntent from intent data map
func parseSearchIntent(intentData map[string]interface{}) *SearchIntent {
if intentData == nil {
return nil
}
needSearch, ok := intentData["need_search"].(bool)
if !ok {
return nil
}
intent := &SearchIntent{
NeedSearch: needSearch,
SearchTypes: []string{},
Confidence: 0.5, // Default confidence
}
// Parse search_types
if types, ok := intentData["search_types"].([]interface{}); ok {
for _, t := range types {
if typeStr, ok := t.(string); ok {
// Validate type
typeStr = strings.ToLower(typeStr)
if typeStr == "web" || typeStr == "kb" || typeStr == "db" {
intent.SearchTypes = append(intent.SearchTypes, typeStr)
}
}
}
}
// Parse confidence
if confidence, ok := intentData["confidence"].(float64); ok {
intent.Confidence = confidence
}
// Parse reason
if reason, ok := intentData["reason"].(string); ok {
intent.Reason = reason
}
return intent
}
// parseSearchIntentFromContent parses SearchIntent from LLM completion content
// Handles JSON wrapped in markdown code blocks
func parseSearchIntentFromContent(content string) *SearchIntent {
// Default intent: no search needed
defaultIntent := &SearchIntent{
NeedSearch: false,
SearchTypes: []string{},
Confidence: 0,
}
// Remove markdown code block if present
content = strings.TrimSpace(content)
if strings.HasPrefix(content, "```json") {
content = strings.TrimPrefix(content, "```json")
content = strings.TrimSuffix(content, "```")
content = strings.TrimSpace(content)
} else if strings.HasPrefix(content, "```") {
content = strings.TrimPrefix(content, "```")
content = strings.TrimSuffix(content, "```")
content = strings.TrimSpace(content)
}
// Try to parse JSON
var result map[string]interface{}
if err := json.Unmarshal([]byte(content), &result); err != nil {
// Failed to parse, default to no search
return defaultIntent
}
intent := parseSearchIntent(result)
if intent == nil {
return defaultIntent
}
return intent
}
// sendIntentLoading sends the initial intent detection loading message
// Returns the message ID for later replacement
func (ast *Assistant) sendIntentLoading(ctx *context.Context) string {
loadingMsg := i18n.T(ctx.Locale, "search.intent.loading")
msg := &message.Message{
Type: "loading",
Props: map[string]any{
"message": loadingMsg,
},
}
// Send and get message ID
msgID, err := ctx.SendStream(msg)
if err != nil {
ctx.Logger.Warn("Failed to send intent loading message: %v", err)
return ""
}
return msgID
}
// sendIntentDone replaces loading with result
// Only marks as done when needSearch is false (no further loading will follow)
// When needSearch is true, the search loading will continue
func (ast *Assistant) sendIntentDone(ctx *context.Context, loadingID string, needSearch bool, reason string) {
if loadingID == "" {
return
}
var resultMsg string
if needSearch {
resultMsg = i18n.T(ctx.Locale, "search.intent.need_search")
} else {
resultMsg = i18n.T(ctx.Locale, "search.intent.no_search")
}
msg := &message.Message{
MessageID: loadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: "loading",
Props: map[string]any{
"message": resultMsg,
"done": true, // Intent detection loading is independent, always close it
},
}
if err := ctx.Send(msg); err != nil {
ctx.Logger.Warn("Failed to send intent done message: %v", err)
}
}
// getMergedSearchUses returns the merged uses configuration for search
// Priority: createResponse > options.Uses > assistant
func (ast *Assistant) getMergedSearchUses(createResponse *context.HookCreateResponse, opts ...*context.Options) *context.Uses {
// Start with assistant uses
var uses *context.Uses
if ast.Uses != nil {
uses = &context.Uses{
Search: ast.Uses.Search,
Web: ast.Uses.Web,
Keyword: ast.Uses.Keyword,
QueryDSL: ast.Uses.QueryDSL,
Rerank: ast.Uses.Rerank,
}
}
// Override with options.Uses if provided (highest priority)
if len(opts) > 0 && opts[0] != nil && opts[0].Uses != nil {
if uses == nil {
uses = &context.Uses{}
}
if opts[0].Uses.Search != "" {
uses.Search = opts[0].Uses.Search
}
if opts[0].Uses.Web != "" {
uses.Web = opts[0].Uses.Web
}
if opts[0].Uses.Keyword != "" {
uses.Keyword = opts[0].Uses.Keyword
}
if opts[0].Uses.QueryDSL != "" {
uses.QueryDSL = opts[0].Uses.QueryDSL
}
if opts[0].Uses.Rerank != "" {
uses.Rerank = opts[0].Uses.Rerank
}
}
// Override with createResponse.Uses if provided (highest priority)
if createResponse != nil && createResponse.Uses != nil {
if uses == nil {
uses = &context.Uses{}
}
if createResponse.Uses.Search != "" {
uses.Search = createResponse.Uses.Search
}
if createResponse.Uses.Web != "" {
uses.Web = createResponse.Uses.Web
}
if createResponse.Uses.Keyword != "" {
uses.Keyword = createResponse.Uses.Keyword
}
if createResponse.Uses.QueryDSL != "" {
uses.QueryDSL = createResponse.Uses.QueryDSL
}
if createResponse.Uses.Rerank != "" {
uses.Rerank = createResponse.Uses.Rerank
}
}
return uses
}
// executeAutoSearch executes auto search based on configuration and intent
// Returns ReferenceContext with results and formatted context
// intent specifies which search types to execute (from needsearch agent)
// opts is optional, used to check Skip.Keyword
func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, intent *SearchIntent, opts ...*context.Options) *searchTypes.ReferenceContext {
ctx.Logger.Phase("Search")
defer ctx.Logger.PhaseComplete("Search")
// Get merged uses configuration
uses := ast.getMergedSearchUses(createResponse, opts...)
// Convert to search.Uses
searchUses := &search.Uses{}
if uses != nil {
searchUses.Search = uses.Search
searchUses.Web = uses.Web
searchUses.Keyword = uses.Keyword
searchUses.QueryDSL = uses.QueryDSL
searchUses.Rerank = uses.Rerank
}
// Get merged search config
searchConfig := ast.GetMergedSearchConfig()
// Create searcher
searcher := search.New(searchConfig, searchUses)
// Extract query from messages (save original for storage)
originalQuery := extractQueryFromMessages(messages)
if originalQuery == "" {
ctx.Logger.Info("No query found in messages, skipping auto search")
return nil
}
// Build query with conversation context for better keyword extraction
// This helps the keyword extractor understand the full context
contextMessages := buildContextMessage(messages)
query := originalQuery
if len(contextMessages) > 0 {
if contextStr, ok := contextMessages[0].Content.(string); ok {
query = contextStr
}
}
// Check if keyword extraction should be skipped
skipKeyword := false
if len(opts) > 0 && opts[0] != nil && opts[0].Skip != nil {
skipKeyword = opts[0].Skip.Keyword
}
// Build search requests based on configuration and intent
// Keyword extraction is done inside buildSearchRequests for web search
buildOpts := &buildSearchRequestsOptions{
skipKeyword: skipKeyword,
usesKeyword: searchUses.Keyword,
}
requests, extractedKeywords := ast.buildSearchRequests(ctx, query, searchConfig, intent, buildOpts)
if len(requests) == 0 {
ctx.Logger.Info("No search requests to execute")
return nil
}
// Update query if keywords were extracted (for web search)
if len(extractedKeywords) > 0 {
query = keywordsToQuery(extractedKeywords)
}
// === Output: Send loading message ===
loadingID := ast.sendSearchLoading(ctx)
// === Trace: Create search trace node ===
searchNode := ast.createSearchTrace(ctx, query, requests)
// Execute searches in parallel
// Build provider info for logging
providerInfo := ast.getSearchProviderInfo(searchConfig, searchUses)
ctx.Logger.Info("Executing %d search requests via %s for query: %s", len(requests), providerInfo, truncateString(query, 50))
startTime := time.Now()
results, err := searcher.All(ctx, requests)
duration := time.Since(startTime).Milliseconds()
if err != nil {
// Log error but don't fail - search errors shouldn't block the main flow
ctx.Logger.Error("Auto search failed: %v", err)
// === Output: Send failed message ===
ast.sendSearchDone(ctx, loadingID, 0, true)
// === Trace: Mark as failed ===
ast.completeSearchTrace(searchNode, 0, err)
// === Storage: Save failed search ===
ast.saveSearch(ctx, &SearchExecutionResult{
Query: originalQuery,
Keywords: extractedKeywords,
Config: ast.configToMap(searchConfig),
Duration: duration,
Error: err,
SearchType: "auto",
})
return nil
}
// Build reference context (includes references, XML, and prompt)
var citationConfig *searchTypes.CitationConfig
if searchConfig != nil {
citationConfig = searchConfig.Citation
}
refCtx := search.BuildReferenceContext(results, citationConfig)
resultCount := len(refCtx.References)
// === Output: Send result message, then done ===
ast.sendSearchResult(ctx, loadingID, resultCount)
ast.sendSearchDone(ctx, loadingID, resultCount, false)
// === Trace: Mark as completed ===
ast.completeSearchTrace(searchNode, resultCount, nil)
// === Storage: Save successful search ===
ast.saveSearch(ctx, &SearchExecutionResult{
Query: originalQuery,
Keywords: extractedKeywords,
Config: ast.configToMap(searchConfig),
RefCtx: refCtx,
Results: results,
Duration: duration,
SearchType: "auto",
})
if resultCount == 0 {
ctx.Logger.Info("No search results found")
return nil
}
ctx.Logger.Info("Auto search completed: %d references", resultCount)
return refCtx
}
// ============================================================================
// Output: Loading Replace Pattern
// ============================================================================
// sendSearchLoading sends the initial loading message
// Returns the message ID for later replacement
func (ast *Assistant) sendSearchLoading(ctx *context.Context) string {
loadingMsg := i18n.T(ctx.Locale, "search.loading")
msg := &message.Message{
Type: "loading",
Props: map[string]any{
"message": loadingMsg,
},
}
// Send and get message ID
msgID, err := ctx.SendStream(msg)
if err != nil {
ctx.Logger.Warn("Failed to send search loading message: %v", err)
return ""
}
return msgID
}
// sendKeywordLoading sends the keyword extraction loading message
// Returns the message ID for later replacement
func (ast *Assistant) sendKeywordLoading(ctx *context.Context) string {
loadingMsg := i18n.T(ctx.Locale, "search.keyword.loading")
msg := &message.Message{
Type: "loading",
Props: map[string]any{
"message": loadingMsg,
},
}
// Send and get message ID
msgID, err := ctx.SendStream(msg)
if err != nil {
ctx.Logger.Warn("Failed to send keyword loading message: %v", err)
return ""
}
return msgID
}
// sendKeywordDone replaces keyword loading with done message
func (ast *Assistant) sendKeywordDone(ctx *context.Context, loadingID string, success bool) {
if loadingID == "" {
return
}
resultMsg := i18n.T(ctx.Locale, "search.keyword.done")
msg := &message.Message{
MessageID: loadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: "loading",
Props: map[string]any{
"message": resultMsg,
"done": true,
},
}
if err := ctx.Send(msg); err != nil {
ctx.Logger.Warn("Failed to send keyword done message: %v", err)
}
}
// sendSearchResult replaces loading with result message (without done flag)
func (ast *Assistant) sendSearchResult(ctx *context.Context, loadingID string, count int) {
if loadingID == "" {
return
}
var resultMsg string
if count == 0 {
resultMsg = i18n.T(ctx.Locale, "search.no_results")
} else if count == 1 {
resultMsg = i18n.T(ctx.Locale, "search.success.one")
} else {
resultMsg = fmt.Sprintf(i18n.T(ctx.Locale, "search.success"), count)
}
msg := &message.Message{
MessageID: loadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: "loading",
Props: map[string]any{
"message": resultMsg,
},
}
if err := ctx.Send(msg); err != nil {
ctx.Logger.Warn("Failed to send search result message: %v", err)
}
}
// sendSearchDone sends the final done message (removes loading indicator)
func (ast *Assistant) sendSearchDone(ctx *context.Context, loadingID string, count int, failed bool) {
if loadingID == "" {
return
}
var resultMsg string
if failed {
resultMsg = i18n.T(ctx.Locale, "search.failed")
} else if count == 0 {
resultMsg = i18n.T(ctx.Locale, "search.no_results")
} else if count == 1 {
resultMsg = i18n.T(ctx.Locale, "search.success.one")
} else {
resultMsg = fmt.Sprintf(i18n.T(ctx.Locale, "search.success"), count)
}
msg := &message.Message{
MessageID: loadingID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: "loading",
Props: map[string]any{
"message": resultMsg,
"done": true, // Frontend will remove loading indicator
},
}
if err := ctx.Send(msg); err != nil {
ctx.Logger.Warn("Failed to send search done message: %v", err)
}
}
// ============================================================================
// Trace: Search Node
// ============================================================================
// createSearchTrace creates a trace node for search operation
func (ast *Assistant) createSearchTrace(ctx *context.Context, query string, requests []*searchTypes.Request) traceTypes.Node {
trace, _ := ctx.Trace()
if trace == nil {
return nil
}
// Build search types list
var searchTypes []string
for _, req := range requests {
searchTypes = append(searchTypes, string(req.Type))
}
input := map[string]any{
"query": query,
"types": searchTypes,
}
node, err := trace.Add(input, traceTypes.TraceNodeOption{
Label: i18n.T(ctx.Locale, "search.trace.label"),
Type: "search",
Icon: "search",
Description: i18n.T(ctx.Locale, "search.trace.description"),
})
if err != nil {
ctx.Logger.Warn("Failed to create search trace node: %v", err)
return nil
}
// Log search start
node.Info("Starting search", map[string]any{
"query": query,
"types": searchTypes,
})
return node
}
// completeSearchTrace marks the search trace node as completed or failed
func (ast *Assistant) completeSearchTrace(node traceTypes.Node, resultCount int, err error) {
if node == nil {
return
}
if err != nil {
node.Warn("Search failed", map[string]any{"error": err.Error()})
node.Fail(err)
return
}
// Log completion
node.Info("Search completed", map[string]any{
"result_count": resultCount,
})
// Complete with output
node.Complete(map[string]any{
"result_count": resultCount,
})
}
// buildSearchRequestsOptions contains options for building search requests
type buildSearchRequestsOptions struct {
skipKeyword bool // Skip keyword extraction
usesKeyword string // Keyword extractor config: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
}
// buildSearchRequests builds search requests based on assistant configuration and intent
// intent specifies which search types to execute (from needsearch agent)
// Returns requests and extracted keywords (if any)
func (ast *Assistant) buildSearchRequests(ctx *context.Context, query string, config *searchTypes.Config, intent *SearchIntent, opts *buildSearchRequestsOptions) ([]*searchTypes.Request, []searchTypes.Keyword) {
var requests []*searchTypes.Request
var extractedKeywords []searchTypes.Keyword
// Helper to check if a search type is allowed by intent
isTypeAllowed := func(searchType string) bool {
if intent == nil || len(intent.SearchTypes) == 0 {
return true // No intent or empty types means all types allowed
}
for _, t := range intent.SearchTypes {
if t == searchType {
return true
}
}
return false
}
// Web search - check if web search is configured and allowed by intent
if config != nil && config.Web != nil && isTypeAllowed("web") {
webQuery := query
// Extract keywords for web search if configured
if opts != nil && !opts.skipKeyword && opts.usesKeyword != "" {
// === Output: Send keyword extraction loading ===
keywordLoadingID := ast.sendKeywordLoading(ctx)
extractor := keyword.NewExtractor(opts.usesKeyword, config.Keyword)
keywords, err := extractor.Extract(ctx, query, nil)
if err != nil {
ctx.Logger.Warn("Keyword extraction failed, using original query: %v", err)
ast.sendKeywordDone(ctx, keywordLoadingID, false)
} else if len(keywords) > 0 {
extractedKeywords = keywords
// Use extracted keywords as the search query for web search
webQuery = keywordsToQuery(keywords)
ctx.Logger.Info("Extracted keywords for web search: %s -> %s", truncateString(query, 30), webQuery)
ast.sendKeywordDone(ctx, keywordLoadingID, true)
} else {
ast.sendKeywordDone(ctx, keywordLoadingID, true)
}
}
requests = append(requests, &searchTypes.Request{
Type: searchTypes.SearchTypeWeb,
Query: webQuery,
Source: searchTypes.SourceAuto,
Limit: config.Web.MaxResults,
})
}
// KB search - check if KB is configured and allowed by intent
if ast.KB != nil && len(ast.KB.Collections) > 0 && isTypeAllowed("kb") {
limit := 10
threshold := 0.7
if config != nil && config.KB != nil {
if config.KB.Threshold > 0 {
threshold = config.KB.Threshold
}
}
// Filter collections by authorization (Collection-level permission check)
allowedCollections := FilterKBCollectionsByAuth(ctx, ast.KB.Collections)
if len(allowedCollections) == 0 {
ctx.Logger.Info("No accessible KB collections after auth filter")
} else {
// Build KB request
kbReq := &searchTypes.Request{
Type: searchTypes.SearchTypeKB,
Query: query, // KB uses original query for semantic search
Source: searchTypes.SourceAuto,
Limit: limit,
Collections: allowedCollections,
Threshold: threshold,
Graph: config != nil && config.KB != nil && config.KB.Graph,
}
requests = append(requests, kbReq)
}
}
// DB search - check if DB is configured and allowed by intent
if ast.DB != nil && len(ast.DB.Models) > 0 && isTypeAllowed("db") {
limit := 20
if config != nil && config.DB != nil && config.DB.MaxResults > 0 {
limit = config.DB.MaxResults
}
// Build DB request with auth where clauses
dbReq := &searchTypes.Request{
Type: searchTypes.SearchTypeDB,
Query: query, // DB uses original query for QueryDSL generation
Source: searchTypes.SourceAuto,
Limit: limit,
Models: ast.DB.Models,
}
// Apply authorization where clauses
if authWheres := BuildDBAuthWheres(ctx); authWheres != nil {
dbReq.Wheres = authWheres
}
requests = append(requests, dbReq)
}
return requests, extractedKeywords
}
// injectSearchContext injects search results into messages
// Adds search context as a system message after existing system messages
func (ast *Assistant) injectSearchContext(messages []context.Message, refCtx *searchTypes.ReferenceContext) []context.Message {
if refCtx == nil || len(refCtx.References) == 0 {
return messages
}
// Build the search context message
var contentParts []string
// Add citation prompt
if refCtx.Prompt != "" {
contentParts = append(contentParts, refCtx.Prompt)
}
// Add XML context
if refCtx.XML != "" {
contentParts = append(contentParts, refCtx.XML)
}
if len(contentParts) == 0 {
return messages
}
// Create system message with search context
searchMessage := context.Message{
Role: "system",
Content: strings.Join(contentParts, "\n\n"),
}
// Find the position to insert the search message
// Insert after any existing system messages but before user messages
insertIndex := 0
for i, msg := range messages {
if msg.Role == "system" {
insertIndex = i + 1
} else {
break
}
}
// Insert the search message
result := make([]context.Message, 0, len(messages)+1)
result = append(result, messages[:insertIndex]...)
result = append(result, searchMessage)
result = append(result, messages[insertIndex:]...)
return result
}
// extractTextContent extracts text-only content from a message
// For multimodal messages, concatenates all text parts
// Returns empty string if no text content found
func extractTextContent(msg context.Message) string {
content := msg.Content
// Handle string content
if str, ok := content.(string); ok {
return str
}
// Handle content parts (array of objects) - extract only text parts
if parts, ok := content.([]interface{}); ok {
var texts []string
for _, part := range parts {
if partMap, ok := part.(map[string]interface{}); ok {
if partMap["type"] == "text" {
if text, ok := partMap["text"].(string); ok {
texts = append(texts, text)
}
}
}
}
if len(texts) > 0 {
return strings.Join(texts, "\n")
}
}
// Handle []context.ContentPart
if parts, ok := content.([]context.ContentPart); ok {
var texts []string
for _, part := range parts {
if part.Type == context.ContentText && part.Text != "" {
texts = append(texts, part.Text)
}
}
if len(texts) > 0 {
return strings.Join(texts, "\n")
}
}
return ""
}
// buildContextMessage builds a single user message with conversation context
// Filters out system messages and extracts text-only content
// Only takes the last 5 messages for efficiency
// Returns a slice with one message containing the full context, or empty slice if no content
func buildContextMessage(messages []context.Message) []context.Message {
const maxMessages = 5
// Take only the last maxMessages (excluding system messages)
var recentMessages []context.Message
for i := len(messages) - 1; i >= 0 && len(recentMessages) < maxMessages; i-- {
if messages[i].Role != "system" {
recentMessages = append(recentMessages, messages[i])
}
}
// Reverse to maintain chronological order
for i, j := 0, len(recentMessages)-1; i < j; i, j = i+1, j-1 {
recentMessages[i], recentMessages[j] = recentMessages[j], recentMessages[i]
}
var contextParts []string
var lastUserMessage string
for _, msg := range recentMessages {
textContent := extractTextContent(msg)
if textContent == "" {
continue
}
// Format message with role label
switch msg.Role {
case "user":
contextParts = append(contextParts, "[User]: "+textContent)
lastUserMessage = textContent
case "assistant":
contextParts = append(contextParts, "[Assistant]: "+textContent)
default:
contextParts = append(contextParts, "["+string(msg.Role)+"]: "+textContent)
}
}
// Build single message with context
var result []context.Message
if len(contextParts) > 1 {
// Multiple messages: include conversation context
fullContext := "=== Conversation Context ===\n" + strings.Join(contextParts, "\n\n") + "\n=== End Context ===\n\nCurrent user request: " + lastUserMessage
result = append(result, context.Message{
Role: "user",
Content: fullContext,
})
} else if lastUserMessage != "" {
// Single user message: just use it directly
result = append(result, context.Message{
Role: "user",
Content: lastUserMessage,
})
}
return result
}
// extractQueryFromMessages extracts the search query from messages
// Uses the last user message as the query
func extractQueryFromMessages(messages []context.Message) string {
// Find the last user message
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
content := messages[i].Content
// Handle string content
if str, ok := content.(string); ok {
return str
}
// Handle content parts (array of objects)
if parts, ok := content.([]interface{}); ok {
for _, part := range parts {
if partMap, ok := part.(map[string]interface{}); ok {
if partMap["type"] == "text" {
if text, ok := partMap["text"].(string); ok {
return text
}
}
}
}
}
}
}
return ""
}
// truncateString truncates a string to maxLen characters
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// ============================================================================
// Storage: Save Search Results
// ============================================================================
// SearchExecutionResult holds all data from search execution for storage
type SearchExecutionResult struct {
Query string // Original query (before keyword optimization)
Keywords []searchTypes.Keyword // Extracted keywords with weights
Config map[string]any // Search config used
RefCtx *searchTypes.ReferenceContext // Reference context with results
Results []*searchTypes.Result // Raw search results (for extracting DSL, etc.)
Duration int64 // Search duration in ms
Error error // Error if failed
SearchType string // "auto", "web", "kb", "db"
}
// keywordsToQuery converts keywords with weights to a search query string
// Keywords are sorted by weight (descending) and joined with spaces
func keywordsToQuery(keywords []searchTypes.Keyword) string {
if len(keywords) == 0 {
return ""
}
// Sort by weight descending (higher weight first)
sorted := make([]searchTypes.Keyword, len(keywords))
copy(sorted, keywords)
for i := 0; i < len(sorted)-1; i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[j].W > sorted[i].W {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
// Join keywords
parts := make([]string, len(sorted))
for i, kw := range sorted {
parts[i] = kw.K
}
return strings.Join(parts, " ")
}
// keywordsToStrings converts keywords to string slice for storage
func keywordsToStrings(keywords []searchTypes.Keyword) []string {
if len(keywords) == 0 {
return nil
}
result := make([]string, len(keywords))
for i, kw := range keywords {
result[i] = kw.K
}
return result
}
// containsSearchType checks if a search type is in the list
func containsSearchType(types []string, searchType string) bool {
for _, t := range types {
if t == searchType {
return true
}
}
return false
}
// saveSearch saves search results to storage
// Called after search execution completes (success or failure)
func (ast *Assistant) saveSearch(ctx *context.Context, execResult *SearchExecutionResult) {
// Get store
store := GetStore()
if store == nil {
ctx.Logger.Debug("Storage not configured, skipping search save")
return
}
// Build search record
searchRecord := &storeTypes.Search{
RequestID: ctx.RequestID(),
ChatID: ctx.ChatID,
Query: execResult.Query,
Keywords: keywordsToStrings(execResult.Keywords),
Config: execResult.Config,
Source: execResult.SearchType,
Duration: execResult.Duration,
CreatedAt: time.Now(),
}
// Set error if present
if execResult.Error != nil {
searchRecord.Error = execResult.Error.Error()
}
// Convert references if available
if execResult.RefCtx != nil {
searchRecord.References = convertToStoreReferences(execResult.RefCtx.References)
searchRecord.XML = execResult.RefCtx.XML
searchRecord.Prompt = execResult.RefCtx.Prompt
}
// Extract DSL from DB search results
if execResult.Results != nil {
for _, result := range execResult.Results {
if result != nil && result.Type == searchTypes.SearchTypeDB && result.DSL != nil {
searchRecord.DSL = result.DSL
break // Only store the first DSL (usually there's only one DB search)
}
}
}
// Save to store
if err := store.SaveSearch(searchRecord); err != nil {
ctx.Logger.Warn("Failed to save search record: %v", err)
return
}
ctx.Logger.Debug("Search record saved: request_id=%s, refs=%d",
searchRecord.RequestID, len(searchRecord.References))
}
// convertToStoreReferences converts search References to store References
func convertToStoreReferences(refs []*searchTypes.Reference) []storeTypes.Reference {
if len(refs) == 0 {
return nil
}
storeRefs := make([]storeTypes.Reference, len(refs))
for i, ref := range refs {
if ref == nil {
continue
}
// Parse citation ID as integer (e.g., "1", "2", "3")
index := i + 1 // Default to position-based index
if ref.ID != "" {
if n, err := fmt.Sscanf(ref.ID, "%d", &index); n != 1 || err != nil {
index = i + 1
}
}
storeRefs[i] = storeTypes.Reference{
Index: index,
Type: string(ref.Type),
Title: ref.Title,
URL: ref.URL,
Snippet: truncateString(ref.Content, 200), // Short snippet
Content: ref.Content,
Metadata: map[string]any{
"weight": ref.Weight,
"score": ref.Score,
"source": string(ref.Source),
},
}
}
return storeRefs
}
// configToMap converts search config to map for storage
func (ast *Assistant) configToMap(config *searchTypes.Config) map[string]any {
if config == nil {
return nil
}
result := make(map[string]any)
if config.Web != nil {
result["web"] = map[string]any{
"provider": config.Web.Provider,
"max_results": config.Web.MaxResults,
}
}
if config.KB != nil {
result["kb"] = map[string]any{
"threshold": config.KB.Threshold,
"graph": config.KB.Graph,
}
}
if config.DB != nil {
result["db"] = map[string]any{
"max_results": config.DB.MaxResults,
}
}
if config.Weights != nil {
result["weights"] = map[string]any{
"user": config.Weights.User,
"hook": config.Weights.Hook,
"auto": config.Weights.Auto,
}
}
return result
}
// getSearchProviderInfo returns a human-readable string describing the search provider(s)
func (ast *Assistant) getSearchProviderInfo(config *searchTypes.Config, uses *search.Uses) string {
var parts []string
// Web search provider - always show when web search is being executed
webMode := ""
if uses != nil {
webMode = uses.Web
}
if webMode == "" || webMode == "builtin" {
// Builtin mode: show the actual provider (tavily/serper/serpapi)
provider := "tavily" // default
if config != nil && config.Web != nil && config.Web.Provider != "" {
provider = config.Web.Provider
}
parts = append(parts, "web:"+provider)
} else if strings.HasPrefix(webMode, "mcp:") {
parts = append(parts, "web:"+webMode)
} else {
parts = append(parts, "web:agent:"+webMode)
}
// KB search
if config != nil && config.KB != nil && len(config.KB.Collections) > 0 {
parts = append(parts, "kb")
}
// DB search
if config != nil && config.DB != nil && len(config.DB.Models) > 0 {
parts = append(parts, "db")
}
return strings.Join(parts, ", ")
}