Enhance Search Functionality with Keyword Extraction and Intent Detection

- Updated the search handling to incorporate keyword extraction with weights, improving the relevance of search results.
- Refactored the `shouldAutoSearch` method to return a `SearchIntent` struct, allowing for more nuanced control over search execution based on context.
- Enhanced the `buildSearchRequests` function to utilize extracted keywords, optimizing search queries based on user input.
- Improved the handling of search types and conditions, ensuring that the system can dynamically adjust search behavior based on intent and configuration.
- Updated documentation and prompts to reflect changes in keyword extraction and search intent classification, providing clearer guidelines for usage.
This commit is contained in:
Max 2025-12-18 20:16:56 +08:00
parent 99ac7abdf1
commit 3cf3060e0c
16 changed files with 820 additions and 394 deletions

View file

@ -205,8 +205,8 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// ================================================ // ================================================
// Execute Auto Search (if enabled) // Execute Auto Search (if enabled)
// ================================================ // ================================================
if ast.shouldAutoSearch(ctx, completionMessages, createResponse, opts) { if intent := ast.shouldAutoSearch(ctx, completionMessages, createResponse, opts); intent != nil {
refCtx := ast.executeAutoSearch(ctx, completionMessages, createResponse, opts) refCtx := ast.executeAutoSearch(ctx, completionMessages, createResponse, intent, opts)
if refCtx != nil && len(refCtx.References) > 0 { if refCtx != nil && len(refCtx.References) > 0 {
completionMessages = ast.injectSearchContext(completionMessages, refCtx) completionMessages = ast.injectSearchContext(completionMessages, refCtx)
} }

View file

@ -17,16 +17,32 @@ import (
) )
// shouldAutoSearch determines if auto search should be executed // shouldAutoSearch determines if auto search should be executed
// Returns false if: // Returns nil if search should be skipped, otherwise returns SearchIntent with types to search
// Search is skipped if:
// - opts.Skip.Search is true // - opts.Skip.Search is true
// - createResponse.Search is false
// - uses.search is "disabled" // - uses.search is "disabled"
// - assistant has no search configuration // - assistant has no search configuration
// - needsearch intent detection returns false // - needsearch intent detection returns false
func (ast *Assistant) shouldAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, opts *context.Options) bool { func (ast *Assistant) shouldAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, opts *context.Options) *SearchIntent {
// Check if search is skipped via options // Check if search is skipped via options
if opts != nil && opts.Skip != nil && opts.Skip.Search { if opts != nil && opts.Skip != nil && opts.Skip.Search {
ctx.Logger.Debug("Auto search skipped by opts.Skip.Search") ctx.Logger.Debug("Auto search skipped by opts.Skip.Search")
return false 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 // Get merged uses configuration
@ -35,27 +51,101 @@ func (ast *Assistant) shouldAutoSearch(ctx *context.Context, messages []context.
// Check if search is explicitly disabled // Check if search is explicitly disabled
if uses != nil && uses.Search == "disabled" { if uses != nil && uses.Search == "disabled" {
ctx.Logger.Info("Auto search disabled by uses.search=disabled") ctx.Logger.Info("Auto search disabled by uses.search=disabled")
return false return nil
} }
// Check if assistant has search configuration // Check if assistant has search configuration
if ast.Search == nil && (uses == nil || uses.Search == "") { if ast.Search == nil && (uses == nil || uses.Search == "") {
return false return nil
} }
// Check search intent using __yao.needsearch agent // Check search intent using __yao.needsearch agent
if !ast.checkSearchIntent(ctx, messages) { intent := ast.checkSearchIntent(ctx, messages)
if intent == nil || !intent.NeedSearch {
ctx.Logger.Info("Auto search skipped: intent detection returned false") ctx.Logger.Info("Auto search skipped: intent detection returned false")
return false return nil
} }
// Check if search is enabled (builtin, agent, mcp, or empty means builtin) return intent
return true }
// 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", "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 // checkSearchIntent uses __yao.needsearch agent to determine if search is needed
// Returns true if search is needed, false otherwise // Returns SearchIntent with search types and confidence
func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context.Message) bool { 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,
}
// Filter out system messages and pass full conversation context // Filter out system messages and pass full conversation context
var intentMessages []context.Message var intentMessages []context.Message
for _, msg := range messages { for _, msg := range messages {
@ -65,14 +155,14 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
} }
if len(intentMessages) == 0 { if len(intentMessages) == 0 {
return true // No messages, proceed with search return defaultIntent // No messages, skip search
} }
// Try to get __yao.needsearch agent // Try to get __yao.needsearch agent
needsearchAst, err := Get("__yao.needsearch") needsearchAst, err := Get("__yao.needsearch")
if err != nil { if err != nil {
ctx.Logger.Debug("__yao.needsearch agent not available: %v, proceeding with search", err) ctx.Logger.Debug("__yao.needsearch agent not available: %v, skipping search", err)
return true // Agent not available, proceed with search return defaultIntent // Agent not available, skip search
} }
// === Output: Send loading message === // === Output: Send loading message ===
@ -90,10 +180,10 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
result, err := needsearchAst.Stream(ctx, intentMessages, opts) result, err := needsearchAst.Stream(ctx, intentMessages, opts)
if err != nil { if err != nil {
ctx.Logger.Debug("__yao.needsearch failed: %v, proceeding with search", err) ctx.Logger.Debug("__yao.needsearch failed: %v, skipping search", err)
// === Output: Send done (error case, proceed with search) === // === Output: Send done (error case, skip search) ===
ast.sendIntentDone(ctx, loadingID, true, "") ast.sendIntentDone(ctx, loadingID, false, "")
return true // On error, proceed with search return defaultIntent // On error, skip search
} }
// Parse the result // Parse the result
@ -109,11 +199,12 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
intentData = nextData intentData = nextData
} }
if needSearch, ok := intentData["need_search"].(bool); ok { intent := parseSearchIntent(intentData)
reason, _ := intentData["reason"].(string) if intent != nil {
ctx.Logger.Debug("Search intent (from Next): need_search=%v, reason=%s", needSearch, reason) ctx.Logger.Debug("Search intent (from Next): need_search=%v, types=%v, confidence=%.2f, reason=%s",
ast.sendIntentDone(ctx, loadingID, needSearch, reason) intent.NeedSearch, intent.SearchTypes, intent.Confidence, intent.Reason)
return needSearch ast.sendIntentDone(ctx, loadingID, intent.NeedSearch, intent.Reason)
return intent
} }
} }
} }
@ -122,24 +213,75 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
if result.Completion != nil { if result.Completion != nil {
content, ok := result.Completion.Content.(string) content, ok := result.Completion.Content.(string)
if !ok || content == "" { if !ok || content == "" {
ast.sendIntentDone(ctx, loadingID, true, "") ast.sendIntentDone(ctx, loadingID, false, "")
return true return defaultIntent
} }
needSearch, reason := parseNeedSearchFromContent(content) intent := parseSearchIntentFromContent(content)
ctx.Logger.Debug("Search intent (from Content): need_search=%v, reason=%s", needSearch, reason) ctx.Logger.Debug("Search intent (from Content): need_search=%v, types=%v, confidence=%.2f, reason=%s",
ast.sendIntentDone(ctx, loadingID, needSearch, reason) intent.NeedSearch, intent.SearchTypes, intent.Confidence, intent.Reason)
return needSearch ast.sendIntentDone(ctx, loadingID, intent.NeedSearch, intent.Reason)
return intent
} }
// Default: proceed with search if we can't parse the result // Default: skip search if we can't parse the result
// === Output: Send done (default case) === // === Output: Send done (default case) ===
ast.sendIntentDone(ctx, loadingID, true, "") ast.sendIntentDone(ctx, loadingID, false, "")
return true return defaultIntent
} }
// parseNeedSearchFromContent parses need_search result from LLM completion content // 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 // Handles JSON wrapped in markdown code blocks
func parseNeedSearchFromContent(content string) (bool, string) { func parseSearchIntentFromContent(content string) *SearchIntent {
// Default intent: no search needed
defaultIntent := &SearchIntent{
NeedSearch: false,
SearchTypes: []string{},
Confidence: 0,
}
// Remove markdown code block if present // Remove markdown code block if present
content = strings.TrimSpace(content) content = strings.TrimSpace(content)
if strings.HasPrefix(content, "```json") { if strings.HasPrefix(content, "```json") {
@ -155,17 +297,16 @@ func parseNeedSearchFromContent(content string) (bool, string) {
// Try to parse JSON // Try to parse JSON
var result map[string]interface{} var result map[string]interface{}
if err := json.Unmarshal([]byte(content), &result); err != nil { if err := json.Unmarshal([]byte(content), &result); err != nil {
// Failed to parse, default to search // Failed to parse, default to no search
return true, "" return defaultIntent
} }
needSearch, ok := result["need_search"].(bool) intent := parseSearchIntent(result)
if !ok { if intent == nil {
return true, "" return defaultIntent
} }
reason, _ := result["reason"].(string) return intent
return needSearch, reason
} }
// sendIntentLoading sends the initial intent detection loading message // sendIntentLoading sends the initial intent detection loading message
@ -261,10 +402,11 @@ func (ast *Assistant) getMergedSearchUses(createResponse *context.HookCreateResp
return uses return uses
} }
// executeAutoSearch executes auto search based on configuration // executeAutoSearch executes auto search based on configuration and intent
// Returns ReferenceContext with results and formatted context // 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 // opts is optional, used to check Skip.Keyword
func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, opts ...*context.Options) *searchTypes.ReferenceContext { func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, intent *SearchIntent, opts ...*context.Options) *searchTypes.ReferenceContext {
ctx.Logger.Phase("Search") ctx.Logger.Phase("Search")
defer ctx.Logger.PhaseComplete("Search") defer ctx.Logger.PhaseComplete("Search")
@ -301,33 +443,23 @@ func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context
skipKeyword = opts[0].Skip.Keyword skipKeyword = opts[0].Skip.Keyword
} }
// Extract keywords for web search if: // Build search requests based on configuration and intent
// 1. uses.keyword is configured (not empty) // Keyword extraction is done inside buildSearchRequests for web search
// 2. Skip.Keyword is not true buildOpts := &buildSearchRequestsOptions{
// 3. Web search is enabled skipKeyword: skipKeyword,
var extractedKeywords []string usesKeyword: searchUses.Keyword,
webSearchEnabled := searchConfig != nil && searchConfig.Web != nil
if webSearchEnabled && !skipKeyword && searchUses.Keyword != "" {
extractor := keyword.NewExtractor(searchUses.Keyword, searchConfig.Keyword)
keywords, err := extractor.Extract(ctx, query, nil)
if err != nil {
ctx.Logger.Warn("Keyword extraction failed, using original query: %v", err)
} else if len(keywords) > 0 {
extractedKeywords = keywords
// Use extracted keywords as the search query for web search
optimizedQuery := strings.Join(keywords, " ")
ctx.Logger.Info("Extracted keywords for web search: %s -> %s", truncateString(query, 30), optimizedQuery)
query = optimizedQuery
} }
} requests, extractedKeywords := ast.buildSearchRequests(ctx, query, searchConfig, intent, buildOpts)
// Build search requests based on configuration
requests := ast.buildSearchRequests(query, searchConfig)
if len(requests) == 0 { if len(requests) == 0 {
ctx.Logger.Info("No search requests to execute") ctx.Logger.Info("No search requests to execute")
return nil return nil
} }
// Update query if keywords were extracted (for web search)
if len(extractedKeywords) > 0 {
query = keywordsToQuery(extractedKeywords)
}
// === Output: Send loading message === // === Output: Send loading message ===
loadingID := ast.sendSearchLoading(ctx) loadingID := ast.sendSearchLoading(ctx)
@ -425,6 +557,52 @@ func (ast *Assistant) sendSearchLoading(ctx *context.Context) string {
return msgID 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) // sendSearchResult replaces loading with result message (without done flag)
func (ast *Assistant) sendSearchResult(ctx *context.Context, loadingID string, count int) { func (ast *Assistant) sendSearchResult(ctx *context.Context, loadingID string, count int) {
if loadingID == "" { if loadingID == "" {
@ -554,22 +732,67 @@ func (ast *Assistant) completeSearchTrace(node traceTypes.Node, resultCount int,
}) })
} }
// buildSearchRequests builds search requests based on assistant configuration // buildSearchRequestsOptions contains options for building search requests
func (ast *Assistant) buildSearchRequests(query string, config *searchTypes.Config) []*searchTypes.Request { type buildSearchRequestsOptions struct {
var requests []*searchTypes.Request 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)
}
}
// Web search - check if web search is configured
if config != nil && config.Web != nil {
requests = append(requests, &searchTypes.Request{ requests = append(requests, &searchTypes.Request{
Type: searchTypes.SearchTypeWeb, Type: searchTypes.SearchTypeWeb,
Query: query, Query: webQuery,
Source: searchTypes.SourceAuto, Source: searchTypes.SourceAuto,
Limit: config.Web.MaxResults, Limit: config.Web.MaxResults,
}) })
} }
// KB search - check if KB is configured // KB search - check if KB is configured and allowed by intent
if ast.KB != nil && len(ast.KB.Collections) > 0 { if ast.KB != nil && len(ast.KB.Collections) > 0 && isTypeAllowed("kb") {
limit := 10 limit := 10
threshold := 0.7 threshold := 0.7
if config != nil && config.KB != nil { if config != nil && config.KB != nil {
@ -579,7 +802,7 @@ func (ast *Assistant) buildSearchRequests(query string, config *searchTypes.Conf
} }
requests = append(requests, &searchTypes.Request{ requests = append(requests, &searchTypes.Request{
Type: searchTypes.SearchTypeKB, Type: searchTypes.SearchTypeKB,
Query: query, Query: query, // KB uses original query for semantic search
Source: searchTypes.SourceAuto, Source: searchTypes.SourceAuto,
Limit: limit, Limit: limit,
Collections: ast.KB.Collections, Collections: ast.KB.Collections,
@ -588,22 +811,22 @@ func (ast *Assistant) buildSearchRequests(query string, config *searchTypes.Conf
}) })
} }
// DB search - check if DB is configured // DB search - check if DB is configured and allowed by intent
if ast.DB != nil && len(ast.DB.Models) > 0 { if ast.DB != nil && len(ast.DB.Models) > 0 && isTypeAllowed("db") {
limit := 20 limit := 20
if config != nil && config.DB != nil && config.DB.MaxResults > 0 { if config != nil && config.DB != nil && config.DB.MaxResults > 0 {
limit = config.DB.MaxResults limit = config.DB.MaxResults
} }
requests = append(requests, &searchTypes.Request{ requests = append(requests, &searchTypes.Request{
Type: searchTypes.SearchTypeDB, Type: searchTypes.SearchTypeDB,
Query: query, Query: query, // DB uses original query for QueryDSL generation
Source: searchTypes.SourceAuto, Source: searchTypes.SourceAuto,
Limit: limit, Limit: limit,
Models: ast.DB.Models, Models: ast.DB.Models,
}) })
} }
return requests return requests, extractedKeywords
} }
// injectSearchContext injects search results into messages // injectSearchContext injects search results into messages
@ -699,7 +922,7 @@ func truncateString(s string, maxLen int) string {
// SearchExecutionResult holds all data from search execution for storage // SearchExecutionResult holds all data from search execution for storage
type SearchExecutionResult struct { type SearchExecutionResult struct {
Query string // Original query (before keyword optimization) Query string // Original query (before keyword optimization)
Keywords []string // Extracted keywords Keywords []searchTypes.Keyword // Extracted keywords with weights
Config map[string]any // Search config used Config map[string]any // Search config used
RefCtx *searchTypes.ReferenceContext // Reference context with results RefCtx *searchTypes.ReferenceContext // Reference context with results
Duration int64 // Search duration in ms Duration int64 // Search duration in ms
@ -707,6 +930,54 @@ type SearchExecutionResult struct {
SearchType string // "auto", "web", "kb", "db" 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 // saveSearch saves search results to storage
// Called after search execution completes (success or failure) // Called after search execution completes (success or failure)
func (ast *Assistant) saveSearch(ctx *context.Context, execResult *SearchExecutionResult) { func (ast *Assistant) saveSearch(ctx *context.Context, execResult *SearchExecutionResult) {
@ -722,7 +993,7 @@ func (ast *Assistant) saveSearch(ctx *context.Context, execResult *SearchExecuti
RequestID: ctx.RequestID(), RequestID: ctx.RequestID(),
ChatID: ctx.ChatID, ChatID: ctx.ChatID,
Query: execResult.Query, Query: execResult.Query,
Keywords: execResult.Keywords, Keywords: keywordsToStrings(execResult.Keywords),
Config: execResult.Config, Config: execResult.Config,
Source: execResult.SearchType, Source: execResult.SearchType,
Duration: execResult.Duration, Duration: execResult.Duration,

View file

@ -85,6 +85,10 @@ type NextProcessContext struct {
CreateResponse *chatctx.HookCreateResponse // Create hook response CreateResponse *chatctx.HookCreateResponse // Create hook response
} }
// SearchIntent is an alias for context.SearchIntent
// Used for search intent detection from __yao.needsearch agent
type SearchIntent = chatctx.SearchIntent
// ParsedContent extracts the actual tool return value from MCP ToolContent array // ParsedContent extracts the actual tool return value from MCP ToolContent array
// According to MCP protocol: // According to MCP protocol:
// - Content is []ToolContent array // - Content is []ToolContent array

View file

@ -16,7 +16,7 @@ func (opts *Options) ToMap() map[string]interface{} {
result["mode"] = opts.Mode result["mode"] = opts.Mode
} }
if opts.Search != nil { if opts.Search != nil {
result["search"] = *opts.Search result["search"] = opts.Search
} }
if opts.Skip != nil { if opts.Skip != nil {
result["skip"] = opts.Skip result["skip"] = opts.Skip
@ -50,8 +50,9 @@ func OptionsFromMap(m map[string]interface{}) *Options {
if mode, ok := m["mode"].(string); ok { if mode, ok := m["mode"].(string); ok {
opts.Mode = mode opts.Mode = mode
} }
if search, ok := m["search"].(bool); ok { // Search supports: bool | SearchIntent | map[string]any | nil
opts.Search = &search if search := m["search"]; search != nil {
opts.Search = search
} }
if skipMap, ok := m["skip"].(map[string]interface{}); ok { if skipMap, ok := m["skip"].(map[string]interface{}); ok {
skip := &Skip{} skip := &Skip{}

View file

@ -266,6 +266,15 @@ type Context struct {
Metadata map[string]interface{} `json:"metadata,omitempty"` // The metadata of the request, it will be used to pass data to the page Metadata map[string]interface{} `json:"metadata,omitempty"` // The metadata of the request, it will be used to pass data to the page
} }
// SearchIntent represents the result of search intent detection
// Used by Create hook to specify fine-grained search behavior
type SearchIntent struct {
NeedSearch bool `json:"need_search"` // Whether search is needed
SearchTypes []string `json:"search_types,omitempty"` // Types of search to perform: "web", "kb", "db"
Confidence float64 `json:"confidence,omitempty"` // Confidence level (0-1)
Reason string `json:"reason,omitempty"` // Reason for the decision
}
// Options represents the options for the context // Options represents the options for the context
type Options struct { type Options struct {
@ -284,8 +293,11 @@ type Options struct {
// Disable global prompts, default is false // Disable global prompts, default is false
DisableGlobalPrompts bool `json:"disable_global_prompts,omitempty"` // Temporarily disable global prompts for this request DisableGlobalPrompts bool `json:"disable_global_prompts,omitempty"` // Temporarily disable global prompts for this request
// Search mode, default is true // Search controls search behavior, supports multiple types:
Search *bool `json:"search,omitempty"` // Search mode, default is true // - bool: true = enable all search types, false = disable all search
// - SearchIntent: fine-grained control with specific types, confidence, etc.
// - nil: use default behavior (determined by __yao.needsearch agent)
Search any `json:"search,omitempty"` // Search mode: bool | SearchIntent | nil
// Agent mode, use to select the mode of the request, default is "chat" // Agent mode, use to select the mode of the request, default is "chat"
Mode string `json:"mode,omitempty"` // Agent mode, use to select the mode of the request, default is "chat" Mode string `json:"mode,omitempty"` // Agent mode, use to select the mode of the request, default is "chat"
@ -372,6 +384,12 @@ type HookCreateResponse struct {
// ForceUses controls whether to force using Uses tools even when model has native capabilities // ForceUses controls whether to force using Uses tools even when model has native capabilities
ForceUses *bool `json:"force_uses,omitempty"` // Force using Uses tools regardless of model capabilities ForceUses *bool `json:"force_uses,omitempty"` // Force using Uses tools regardless of model capabilities
// Search controls search behavior, supports multiple types:
// - bool: true = enable all search types, false = disable all search
// - SearchIntent: fine-grained control with specific types, confidence, etc.
// - nil: use default behavior (determined by __yao.needsearch agent)
Search any `json:"search,omitempty"` // Search mode: bool | SearchIntent | nil
} }
// NextHookPayload payload for the next hook // NextHookPayload payload for the next hook

View file

@ -112,6 +112,10 @@ func init() {
"search.intent.need_search": "Searching for references...", "search.intent.need_search": "Searching for references...",
"search.intent.no_search": "No references needed", "search.intent.no_search": "No references needed",
// Keyword Extraction: assistant/search.go - Keyword extraction messages
"search.keyword.loading": "Analyzing conversation...",
"search.keyword.done": "Analysis complete",
// Search: assistant/search.go - Trace labels // Search: assistant/search.go - Trace labels
"search.trace.label": "Search", "search.trace.label": "Search",
"search.trace.description": "Search the web and knowledge base for relevant information", "search.trace.description": "Search the web and knowledge base for relevant information",
@ -201,6 +205,10 @@ func init() {
"search.intent.need_search": "正在查询相关资料...", "search.intent.need_search": "正在查询相关资料...",
"search.intent.no_search": "无需查询资料", "search.intent.no_search": "无需查询资料",
// Keyword Extraction: assistant/search.go - Keyword extraction messages
"search.keyword.loading": "正在分析对话内容...",
"search.keyword.done": "分析完成",
// Search: assistant/search.go - Trace labels // Search: assistant/search.go - Trace labels
"search.trace.label": "搜索", "search.trace.label": "搜索",
"search.trace.description": "搜索网络和知识库获取相关信息", "search.trace.description": "搜索网络和知识库获取相关信息",
@ -318,6 +326,10 @@ func init() {
"search.intent.need_search": "正在查询相关资料...", "search.intent.need_search": "正在查询相关资料...",
"search.intent.no_search": "无需查询资料", "search.intent.no_search": "无需查询资料",
// Keyword Extraction: assistant/search.go - Keyword extraction messages
"search.keyword.loading": "正在分析对话内容...",
"search.keyword.done": "分析完成",
// Search: assistant/search.go - Trace labels // Search: assistant/search.go - Trace labels
"search.trace.label": "搜索", "search.trace.label": "搜索",
"search.trace.description": "搜索网络和知识库获取相关信息", "search.trace.description": "搜索网络和知识库获取相关信息",

View file

@ -213,6 +213,10 @@ func initAssistant() error {
Audio: agentDSL.Uses.Audio, Audio: agentDSL.Uses.Audio,
Search: agentDSL.Uses.Search, Search: agentDSL.Uses.Search,
Fetch: agentDSL.Uses.Fetch, Fetch: agentDSL.Uses.Fetch,
Web: agentDSL.Uses.Web,
Keyword: agentDSL.Uses.Keyword,
QueryDSL: agentDSL.Uses.QueryDSL,
Rerank: agentDSL.Uses.Rerank,
} }
assistant.SetGlobalUses(globalUses) assistant.SetGlobalUses(globalUses)
} }

View file

@ -23,8 +23,8 @@ func NewAgentProvider(agentID string) *AgentProvider {
} }
// Extract extracts keywords by calling the target agent // Extract extracts keywords by calling the target agent
// The agent receives the content and returns extracted keywords // The agent receives the content and returns extracted keywords with weights
func (p *AgentProvider) Extract(ctx *agentContext.Context, content string, opts *types.KeywordOptions) ([]string, error) { func (p *AgentProvider) Extract(ctx *agentContext.Context, content string, opts *types.KeywordOptions) ([]types.Keyword, error) {
if ctx == nil { if ctx == nil {
return nil, fmt.Errorf("context is required for agent keyword extraction") return nil, fmt.Errorf("context is required for agent keyword extraction")
} }
@ -77,19 +77,20 @@ func (p *AgentProvider) Extract(ctx *agentContext.Context, content string, opts
// Now that agent.Stream() returns *context.Response directly, // Now that agent.Stream() returns *context.Response directly,
// we can access fields without type assertions. // we can access fields without type assertions.
// //
// The agent returns keywords in response.Next field // The agent returns keywords in response.Next field as {data: {keywords: [{k, w}, ...]}}
func (p *AgentProvider) parseResponse(response *agentContext.Response) ([]string, error) { func (p *AgentProvider) parseResponse(response *agentContext.Response) ([]types.Keyword, error) {
if response == nil || response.Next == nil { if response == nil || response.Next == nil {
return []string{}, nil return []types.Keyword{}, nil
} }
return p.parseNextData(response.Next) return p.parseNextData(response.Next)
} }
// parseNextData extracts keywords from Next hook data // parseNextData extracts keywords from Next hook data
func (p *AgentProvider) parseNextData(next interface{}) ([]string, error) { // Expected format: {data: {keywords: [{k: "keyword", w: 0.9}, ...]}}
func (p *AgentProvider) parseNextData(next interface{}) ([]types.Keyword, error) {
if next == nil { if next == nil {
return []string{}, nil return []types.Keyword{}, nil
} }
// Try to convert to map first (most common case) // Try to convert to map first (most common case)
@ -101,32 +102,26 @@ func (p *AgentProvider) parseNextData(next interface{}) ([]string, error) {
case string: case string:
// Try to parse as JSON // Try to parse as JSON
if err := json.Unmarshal([]byte(v), &data); err != nil { if err := json.Unmarshal([]byte(v), &data); err != nil {
// Not a JSON object, try as array // Not a JSON object, try as array of keywords
var keywords []string var keywords []types.Keyword
if err := json.Unmarshal([]byte(v), &keywords); err == nil { if err := json.Unmarshal([]byte(v), &keywords); err == nil {
return keywords, nil return keywords, nil
} }
// Return as single keyword // Return as single keyword with default weight
return []string{v}, nil return []types.Keyword{{K: v, W: 0.5}}, nil
} }
case []string: case []types.Keyword:
return v, nil return v, nil
case []interface{}: case []interface{}:
keywords := make([]string, 0, len(v)) return p.extractKeywordsFromArray(v)
for _, item := range v {
if s, ok := item.(string); ok {
keywords = append(keywords, s)
}
}
return keywords, nil
default: default:
// Try to marshal and unmarshal // Try to marshal and unmarshal
jsonBytes, err := json.Marshal(next) jsonBytes, err := json.Marshal(next)
if err != nil { if err != nil {
return []string{}, nil return []types.Keyword{}, nil
} }
if err := json.Unmarshal(jsonBytes, &data); err != nil { if err := json.Unmarshal(jsonBytes, &data); err != nil {
return []string{}, nil return []types.Keyword{}, nil
} }
} }
@ -144,28 +139,48 @@ func (p *AgentProvider) parseNextData(next interface{}) ([]string, error) {
return p.extractKeywordsFromValue(d) return p.extractKeywordsFromValue(d)
} }
return []string{}, nil return []types.Keyword{}, nil
} }
// extractKeywordsFromValue extracts string array from various types // extractKeywordsFromValue extracts Keyword array from various types
func (p *AgentProvider) extractKeywordsFromValue(v interface{}) ([]string, error) { func (p *AgentProvider) extractKeywordsFromValue(v interface{}) ([]types.Keyword, error) {
switch kw := v.(type) { switch kw := v.(type) {
case []string: case []types.Keyword:
return kw, nil return kw, nil
case []interface{}: case []interface{}:
keywords := make([]string, 0, len(kw)) return p.extractKeywordsFromArray(kw)
for _, item := range kw {
if s, ok := item.(string); ok {
keywords = append(keywords, s)
}
}
return keywords, nil
case string: case string:
var keywords []string var keywords []types.Keyword
if err := json.Unmarshal([]byte(kw), &keywords); err == nil { if err := json.Unmarshal([]byte(kw), &keywords); err == nil {
return keywords, nil return keywords, nil
} }
return []string{kw}, nil return []types.Keyword{{K: kw, W: 0.5}}, nil
} }
return []string{}, nil return []types.Keyword{}, nil
}
// extractKeywordsFromArray extracts keywords from []interface{}
// Handles both {k, w} objects and plain strings
func (p *AgentProvider) extractKeywordsFromArray(items []interface{}) ([]types.Keyword, error) {
keywords := make([]types.Keyword, 0, len(items))
for _, item := range items {
switch v := item.(type) {
case map[string]interface{}:
// Handle {k: "keyword", w: 0.9} format
k, _ := v["k"].(string)
w, _ := v["w"].(float64)
if k != "" {
if w == 0 {
w = 0.5 // Default weight
}
keywords = append(keywords, types.Keyword{K: k, W: w})
}
case string:
// Plain string, use default weight
if v != "" {
keywords = append(keywords, types.Keyword{K: v, W: 0.5})
}
}
}
return keywords, nil
} }

View file

@ -34,8 +34,8 @@ func NewExtractor(usesKeyword string, cfg *types.KeywordConfig) *Extractor {
} }
// Extract extracts keywords from content based on configured mode // Extract extracts keywords from content based on configured mode
// Returns a list of keywords optimized for search queries // Returns a list of keywords with weights optimized for search queries
func (e *Extractor) Extract(ctx *context.Context, content string, opts *types.KeywordOptions) ([]string, error) { func (e *Extractor) Extract(ctx *context.Context, content string, opts *types.KeywordOptions) ([]types.Keyword, error) {
// Merge options with config defaults // Merge options with config defaults
mergedOpts := e.mergeOptions(opts) mergedOpts := e.mergeOptions(opts)
@ -83,7 +83,7 @@ func (e *Extractor) mergeOptions(opts *types.KeywordOptions) *types.KeywordOptio
// agentExtract delegates to an LLM-powered assistant // agentExtract delegates to an LLM-powered assistant
// The assistant can understand context and extract semantically relevant keywords // The assistant can understand context and extract semantically relevant keywords
func (e *Extractor) agentExtract(ctx *context.Context, content string, agentID string, opts *types.KeywordOptions) ([]string, error) { func (e *Extractor) agentExtract(ctx *context.Context, content string, agentID string, opts *types.KeywordOptions) ([]types.Keyword, error) {
if ctx == nil { if ctx == nil {
return nil, fmt.Errorf("context is required for keyword extraction") return nil, fmt.Errorf("context is required for keyword extraction")
} }
@ -93,7 +93,7 @@ func (e *Extractor) agentExtract(ctx *context.Context, content string, agentID s
// mcpExtract calls an external MCP tool // mcpExtract calls an external MCP tool
// Format: "mcp:<server>.<tool>" // Format: "mcp:<server>.<tool>"
func (e *Extractor) mcpExtract(ctx *context.Context, content string, opts *types.KeywordOptions) ([]string, error) { func (e *Extractor) mcpExtract(ctx *context.Context, content string, opts *types.KeywordOptions) ([]types.Keyword, error) {
mcpRef := strings.TrimPrefix(e.usesKeyword, "mcp:") mcpRef := strings.TrimPrefix(e.usesKeyword, "mcp:")
provider, err := NewMCPProvider(mcpRef) provider, err := NewMCPProvider(mcpRef)
if err != nil { if err != nil {

View file

@ -31,7 +31,7 @@ func NewMCPProvider(mcpRef string) (*MCPProvider, error) {
} }
// Extract extracts keywords by calling the MCP tool // Extract extracts keywords by calling the MCP tool
func (p *MCPProvider) Extract(ctx *agentContext.Context, content string, opts *types.KeywordOptions) ([]string, error) { func (p *MCPProvider) Extract(ctx *agentContext.Context, content string, opts *types.KeywordOptions) ([]types.Keyword, error) {
// Get MCP client // Get MCP client
client, err := mcp.Select(p.serverID) client, err := mcp.Select(p.serverID)
if err != nil { if err != nil {
@ -56,9 +56,9 @@ func (p *MCPProvider) Extract(ctx *agentContext.Context, content string, opts *t
} }
// parseResult extracts keywords from the MCP tool response // parseResult extracts keywords from the MCP tool response
func (p *MCPProvider) parseResult(result *gouMCPTypes.CallToolResponse) ([]string, error) { func (p *MCPProvider) parseResult(result *gouMCPTypes.CallToolResponse) ([]types.Keyword, error) {
if result == nil { if result == nil {
return []string{}, nil return []types.Keyword{}, nil
} }
// Check for errors in result // Check for errors in result
@ -72,7 +72,7 @@ func (p *MCPProvider) parseResult(result *gouMCPTypes.CallToolResponse) ([]strin
// Parse content - expect JSON data with "keywords" field // Parse content - expect JSON data with "keywords" field
if len(result.Content) == 0 { if len(result.Content) == 0 {
return []string{}, nil return []types.Keyword{}, nil
} }
// Try to extract keywords from content // Try to extract keywords from content
@ -88,36 +88,50 @@ func (p *MCPProvider) parseResult(result *gouMCPTypes.CallToolResponse) ([]strin
} }
} }
// Try to parse as direct array // Try to parse as direct array of keywords
var keywords []string var keywords []types.Keyword
if err := json.Unmarshal([]byte(content.Text), &keywords); err == nil { if err := json.Unmarshal([]byte(content.Text), &keywords); err == nil {
return keywords, nil return keywords, nil
} }
} }
} }
return []string{}, nil return []types.Keyword{}, nil
} }
// extractKeywordsFromValue extracts string array from various types // extractKeywordsFromValue extracts Keyword array from various types
func (p *MCPProvider) extractKeywordsFromValue(v interface{}) ([]string, error) { func (p *MCPProvider) extractKeywordsFromValue(v interface{}) ([]types.Keyword, error) {
switch kw := v.(type) { switch kw := v.(type) {
case []string: case []types.Keyword:
return kw, nil return kw, nil
case []interface{}: case []interface{}:
keywords := make([]string, 0, len(kw)) keywords := make([]types.Keyword, 0, len(kw))
for _, item := range kw { for _, item := range kw {
if s, ok := item.(string); ok { switch v := item.(type) {
keywords = append(keywords, s) case map[string]interface{}:
// Handle {k: "keyword", w: 0.9} format
k, _ := v["k"].(string)
w, _ := v["w"].(float64)
if k != "" {
if w == 0 {
w = 0.5 // Default weight
}
keywords = append(keywords, types.Keyword{K: k, W: w})
}
case string:
// Plain string, use default weight
if v != "" {
keywords = append(keywords, types.Keyword{K: v, W: 0.5})
}
} }
} }
return keywords, nil return keywords, nil
case string: case string:
var keywords []string var keywords []types.Keyword
if err := json.Unmarshal([]byte(kw), &keywords); err == nil { if err := json.Unmarshal([]byte(kw), &keywords); err == nil {
return keywords, nil return keywords, nil
} }
return []string{kw}, nil return []types.Keyword{{K: kw, W: 0.5}}, nil
} }
return []string{}, nil return []types.Keyword{}, nil
} }

View file

@ -120,6 +120,12 @@ type ProcessedQuery struct {
DSL *gou.QueryDSL `json:"dsl,omitempty"` // For DB search, uses GOU QueryDSL DSL *gou.QueryDSL `json:"dsl,omitempty"` // For DB search, uses GOU QueryDSL
} }
// Keyword represents an extracted keyword with weight
type Keyword struct {
K string `json:"k"` // Keyword text
W float64 `json:"w"` // Weight (0.1-1.0), higher means more relevant
}
// Note: For QueryDSL and Model types, use GOU types directly: // Note: For QueryDSL and Model types, use GOU types directly:
// - github.com/yaoapp/gou/query/gou.QueryDSL // - github.com/yaoapp/gou/query/gou.QueryDSL
// - github.com/yaoapp/gou/model.Model // - github.com/yaoapp/gou/model.Model

View file

@ -254,18 +254,9 @@ func (a *Asserter) assertJSONPath(assertion *Assertion, output interface{}) *Ass
actual := a.extractPath(jsonData, path) actual := a.extractPath(jsonData, path)
result.Actual = actual result.Actual = actual
// Support array of expected values (OR logic - any match passes) // Compare expected value with actual value
if expectedArr, ok := assertion.Value.([]interface{}); ok && len(expectedArr) > 0 { // First, try direct comparison (handles both primitive values and arrays)
for _, expected := range expectedArr { if validateOutput(actual, assertion.Value) {
if validateOutput(actual, expected) {
result.Passed = true
result.Message = fmt.Sprintf("path '%s' equals one of expected values", assertion.Path)
return result
}
}
result.Passed = false
result.Message = fmt.Sprintf("path '%s': expected one of %v, got %v", assertion.Path, assertion.Value, actual)
} else if validateOutput(actual, assertion.Value) {
result.Passed = true result.Passed = true
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path) result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
} else { } else {

File diff suppressed because one or more lines are too long

View file

@ -1,21 +1,25 @@
- role: system - role: system
content: | content: |
Extract keywords from text content. You are a keyword extraction tool, NOT a chatbot. Do NOT answer questions or provide explanations.
Your ONLY job: analyze text and output a JSON array of keywords with weights.
Task: Output format: ["keyword:weight", ...]
1. Analyze input text
2. Extract important keywords
3. Return JSON format
4. Match input language
Response Format (JSON only): Weight:
```json - 1.0: Core topic
{"keywords": ["keyword1", "keyword2", ...]} - 0.8-0.9: Key concepts
``` - 0.6-0.7: Supporting themes
- 0.4-0.5: Peripheral concepts
Guidelines: Examples:
- Extract 5-15 keywords based on content length - Input(EN): "Developers frustrated with callback hell. ES2017 async/await improved readability."
- Prioritize nouns, proper nouns, key concepts - Output: ["async/await:1", "asynchronous programming:0.9", "ES2017:0.8", "code readability:0.7"]
- Include single words and short phrases
- Exclude common stop words - Input(中文): "用户反馈APP启动慢、页面卡顿。需要优化首屏加载和内存占用。"
- Keywords MUST be in the same language as input - Output: ["性能优化:1", "启动速度:0.9", "内存管理:0.8", "用户体验:0.7"]
Rules:
- ONLY output JSON array, nothing else
- Max 5 keywords, sorted by weight
- Summarize related concepts
- Match input language (EN→EN, 中文→中文)

View file

@ -1,13 +1,20 @@
/** /**
* Keyword Extraction Agent - Next Hook * Keyword Extraction Agent - Next Hook
* Parses LLM response and extracts keywords with error tolerance * Parses LLM response and extracts keywords with weight
* Format: ["keyword:weight", ...] -> [{k, w}, ...]
*/ */
// @ts-nocheck // @ts-nocheck
/** Keyword with weight */
interface Keyword {
k: string; // keyword
w: number; // weight (0.1-1.0)
}
/** /**
* Next hook - processes keyword extraction response * Next hook - processes keyword extraction response
* Uses text.ExtractJSON for fault-tolerant JSON extraction from LLM output * Parses format: ["keyword1:0.9", "keyword2:0.8", ...]
*/ */
function Next( function Next(
ctx: agent.Context, ctx: agent.Context,
@ -21,22 +28,17 @@ function Next(
} }
const content = completion.content; const content = completion.content;
let keywords: string[] = []; let keywords: Keyword[] = [];
try { try {
// Use text.ExtractJSON for fault-tolerant extraction // Extract JSON array from response
// Handles markdown code blocks, broken JSON, etc. const parsed = Process("text.ExtractJSON", content) as string[] | null;
const parsed = Process("text.ExtractJSON", content) as {
keywords?: string[];
} | null;
if (parsed && Array.isArray(parsed.keywords)) { if (parsed && Array.isArray(parsed)) {
keywords = parsed.keywords.filter( keywords = parseKeywordArray(parsed);
(k) => typeof k === "string" && k.trim().length > 0
);
} }
} catch (e) { } catch (e) {
// If extraction fails, try to extract keywords from text // If extraction fails, try to extract from text
keywords = extractKeywordsFromText(content); keywords = extractKeywordsFromText(content);
} }
@ -45,6 +47,9 @@ function Next(
keywords = extractKeywordsFromText(content); keywords = extractKeywordsFromText(content);
} }
// Sort by weight descending and limit to 5
keywords = keywords.sort((a, b) => b.w - a.w).slice(0, 5);
// Return parsed keywords // Return parsed keywords
return { return {
data: { data: {
@ -54,49 +59,99 @@ function Next(
} }
/** /**
* Extract keywords from plain text when JSON parsing fails * Parse keyword array format: ["keyword:weight", ...]
* Handles formats like: * Examples: ["AI:0.9", "机器学习:0.8", "deep learning:0.7"]
* - Comma-separated: "keyword1, keyword2, keyword3"
* - Line-separated: "keyword1\nkeyword2\nkeyword3"
* - Bullet points: "- keyword1\n- keyword2"
* - Numbered: "1. keyword1\n2. keyword2"
*/ */
function extractKeywordsFromText(text: string): string[] { function parseKeywordArray(items: (string | any)[]): Keyword[] {
const keywords: string[] = []; const keywords: Keyword[] = [];
// Remove common prefixes/suffixes for (const item of items) {
let cleaned = text if (typeof item === "string") {
.replace(/^[\s\S]*?keywords?[\s:]*\[?/i, "") // Remove "keywords:" prefix const parsed = parseKeywordString(item);
.replace(/\][\s\S]*$/, "") // Remove trailing ] if (parsed) {
.trim(); keywords.push(parsed);
}
} else if (item && typeof item === "object" && item.k) {
// Fallback: handle {k, w} format
const k = String(item.k).trim();
const w =
typeof item.w === "number" ? Math.min(1.0, Math.max(0.1, item.w)) : 0.5;
if (k.length > 0) {
keywords.push({ k, w });
}
}
}
// Try line-by-line extraction return keywords;
const lines = cleaned.split(/[\n\r]+/); }
/**
* Parse single keyword string: "keyword:weight" or "keyword"
*/
function parseKeywordString(str: string): Keyword | null {
const trimmed = str.trim().replace(/^["']+|["']+$/g, ""); // Remove quotes
if (!trimmed) return null;
// Try to split by last colon (keyword may contain colons)
const lastColonIdx = trimmed.lastIndexOf(":");
if (lastColonIdx > 0) {
const keyword = trimmed.substring(0, lastColonIdx).trim();
const weightStr = trimmed.substring(lastColonIdx + 1).trim();
const weight = parseFloat(weightStr);
if (keyword && !isNaN(weight)) {
return {
k: keyword,
w: Math.min(1.0, Math.max(0.1, weight)),
};
}
}
// No weight found, return with default weight
return { k: trimmed, w: 0.5 };
}
/**
* Extract keywords from plain text when JSON parsing fails
*/
function extractKeywordsFromText(text: string): Keyword[] {
const keywords: Keyword[] = [];
// Try to find array-like content
const arrayMatch = text.match(/\[([^\]]+)\]/);
if (arrayMatch) {
const items = arrayMatch[1].split(",");
for (const item of items) {
const parsed = parseKeywordString(item);
if (parsed) {
keywords.push(parsed);
}
}
if (keywords.length > 0) return keywords;
}
// Fallback: line-by-line extraction
const lines = text.split(/[\n\r,]+/);
let defaultWeight = 1.0;
for (const line of lines) { for (const line of lines) {
// Remove bullet points, numbers, quotes let cleaned = line
let keyword = line .replace(/^[\s\-\*\•\d\.\[\]"'`]+/, "") // Remove prefixes
.replace(/^[\s\-\*\•\d\.]+/, "") // Remove bullets/numbers .replace(/[\]"'`]+$/, "") // Remove suffixes
.replace(/^["'`]+|["'`]+$/g, "") // Remove quotes
.replace(/,\s*$/, "") // Remove trailing comma
.trim(); .trim();
// Skip empty or too long if (cleaned.length > 0 && cleaned.length < 100) {
if (keyword.length > 0 && keyword.length < 100) { const parsed = parseKeywordString(cleaned);
// Split by comma if contains multiple if (parsed) {
if (keyword.includes(",")) { // Use parsed weight or assign decreasing default
const parts = keyword.split(",").map((p) => p.trim()); if (parsed.w === 0.5) {
for (const part of parts) { parsed.w = Math.max(0.1, defaultWeight);
if (part.length > 0 && part.length < 100) { defaultWeight -= 0.1;
keywords.push(part);
} }
} keywords.push(parsed);
} else {
keywords.push(keyword);
} }
} }
} }
// Deduplicate return keywords;
return [...new Set(keywords)];
} }

View file

@ -9,10 +9,41 @@
- DO NOT extract keywords, DO NOT answer the question, DO NOT add explanations - DO NOT extract keywords, DO NOT answer the question, DO NOT add explanations
## Classification Rules ## Classification Rules
need_search=false: greetings, chitchat, math, code requests, text processing, general knowledge, philosophy
need_search=true with search_types=["web"]: weather, news, prices, exchange rates, live events, real-time info ### need_search=false (No search needed)
need_search=true with search_types=["kb"]: docs, how-to, config, FAQ, product info, policies Use when the question can be answered from LLM's internal knowledge:
need_search=true with search_types=["db"]: user data (my orders, my balance), account info, business records - Greetings & chitchat: "hello", "how are you", casual conversation
- Math & calculations: arithmetic, equations, formulas
- Code generation: write code, debug, explain code, algorithms
- Text processing: translate, summarize, rewrite, format
- General knowledge: history, science, concepts (not time-sensitive)
- Creative tasks: write stories, poems, brainstorm ideas
- Reasoning & logic: philosophy, opinions, hypothetical questions
### need_search=true with search_types=["web"] (Web search)
Use when real-time or frequently changing information is needed:
- Current events: news, breaking stories, recent happenings
- Time-sensitive data: weather, stock prices, exchange rates, sports scores
- Live information: event schedules, store hours, availability
- Recent updates: latest versions, new releases, current status
- Location-based: nearby places, local info, addresses
### need_search=true with search_types=["kb"] (Knowledge base)
Use when querying internal documentation or product knowledge:
- Documentation: how-to guides, tutorials, setup instructions
- Configuration: settings, parameters, options explained
- Product info: features, specifications, capabilities
- Policies: terms, rules, guidelines, compliance
- FAQ: common questions about the system/product
- Troubleshooting: error messages, known issues, solutions
### need_search=true with search_types=["db"] (Database)
Use when querying user-specific or transactional data:
- Personal data: "my orders", "my profile", "my history"
- Account info: balance, subscription, membership status
- Business records: invoices, transactions, payments
- User preferences: settings, saved items, favorites
- Keywords: "my", "mine", specific order/ID numbers
## Required Output Format (JSON only, no markdown) ## Required Output Format (JSON only, no markdown)
{"need_search": true/false, "search_types": [], "confidence": 0.0-1.0} {"need_search": true/false, "search_types": [], "confidence": 0.0-1.0}