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)
// ================================================
if ast.shouldAutoSearch(ctx, completionMessages, createResponse, opts) {
refCtx := ast.executeAutoSearch(ctx, completionMessages, createResponse, opts)
if intent := ast.shouldAutoSearch(ctx, completionMessages, createResponse, opts); intent != nil {
refCtx := ast.executeAutoSearch(ctx, completionMessages, createResponse, intent, opts)
if refCtx != nil && len(refCtx.References) > 0 {
completionMessages = ast.injectSearchContext(completionMessages, refCtx)
}

View file

@ -17,16 +17,32 @@ import (
)
// 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
// - 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) 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
if opts != nil && opts.Skip != nil && 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
@ -35,27 +51,101 @@ func (ast *Assistant) shouldAutoSearch(ctx *context.Context, messages []context.
// Check if search is explicitly disabled
if uses != nil && uses.Search == "disabled" {
ctx.Logger.Info("Auto search disabled by uses.search=disabled")
return false
return nil
}
// Check if assistant has search configuration
if ast.Search == nil && (uses == nil || uses.Search == "") {
return false
return nil
}
// 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")
return false
return nil
}
// Check if search is enabled (builtin, agent, mcp, or empty means builtin)
return true
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", "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 true if search is needed, false otherwise
func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context.Message) bool {
// 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,
}
// Filter out system messages and pass full conversation context
var intentMessages []context.Message
for _, msg := range messages {
@ -65,14 +155,14 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
}
if len(intentMessages) == 0 {
return true // No messages, proceed with search
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, proceeding with search", err)
return true // Agent not available, proceed with search
ctx.Logger.Debug("__yao.needsearch agent not available: %v, skipping search", err)
return defaultIntent // Agent not available, skip search
}
// === Output: Send loading message ===
@ -90,10 +180,10 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
result, err := needsearchAst.Stream(ctx, intentMessages, opts)
if err != nil {
ctx.Logger.Debug("__yao.needsearch failed: %v, proceeding with search", err)
// === Output: Send done (error case, proceed with search) ===
ast.sendIntentDone(ctx, loadingID, true, "")
return true // On error, proceed with search
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
@ -109,11 +199,12 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
intentData = nextData
}
if needSearch, ok := intentData["need_search"].(bool); ok {
reason, _ := intentData["reason"].(string)
ctx.Logger.Debug("Search intent (from Next): need_search=%v, reason=%s", needSearch, reason)
ast.sendIntentDone(ctx, loadingID, needSearch, reason)
return needSearch
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
}
}
}
@ -122,24 +213,75 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
if result.Completion != nil {
content, ok := result.Completion.Content.(string)
if !ok || content == "" {
ast.sendIntentDone(ctx, loadingID, true, "")
return true
ast.sendIntentDone(ctx, loadingID, false, "")
return defaultIntent
}
needSearch, reason := parseNeedSearchFromContent(content)
ctx.Logger.Debug("Search intent (from Content): need_search=%v, reason=%s", needSearch, reason)
ast.sendIntentDone(ctx, loadingID, needSearch, reason)
return needSearch
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: 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) ===
ast.sendIntentDone(ctx, loadingID, true, "")
return true
ast.sendIntentDone(ctx, loadingID, false, "")
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
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
content = strings.TrimSpace(content)
if strings.HasPrefix(content, "```json") {
@ -155,17 +297,16 @@ func parseNeedSearchFromContent(content string) (bool, string) {
// Try to parse JSON
var result map[string]interface{}
if err := json.Unmarshal([]byte(content), &result); err != nil {
// Failed to parse, default to search
return true, ""
// Failed to parse, default to no search
return defaultIntent
}
needSearch, ok := result["need_search"].(bool)
if !ok {
return true, ""
intent := parseSearchIntent(result)
if intent == nil {
return defaultIntent
}
reason, _ := result["reason"].(string)
return needSearch, reason
return intent
}
// sendIntentLoading sends the initial intent detection loading message
@ -261,10 +402,11 @@ func (ast *Assistant) getMergedSearchUses(createResponse *context.HookCreateResp
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
// 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, 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")
defer ctx.Logger.PhaseComplete("Search")
@ -301,33 +443,23 @@ func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context
skipKeyword = opts[0].Skip.Keyword
}
// Extract keywords for web search if:
// 1. uses.keyword is configured (not empty)
// 2. Skip.Keyword is not true
// 3. Web search is enabled
var extractedKeywords []string
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
}
// Build search requests based on configuration and intent
// Keyword extraction is done inside buildSearchRequests for web search
buildOpts := &buildSearchRequestsOptions{
skipKeyword: skipKeyword,
usesKeyword: searchUses.Keyword,
}
// Build search requests based on configuration
requests := ast.buildSearchRequests(query, searchConfig)
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)
@ -425,6 +557,52 @@ func (ast *Assistant) sendSearchLoading(ctx *context.Context) string {
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 == "" {
@ -554,22 +732,67 @@ func (ast *Assistant) completeSearchTrace(node traceTypes.Node, resultCount int,
})
}
// buildSearchRequests builds search requests based on assistant configuration
func (ast *Assistant) buildSearchRequests(query string, config *searchTypes.Config) []*searchTypes.Request {
var requests []*searchTypes.Request
// 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)
}
}
// Web search - check if web search is configured
if config != nil && config.Web != nil {
requests = append(requests, &searchTypes.Request{
Type: searchTypes.SearchTypeWeb,
Query: query,
Query: webQuery,
Source: searchTypes.SourceAuto,
Limit: config.Web.MaxResults,
})
}
// KB search - check if KB is configured
if ast.KB != nil && len(ast.KB.Collections) > 0 {
// 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 {
@ -579,7 +802,7 @@ func (ast *Assistant) buildSearchRequests(query string, config *searchTypes.Conf
}
requests = append(requests, &searchTypes.Request{
Type: searchTypes.SearchTypeKB,
Query: query,
Query: query, // KB uses original query for semantic search
Source: searchTypes.SourceAuto,
Limit: limit,
Collections: ast.KB.Collections,
@ -588,22 +811,22 @@ func (ast *Assistant) buildSearchRequests(query string, config *searchTypes.Conf
})
}
// DB search - check if DB is configured
if ast.DB != nil && len(ast.DB.Models) > 0 {
// 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
}
requests = append(requests, &searchTypes.Request{
Type: searchTypes.SearchTypeDB,
Query: query,
Query: query, // DB uses original query for QueryDSL generation
Source: searchTypes.SourceAuto,
Limit: limit,
Models: ast.DB.Models,
})
}
return requests
return requests, extractedKeywords
}
// 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
type SearchExecutionResult struct {
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
RefCtx *searchTypes.ReferenceContext // Reference context with results
Duration int64 // Search duration in ms
@ -707,6 +930,54 @@ type SearchExecutionResult struct {
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) {
@ -722,7 +993,7 @@ func (ast *Assistant) saveSearch(ctx *context.Context, execResult *SearchExecuti
RequestID: ctx.RequestID(),
ChatID: ctx.ChatID,
Query: execResult.Query,
Keywords: execResult.Keywords,
Keywords: keywordsToStrings(execResult.Keywords),
Config: execResult.Config,
Source: execResult.SearchType,
Duration: execResult.Duration,

View file

@ -85,6 +85,10 @@ type NextProcessContext struct {
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
// According to MCP protocol:
// - Content is []ToolContent array

View file

@ -16,7 +16,7 @@ func (opts *Options) ToMap() map[string]interface{} {
result["mode"] = opts.Mode
}
if opts.Search != nil {
result["search"] = *opts.Search
result["search"] = opts.Search
}
if opts.Skip != nil {
result["skip"] = opts.Skip
@ -50,8 +50,9 @@ func OptionsFromMap(m map[string]interface{}) *Options {
if mode, ok := m["mode"].(string); ok {
opts.Mode = mode
}
if search, ok := m["search"].(bool); ok {
opts.Search = &search
// Search supports: bool | SearchIntent | map[string]any | nil
if search := m["search"]; search != nil {
opts.Search = search
}
if skipMap, ok := m["skip"].(map[string]interface{}); ok {
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
}
// 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
type Options struct {
@ -284,8 +293,11 @@ type Options struct {
// Disable global prompts, default is false
DisableGlobalPrompts bool `json:"disable_global_prompts,omitempty"` // Temporarily disable global prompts for this request
// Search mode, default is true
Search *bool `json:"search,omitempty"` // Search mode, default is true
// 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
// 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 *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

View file

@ -112,6 +112,10 @@ func init() {
"search.intent.need_search": "Searching for references...",
"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.trace.label": "Search",
"search.trace.description": "Search the web and knowledge base for relevant information",
@ -201,6 +205,10 @@ func init() {
"search.intent.need_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.trace.label": "搜索",
"search.trace.description": "搜索网络和知识库获取相关信息",
@ -318,6 +326,10 @@ func init() {
"search.intent.need_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.trace.label": "搜索",
"search.trace.description": "搜索网络和知识库获取相关信息",

View file

@ -209,10 +209,14 @@ func initAssistant() error {
// Set global Uses configuration
if agentDSL.Uses != nil {
globalUses := &context.Uses{
Vision: agentDSL.Uses.Vision,
Audio: agentDSL.Uses.Audio,
Search: agentDSL.Uses.Search,
Fetch: agentDSL.Uses.Fetch,
Vision: agentDSL.Uses.Vision,
Audio: agentDSL.Uses.Audio,
Search: agentDSL.Uses.Search,
Fetch: agentDSL.Uses.Fetch,
Web: agentDSL.Uses.Web,
Keyword: agentDSL.Uses.Keyword,
QueryDSL: agentDSL.Uses.QueryDSL,
Rerank: agentDSL.Uses.Rerank,
}
assistant.SetGlobalUses(globalUses)
}

View file

@ -23,8 +23,8 @@ func NewAgentProvider(agentID string) *AgentProvider {
}
// Extract extracts keywords by calling the target agent
// The agent receives the content and returns extracted keywords
func (p *AgentProvider) Extract(ctx *agentContext.Context, content string, opts *types.KeywordOptions) ([]string, error) {
// The agent receives the content and returns extracted keywords with weights
func (p *AgentProvider) Extract(ctx *agentContext.Context, content string, opts *types.KeywordOptions) ([]types.Keyword, error) {
if ctx == nil {
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,
// we can access fields without type assertions.
//
// The agent returns keywords in response.Next field
func (p *AgentProvider) parseResponse(response *agentContext.Response) ([]string, error) {
// The agent returns keywords in response.Next field as {data: {keywords: [{k, w}, ...]}}
func (p *AgentProvider) parseResponse(response *agentContext.Response) ([]types.Keyword, error) {
if response == nil || response.Next == nil {
return []string{}, nil
return []types.Keyword{}, nil
}
return p.parseNextData(response.Next)
}
// 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 {
return []string{}, nil
return []types.Keyword{}, nil
}
// Try to convert to map first (most common case)
@ -101,32 +102,26 @@ func (p *AgentProvider) parseNextData(next interface{}) ([]string, error) {
case string:
// Try to parse as JSON
if err := json.Unmarshal([]byte(v), &data); err != nil {
// Not a JSON object, try as array
var keywords []string
// Not a JSON object, try as array of keywords
var keywords []types.Keyword
if err := json.Unmarshal([]byte(v), &keywords); err == nil {
return keywords, nil
}
// Return as single keyword
return []string{v}, nil
// Return as single keyword with default weight
return []types.Keyword{{K: v, W: 0.5}}, nil
}
case []string:
case []types.Keyword:
return v, nil
case []interface{}:
keywords := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
keywords = append(keywords, s)
}
}
return keywords, nil
return p.extractKeywordsFromArray(v)
default:
// Try to marshal and unmarshal
jsonBytes, err := json.Marshal(next)
if err != nil {
return []string{}, nil
return []types.Keyword{}, 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 []string{}, nil
return []types.Keyword{}, nil
}
// extractKeywordsFromValue extracts string array from various types
func (p *AgentProvider) extractKeywordsFromValue(v interface{}) ([]string, error) {
// extractKeywordsFromValue extracts Keyword array from various types
func (p *AgentProvider) extractKeywordsFromValue(v interface{}) ([]types.Keyword, error) {
switch kw := v.(type) {
case []string:
case []types.Keyword:
return kw, nil
case []interface{}:
keywords := make([]string, 0, len(kw))
for _, item := range kw {
if s, ok := item.(string); ok {
keywords = append(keywords, s)
}
}
return keywords, nil
return p.extractKeywordsFromArray(kw)
case string:
var keywords []string
var keywords []types.Keyword
if err := json.Unmarshal([]byte(kw), &keywords); err == 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
// Returns a list of keywords optimized for search queries
func (e *Extractor) Extract(ctx *context.Context, content string, opts *types.KeywordOptions) ([]string, error) {
// Returns a list of keywords with weights optimized for search queries
func (e *Extractor) Extract(ctx *context.Context, content string, opts *types.KeywordOptions) ([]types.Keyword, error) {
// Merge options with config defaults
mergedOpts := e.mergeOptions(opts)
@ -83,7 +83,7 @@ func (e *Extractor) mergeOptions(opts *types.KeywordOptions) *types.KeywordOptio
// agentExtract delegates to an LLM-powered assistant
// 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 {
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
// 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:")
provider, err := NewMCPProvider(mcpRef)
if err != nil {

View file

@ -31,7 +31,7 @@ func NewMCPProvider(mcpRef string) (*MCPProvider, error) {
}
// 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
client, err := mcp.Select(p.serverID)
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
func (p *MCPProvider) parseResult(result *gouMCPTypes.CallToolResponse) ([]string, error) {
func (p *MCPProvider) parseResult(result *gouMCPTypes.CallToolResponse) ([]types.Keyword, error) {
if result == nil {
return []string{}, nil
return []types.Keyword{}, nil
}
// 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
if len(result.Content) == 0 {
return []string{}, nil
return []types.Keyword{}, nil
}
// Try to extract keywords from content
@ -88,36 +88,50 @@ func (p *MCPProvider) parseResult(result *gouMCPTypes.CallToolResponse) ([]strin
}
}
// Try to parse as direct array
var keywords []string
// Try to parse as direct array of keywords
var keywords []types.Keyword
if err := json.Unmarshal([]byte(content.Text), &keywords); err == nil {
return keywords, nil
}
}
}
return []string{}, nil
return []types.Keyword{}, nil
}
// extractKeywordsFromValue extracts string array from various types
func (p *MCPProvider) extractKeywordsFromValue(v interface{}) ([]string, error) {
// extractKeywordsFromValue extracts Keyword array from various types
func (p *MCPProvider) extractKeywordsFromValue(v interface{}) ([]types.Keyword, error) {
switch kw := v.(type) {
case []string:
case []types.Keyword:
return kw, nil
case []interface{}:
keywords := make([]string, 0, len(kw))
keywords := make([]types.Keyword, 0, len(kw))
for _, item := range kw {
if s, ok := item.(string); ok {
keywords = append(keywords, s)
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
case string:
var keywords []string
var keywords []types.Keyword
if err := json.Unmarshal([]byte(kw), &keywords); err == 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
}
// 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:
// - github.com/yaoapp/gou/query/gou.QueryDSL
// - 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)
result.Actual = actual
// Support array of expected values (OR logic - any match passes)
if expectedArr, ok := assertion.Value.([]interface{}); ok && len(expectedArr) > 0 {
for _, expected := range expectedArr {
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) {
// Compare expected value with actual value
// First, try direct comparison (handles both primitive values and arrays)
if validateOutput(actual, assertion.Value) {
result.Passed = true
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
} else {

File diff suppressed because one or more lines are too long

View file

@ -1,21 +1,25 @@
- role: system
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:
1. Analyze input text
2. Extract important keywords
3. Return JSON format
4. Match input language
Output format: ["keyword:weight", ...]
Response Format (JSON only):
```json
{"keywords": ["keyword1", "keyword2", ...]}
```
Weight:
- 1.0: Core topic
- 0.8-0.9: Key concepts
- 0.6-0.7: Supporting themes
- 0.4-0.5: Peripheral concepts
Guidelines:
- Extract 5-15 keywords based on content length
- Prioritize nouns, proper nouns, key concepts
- Include single words and short phrases
- Exclude common stop words
- Keywords MUST be in the same language as input
Examples:
- Input(EN): "Developers frustrated with callback hell. ES2017 async/await improved readability."
- Output: ["async/await:1", "asynchronous programming:0.9", "ES2017:0.8", "code readability:0.7"]
- Input(中文): "用户反馈APP启动慢、页面卡顿。需要优化首屏加载和内存占用。"
- 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
* Parses LLM response and extracts keywords with error tolerance
* Parses LLM response and extracts keywords with weight
* Format: ["keyword:weight", ...] -> [{k, w}, ...]
*/
// @ts-nocheck
/** Keyword with weight */
interface Keyword {
k: string; // keyword
w: number; // weight (0.1-1.0)
}
/**
* 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(
ctx: agent.Context,
@ -21,22 +28,17 @@ function Next(
}
const content = completion.content;
let keywords: string[] = [];
let keywords: Keyword[] = [];
try {
// Use text.ExtractJSON for fault-tolerant extraction
// Handles markdown code blocks, broken JSON, etc.
const parsed = Process("text.ExtractJSON", content) as {
keywords?: string[];
} | null;
// Extract JSON array from response
const parsed = Process("text.ExtractJSON", content) as string[] | null;
if (parsed && Array.isArray(parsed.keywords)) {
keywords = parsed.keywords.filter(
(k) => typeof k === "string" && k.trim().length > 0
);
if (parsed && Array.isArray(parsed)) {
keywords = parseKeywordArray(parsed);
}
} catch (e) {
// If extraction fails, try to extract keywords from text
// If extraction fails, try to extract from text
keywords = extractKeywordsFromText(content);
}
@ -45,6 +47,9 @@ function Next(
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 {
data: {
@ -54,49 +59,99 @@ function Next(
}
/**
* Extract keywords from plain text when JSON parsing fails
* Handles formats like:
* - Comma-separated: "keyword1, keyword2, keyword3"
* - Line-separated: "keyword1\nkeyword2\nkeyword3"
* - Bullet points: "- keyword1\n- keyword2"
* - Numbered: "1. keyword1\n2. keyword2"
* Parse keyword array format: ["keyword:weight", ...]
* Examples: ["AI:0.9", "机器学习:0.8", "deep learning:0.7"]
*/
function extractKeywordsFromText(text: string): string[] {
const keywords: string[] = [];
function parseKeywordArray(items: (string | any)[]): Keyword[] {
const keywords: Keyword[] = [];
// Remove common prefixes/suffixes
let cleaned = text
.replace(/^[\s\S]*?keywords?[\s:]*\[?/i, "") // Remove "keywords:" prefix
.replace(/\][\s\S]*$/, "") // Remove trailing ]
.trim();
// Try line-by-line extraction
const lines = cleaned.split(/[\n\r]+/);
for (const line of lines) {
// Remove bullet points, numbers, quotes
let keyword = line
.replace(/^[\s\-\*\•\d\.]+/, "") // Remove bullets/numbers
.replace(/^["'`]+|["'`]+$/g, "") // Remove quotes
.replace(/,\s*$/, "") // Remove trailing comma
.trim();
// Skip empty or too long
if (keyword.length > 0 && keyword.length < 100) {
// Split by comma if contains multiple
if (keyword.includes(",")) {
const parts = keyword.split(",").map((p) => p.trim());
for (const part of parts) {
if (part.length > 0 && part.length < 100) {
keywords.push(part);
}
}
} else {
keywords.push(keyword);
for (const item of items) {
if (typeof item === "string") {
const parsed = parseKeywordString(item);
if (parsed) {
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 });
}
}
}
// Deduplicate
return [...new Set(keywords)];
return keywords;
}
/**
* 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) {
let cleaned = line
.replace(/^[\s\-\*\•\d\.\[\]"'`]+/, "") // Remove prefixes
.replace(/[\]"'`]+$/, "") // Remove suffixes
.trim();
if (cleaned.length > 0 && cleaned.length < 100) {
const parsed = parseKeywordString(cleaned);
if (parsed) {
// Use parsed weight or assign decreasing default
if (parsed.w === 0.5) {
parsed.w = Math.max(0.1, defaultWeight);
defaultWeight -= 0.1;
}
keywords.push(parsed);
}
}
}
return keywords;
}

View file

@ -9,10 +9,41 @@
- DO NOT extract keywords, DO NOT answer the question, DO NOT add explanations
## 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=true with search_types=["kb"]: docs, how-to, config, FAQ, product info, policies
need_search=true with search_types=["db"]: user data (my orders, my balance), account info, business records
### need_search=false (No search needed)
Use when the question can be answered from LLM's internal knowledge:
- 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)
{"need_search": true/false, "search_types": [], "confidence": 0.0-1.0}