Enhance Auto Search Functionality with Keyword Extraction

- Updated the executeAutoSearch method to include an optional parameter for Skip.Keyword, allowing for conditional keyword extraction during web searches.
- Implemented logic to extract keywords only when configured and not skipped, improving search query optimization.
- Modified the Assistant's Stream method to pass options to executeAutoSearch, ensuring seamless integration of the new functionality.
- Updated DESIGN.md to document the changes in keyword extraction logic and its impact on the search process.
This commit is contained in:
Max 2025-12-13 16:57:00 +08:00
parent e38f6ecc96
commit 4dbce2f92e
5 changed files with 250 additions and 4 deletions

View file

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

View file

@ -5,6 +5,7 @@ import (
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/search"
"github.com/yaoapp/yao/agent/search/nlp/keyword"
searchTypes "github.com/yaoapp/yao/agent/search/types"
)
@ -73,7 +74,8 @@ func (ast *Assistant) getMergedSearchUses(createResponse *context.HookCreateResp
// executeAutoSearch executes auto search based on configuration
// Returns ReferenceContext with results and formatted context
func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse) *searchTypes.ReferenceContext {
// 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 {
ctx.Logger.Phase("Search")
defer ctx.Logger.PhaseComplete("Search")
@ -103,6 +105,30 @@ func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context
return nil
}
// Check if keyword extraction should be skipped
skipKeyword := false
if len(opts) > 0 && opts[0] != nil && opts[0].Skip != nil {
skipKeyword = opts[0].Skip.Keyword
}
// Extract keywords for web search if:
// 1. uses.keyword is configured (not empty)
// 2. Skip.Keyword is not true
// 3. Web search is enabled
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 {
// 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
requests := ast.buildSearchRequests(query, searchConfig)
if len(requests) == 0 {

View file

@ -0,0 +1,186 @@
package assistant_test
import (
stdContext "context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newKeywordTestContext creates a test context for keyword extraction tests
func newKeywordTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
UserID: "test-123",
TenantID: "test-tenant",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.ID = chatID
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Client = context.Client{
Type: "web",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.IDGenerator = message.NewIDGenerator()
ctx.Metadata = make(map[string]interface{})
return ctx
}
func TestSearchAutoKeyword(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.LoadPath("/assistants/tests/search-auto-keyword")
require.NoError(t, err)
require.NotNil(t, ast)
t.Run("ShouldHaveKeywordConfig", func(t *testing.T) {
assert.NotNil(t, ast.Search, "search config should be set")
assert.NotNil(t, ast.Search.Keyword, "keyword config should be set")
assert.Equal(t, 5, ast.Search.Keyword.MaxKeywords)
assert.Equal(t, "auto", ast.Search.Keyword.Language)
})
t.Run("ShouldHaveKeywordInUses", func(t *testing.T) {
assert.NotNil(t, ast.Uses, "uses config should be set")
assert.Equal(t, "builtin", ast.Uses.Keyword)
})
t.Run("StreamWithKeywordExtraction", func(t *testing.T) {
// Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-keyword")
require.NoError(t, err)
require.NotNil(t, agent)
// Create context
ctx := newKeywordTestContext("test-search-keyword", "tests.search-auto-keyword")
// Create messages with a verbose query that should benefit from keyword extraction
messages := []context.Message{
{
Role: "user",
Content: "I want to find the best wireless headphones under 100 dollars for programming and music",
},
}
// Execute stream without Skip.Keyword (keyword extraction should happen)
response, err := agent.Stream(ctx, messages)
// Assert no error (if API key is configured)
if err != nil {
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
t.Logf("Expected error without API key: %v", err)
return
}
require.NoError(t, err)
}
require.NotNil(t, response)
resp := response.(*context.Response)
assert.NotNil(t, resp.Completion, "should have completion")
t.Logf("✓ Stream with keyword extraction executed successfully")
})
t.Run("StreamWithSkipKeyword", func(t *testing.T) {
// Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-keyword")
require.NoError(t, err)
require.NotNil(t, agent)
// Create context
ctx := newKeywordTestContext("test-search-skip-keyword", "tests.search-auto-keyword")
// Create messages
messages := []context.Message{
{
Role: "user",
Content: "I want to find the best wireless headphones under 100 dollars",
},
}
// Execute stream with Skip.Keyword = true (keyword extraction should be skipped)
opts := &context.Options{
Skip: &context.Skip{
Keyword: true,
},
}
response, err := agent.Stream(ctx, messages, opts)
// Assert no error (if API key is configured)
if err != nil {
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
t.Logf("Expected error without API key: %v", err)
return
}
require.NoError(t, err)
}
require.NotNil(t, response)
resp := response.(*context.Response)
assert.NotNil(t, resp.Completion, "should have completion")
t.Logf("✓ Stream with Skip.Keyword executed successfully")
})
}
func TestSearchAutoKeywordNotConfigured(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// Use the search-auto-web assistant which does NOT have uses.keyword configured
ast, err := assistant.LoadPath("/assistants/tests/search-auto-web")
require.NoError(t, err)
require.NotNil(t, ast)
t.Run("ShouldNotHaveKeywordInUses", func(t *testing.T) {
// uses.keyword should be empty (not configured)
if ast.Uses != nil {
assert.Empty(t, ast.Uses.Keyword, "uses.keyword should be empty")
}
})
t.Run("StreamShouldSkipKeywordExtraction", func(t *testing.T) {
// Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-web")
require.NoError(t, err)
require.NotNil(t, agent)
// Create context
ctx := newKeywordTestContext("test-no-keyword", "tests.search-auto-web")
// Create messages
messages := []context.Message{
{
Role: "user",
Content: "What is the latest news about AI?",
},
}
// Execute stream - keyword extraction should NOT happen because uses.keyword is not set
response, err := agent.Stream(ctx, messages)
// Assert no error (if API key is configured)
if err != nil {
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
t.Logf("Expected error without API key: %v", err)
return
}
require.NoError(t, err)
}
require.NotNil(t, response)
resp := response.(*context.Response)
assert.NotNil(t, resp.Completion, "should have completion")
t.Logf("✓ Stream without keyword config executed successfully")
})
}

View file

@ -195,6 +195,7 @@ type Skip struct {
History bool `json:"history"` // Skip saving chat history (for internal calls like title/prompt generation)
Trace bool `json:"trace"` // Skip trace logging
Output bool `json:"output"` // Skip output to client (for internal A2A calls that only need response data)
Keyword bool `json:"keyword"` // Skip keyword extraction for web search (use raw query directly)
}
// MessageMetadata stores metadata for sent messages

View file

@ -1559,6 +1559,7 @@ Stream(ctx, messages, options)
│ ├── IF Create Hook returned uses.search="disabled" → SKIP
│ └── ELSE → Execute Auto Search (executeAutoSearch)
│ ├── Read assistant's search config (GetMergedSearchConfig)
│ ├── Extract keywords (if uses.keyword && !Skip.Keyword)
│ ├── Build search requests (buildSearchRequests)
│ ├── Execute web/kb/db in parallel (searcher.All)
│ ├── Build reference context (BuildReferenceContext)
@ -1586,7 +1587,8 @@ Stream(ctx, messages, options)
func (ast *Assistant) shouldAutoSearch(ctx *context.Context, createResponse *context.HookCreateResponse) bool
// executeAutoSearch executes auto search based on configuration
func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse) *searchTypes.ReferenceContext
// opts is optional, used to check Skip.Keyword for keyword extraction
func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, opts ...*context.Options) *searchTypes.ReferenceContext
// injectSearchContext injects search results into messages
func (ast *Assistant) injectSearchContext(messages []context.Message, refCtx *searchTypes.ReferenceContext) []context.Message
@ -1598,18 +1600,49 @@ func (ast *Assistant) getMergedSearchUses(createResponse *context.HookCreateResp
func (ast *Assistant) buildSearchRequests(query string, config *searchTypes.Config) []*searchTypes.Request
```
**Keyword Extraction in executeAutoSearch:**
When `uses.keyword` is configured and `opts.Skip.Keyword` is not true, keyword extraction is performed before web search:
```go
// Extract keywords for web search if:
// 1. uses.keyword is configured (not empty)
// 2. Skip.Keyword is not true
// 3. Web search is enabled
if webSearchEnabled && !skipKeyword && searchUses.Keyword != "" {
extractor := keyword.NewExtractor(searchUses.Keyword, searchConfig.Keyword)
keywords, err := extractor.Extract(ctx, query, nil)
if err == nil && len(keywords) > 0 {
query = strings.Join(keywords, " ")
}
}
```
**Integration in agent.go:**
```go
// In Stream(), after BuildContent:
if ast.shouldAutoSearch(ctx, createResponse) {
refCtx := ast.executeAutoSearch(ctx, completionMessages, createResponse)
refCtx := ast.executeAutoSearch(ctx, completionMessages, createResponse, opts)
if refCtx != nil && len(refCtx.References) > 0 {
completionMessages = ast.injectSearchContext(completionMessages, refCtx)
}
}
```
**Skip.Keyword Option (`context.Options.Skip`):**
```go
type Skip struct {
History bool `json:"history"` // Skip saving chat history
Trace bool `json:"trace"` // Skip trace logging
Output bool `json:"output"` // Skip output to client
Keyword bool `json:"keyword"` // Skip keyword extraction for web search
}
```
Use `Skip.Keyword = true` when you want to use the raw query directly without keyword extraction.
### Control via Uses.Search
Search is controlled via the `Uses` mechanism, following the merge hierarchy: