Implement search configuration management and testing enhancements
- Introduced a new search configuration structure, allowing for detailed settings for web, knowledge base, database, citation, and weights. - Updated the `Assistant` model to include a `Search` field, enabling assistant-specific search configurations. - Enhanced the loading and merging logic for search configurations, ensuring global defaults can be overridden by assistant-specific settings. - Added comprehensive tests for loading, saving, and updating assistants with search configurations, verifying the integrity of search settings. - Updated documentation in DESIGN.md to reflect the new search configuration hierarchy and usage, clarifying the interaction between global and assistant-level settings.
This commit is contained in:
parent
f7af652aab
commit
d264c7a784
35 changed files with 2599 additions and 271 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -41,6 +41,7 @@ xgen/v1.0/*
|
|||
*-unit-test
|
||||
docker/build/test
|
||||
db
|
||||
!agent/search/handlers/db
|
||||
*.sh
|
||||
data/bindata.go.bak
|
||||
share/const.go.bak
|
||||
|
|
@ -49,3 +50,4 @@ share/const.goe
|
|||
openapi/*.md
|
||||
coverage.html
|
||||
agent/assistant/hook/*.test.md
|
||||
agent/search/TODO.md
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/yaoapp/yao/agent/content"
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
sui "github.com/yaoapp/yao/sui/core"
|
||||
)
|
||||
|
|
@ -115,6 +116,8 @@ func (ast *Assistant) Map() map[string]interface{} {
|
|||
"automated": ast.Automated,
|
||||
"placeholder": ast.Placeholder,
|
||||
"locales": ast.Locales,
|
||||
"uses": ast.Uses,
|
||||
"search": ast.Search,
|
||||
"created_at": store.ToMySQLTime(ast.CreatedAt),
|
||||
"updated_at": store.ToMySQLTime(ast.UpdatedAt),
|
||||
}
|
||||
|
|
@ -183,7 +186,6 @@ func (ast *Assistant) Clone() *Assistant {
|
|||
CreatedAt: ast.CreatedAt,
|
||||
UpdatedAt: ast.UpdatedAt,
|
||||
},
|
||||
Search: ast.Search,
|
||||
HookScript: ast.HookScript,
|
||||
openai: ast.openai,
|
||||
}
|
||||
|
|
@ -346,6 +348,86 @@ func (ast *Assistant) Clone() *Assistant {
|
|||
}
|
||||
}
|
||||
|
||||
// Deep copy uses
|
||||
if ast.Uses != nil {
|
||||
clone.Uses = &agentContext.Uses{
|
||||
Vision: ast.Uses.Vision,
|
||||
Audio: ast.Uses.Audio,
|
||||
Search: ast.Uses.Search,
|
||||
Fetch: ast.Uses.Fetch,
|
||||
Web: ast.Uses.Web,
|
||||
Keyword: ast.Uses.Keyword,
|
||||
QueryDSL: ast.Uses.QueryDSL,
|
||||
Rerank: ast.Uses.Rerank,
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy search config
|
||||
if ast.Search != nil {
|
||||
clone.Search = &searchTypes.Config{}
|
||||
if ast.Search.Web != nil {
|
||||
clone.Search.Web = &searchTypes.WebConfig{
|
||||
Provider: ast.Search.Web.Provider,
|
||||
APIKeyEnv: ast.Search.Web.APIKeyEnv,
|
||||
MaxResults: ast.Search.Web.MaxResults,
|
||||
}
|
||||
}
|
||||
if ast.Search.KB != nil {
|
||||
clone.Search.KB = &searchTypes.KBConfig{
|
||||
Threshold: ast.Search.KB.Threshold,
|
||||
Graph: ast.Search.KB.Graph,
|
||||
}
|
||||
if ast.Search.KB.Collections != nil {
|
||||
clone.Search.KB.Collections = make([]string, len(ast.Search.KB.Collections))
|
||||
copy(clone.Search.KB.Collections, ast.Search.KB.Collections)
|
||||
}
|
||||
}
|
||||
if ast.Search.DB != nil {
|
||||
clone.Search.DB = &searchTypes.DBConfig{
|
||||
MaxResults: ast.Search.DB.MaxResults,
|
||||
}
|
||||
if ast.Search.DB.Models != nil {
|
||||
clone.Search.DB.Models = make([]string, len(ast.Search.DB.Models))
|
||||
copy(clone.Search.DB.Models, ast.Search.DB.Models)
|
||||
}
|
||||
}
|
||||
if ast.Search.Keyword != nil {
|
||||
clone.Search.Keyword = &searchTypes.KeywordConfig{
|
||||
MaxKeywords: ast.Search.Keyword.MaxKeywords,
|
||||
Language: ast.Search.Keyword.Language,
|
||||
}
|
||||
}
|
||||
if ast.Search.QueryDSL != nil {
|
||||
clone.Search.QueryDSL = &searchTypes.QueryDSLConfig{
|
||||
Strict: ast.Search.QueryDSL.Strict,
|
||||
}
|
||||
}
|
||||
if ast.Search.Rerank != nil {
|
||||
clone.Search.Rerank = &searchTypes.RerankConfig{
|
||||
TopN: ast.Search.Rerank.TopN,
|
||||
}
|
||||
}
|
||||
if ast.Search.Citation != nil {
|
||||
clone.Search.Citation = &searchTypes.CitationConfig{
|
||||
Format: ast.Search.Citation.Format,
|
||||
AutoInjectPrompt: ast.Search.Citation.AutoInjectPrompt,
|
||||
CustomPrompt: ast.Search.Citation.CustomPrompt,
|
||||
}
|
||||
}
|
||||
if ast.Search.Weights != nil {
|
||||
clone.Search.Weights = &searchTypes.WeightsConfig{
|
||||
User: ast.Search.Weights.User,
|
||||
Hook: ast.Search.Weights.Hook,
|
||||
Auto: ast.Search.Weights.Auto,
|
||||
}
|
||||
}
|
||||
if ast.Search.Options != nil {
|
||||
clone.Search.Options = &searchTypes.OptionsConfig{
|
||||
SkipThreshold: ast.Search.Options.SkipThreshold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
|
|
@ -512,5 +594,29 @@ func (ast *Assistant) Update(data map[string]interface{}) error {
|
|||
ast.Workflow = workflow
|
||||
}
|
||||
|
||||
// Uses
|
||||
if v, has := data["uses"]; has {
|
||||
uses, err := store.ToUses(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.Uses = uses
|
||||
}
|
||||
|
||||
// Search
|
||||
if v, has := data["search"]; has {
|
||||
search, err := store.ToSearchConfig(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.Search = search
|
||||
}
|
||||
|
||||
return ast.Validate()
|
||||
}
|
||||
|
||||
// GetMergedSearchConfig returns the search config for this assistant
|
||||
// Note: The config is already merged with global config during loading (loadMap)
|
||||
func (ast *Assistant) GetMergedSearchConfig() *searchTypes.Config {
|
||||
return ast.Search
|
||||
}
|
||||
|
|
|
|||
|
|
@ -532,40 +532,10 @@ func (ast *Assistant) applyCreateResponseOptions(options *context.CompletionOpti
|
|||
|
||||
// getUses get the Uses configuration with priority: assistant.Uses > global settings
|
||||
// Note: createResponse.Uses (applied in applyCreateResponseOptions) has even higher priority
|
||||
// Final priority order: createResponse.Uses > assistant.Uses > global settings
|
||||
// getUses returns the Uses config for this assistant
|
||||
// Note: The config is already merged with global config during loading (loadMap)
|
||||
func (ast *Assistant) getUses() *context.Uses {
|
||||
// Priority 1: Assistant-specific Uses configuration
|
||||
if ast.Uses != nil {
|
||||
// Create a merged Uses by starting with global, then override with assistant-specific
|
||||
merged := &context.Uses{}
|
||||
|
||||
// Start with global settings
|
||||
if globalUses != nil {
|
||||
merged.Vision = globalUses.Vision
|
||||
merged.Audio = globalUses.Audio
|
||||
merged.Search = globalUses.Search
|
||||
merged.Fetch = globalUses.Fetch
|
||||
}
|
||||
|
||||
// Override with assistant-specific settings (only if not empty)
|
||||
if ast.Uses.Vision != "" {
|
||||
merged.Vision = ast.Uses.Vision
|
||||
}
|
||||
if ast.Uses.Audio != "" {
|
||||
merged.Audio = ast.Uses.Audio
|
||||
}
|
||||
if ast.Uses.Search != "" {
|
||||
merged.Search = ast.Uses.Search
|
||||
}
|
||||
if ast.Uses.Fetch != "" {
|
||||
merged.Fetch = ast.Uses.Fetch
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// Priority 2: Global settings only
|
||||
return globalUses
|
||||
return ast.Uses
|
||||
}
|
||||
|
||||
// applyMCPTools adds MCP tools to completion options and returns samples prompt
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/yaoapp/gou/fs"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/openai"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
|
@ -22,12 +23,12 @@ import (
|
|||
var loaded = NewCache(200) // 200 is the default capacity
|
||||
var storage store.Store = nil
|
||||
var storeSetting *store.Setting = nil // store setting from agent.yml
|
||||
var search interface{} = nil
|
||||
var modelCapabilities map[string]gouOpenAI.Capabilities = map[string]gouOpenAI.Capabilities{}
|
||||
var defaultConnector string = "" // default connector
|
||||
var globalUses *context.Uses = nil // global uses configuration from agent.yml
|
||||
var globalPrompts []store.Prompt = nil // global prompts from agent/prompts.yml
|
||||
var globalKBSetting *store.KBSetting = nil // global KB setting from agent/kb.yml
|
||||
var defaultConnector string = "" // default connector
|
||||
var globalUses *context.Uses = nil // global uses configuration from agent.yml
|
||||
var globalPrompts []store.Prompt = nil // global prompts from agent/prompts.yml
|
||||
var globalKBSetting *store.KBSetting = nil // global KB setting from agent/kb.yml
|
||||
var globalSearchConfig *searchTypes.Config = nil // global search config from agent/search.yml
|
||||
|
||||
// LoadBuiltIn load the built-in assistants
|
||||
func LoadBuiltIn() error {
|
||||
|
|
@ -183,6 +184,16 @@ func GetGlobalKBSetting() *store.KBSetting {
|
|||
return globalKBSetting
|
||||
}
|
||||
|
||||
// SetGlobalSearchConfig set the global search config from agent/search.yml
|
||||
func SetGlobalSearchConfig(config *searchTypes.Config) {
|
||||
globalSearchConfig = config
|
||||
}
|
||||
|
||||
// GetGlobalSearchConfig returns the global search config
|
||||
func GetGlobalSearchConfig() *searchTypes.Config {
|
||||
return globalSearchConfig
|
||||
}
|
||||
|
||||
// SetCache set the cache
|
||||
func SetCache(capacity int) {
|
||||
ClearCache()
|
||||
|
|
@ -585,19 +596,24 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Search options
|
||||
// Search configuration (from package.yao search block)
|
||||
// This contains search options like web.max_results, kb.threshold, citation.format, etc.
|
||||
// Merge hierarchy: global config < assistant config
|
||||
if v, ok := data["search"].(map[string]interface{}); ok {
|
||||
assistant.Search = &SearchOption{}
|
||||
var assistantSearch searchTypes.Config
|
||||
raw, err := jsoniter.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unmarshal the raw data
|
||||
err = jsoniter.Unmarshal(raw, assistant.Search)
|
||||
err = jsoniter.Unmarshal(raw, &assistantSearch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Merge with global search config
|
||||
assistant.Search = mergeSearchConfig(globalSearchConfig, &assistantSearch)
|
||||
} else if globalSearchConfig != nil {
|
||||
// No assistant-specific config, use global
|
||||
assistant.Search = globalSearchConfig
|
||||
}
|
||||
|
||||
// prompts
|
||||
|
|
@ -681,12 +697,14 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
|||
}
|
||||
|
||||
// uses (wrapper configurations for vision, audio, etc.)
|
||||
// Merge hierarchy: global uses < assistant uses
|
||||
if uses, has := data["uses"]; has {
|
||||
var assistantUses *context.Uses
|
||||
switch v := uses.(type) {
|
||||
case *context.Uses:
|
||||
assistant.Uses = v
|
||||
assistantUses = v
|
||||
case context.Uses:
|
||||
assistant.Uses = &v
|
||||
assistantUses = &v
|
||||
default:
|
||||
raw, err := jsoniter.Marshal(v)
|
||||
if err != nil {
|
||||
|
|
@ -697,8 +715,13 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assistant.Uses = &usesConfig
|
||||
assistantUses = &usesConfig
|
||||
}
|
||||
// Merge with global uses
|
||||
assistant.Uses = mergeUses(globalUses, assistantUses)
|
||||
} else if globalUses != nil {
|
||||
// No assistant-specific uses, use global
|
||||
assistant.Uses = globalUses
|
||||
}
|
||||
|
||||
// Load scripts (hook script and other scripts)
|
||||
|
|
@ -761,3 +784,204 @@ func (ast *Assistant) initialize() error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeUses merges two Uses configs (base < override)
|
||||
func mergeUses(base, override *context.Uses) *context.Uses {
|
||||
if base == nil {
|
||||
return override
|
||||
}
|
||||
if override == nil {
|
||||
return base
|
||||
}
|
||||
|
||||
result := *base // Copy base
|
||||
|
||||
// Override with non-empty values
|
||||
if override.Vision != "" {
|
||||
result.Vision = override.Vision
|
||||
}
|
||||
if override.Audio != "" {
|
||||
result.Audio = override.Audio
|
||||
}
|
||||
if override.Search != "" {
|
||||
result.Search = override.Search
|
||||
}
|
||||
if override.Fetch != "" {
|
||||
result.Fetch = override.Fetch
|
||||
}
|
||||
if override.Web != "" {
|
||||
result.Web = override.Web
|
||||
}
|
||||
if override.Keyword != "" {
|
||||
result.Keyword = override.Keyword
|
||||
}
|
||||
if override.QueryDSL != "" {
|
||||
result.QueryDSL = override.QueryDSL
|
||||
}
|
||||
if override.Rerank != "" {
|
||||
result.Rerank = override.Rerank
|
||||
}
|
||||
|
||||
return &result
|
||||
}
|
||||
|
||||
// mergeSearchConfig merges two search configs (base < override)
|
||||
func mergeSearchConfig(base, override *searchTypes.Config) *searchTypes.Config {
|
||||
if base == nil {
|
||||
return override
|
||||
}
|
||||
if override == nil {
|
||||
return base
|
||||
}
|
||||
|
||||
result := *base // Copy base
|
||||
|
||||
// Merge Web config
|
||||
if override.Web != nil {
|
||||
if result.Web == nil {
|
||||
result.Web = override.Web
|
||||
} else {
|
||||
merged := *result.Web
|
||||
if override.Web.Provider != "" {
|
||||
merged.Provider = override.Web.Provider
|
||||
}
|
||||
if override.Web.APIKeyEnv != "" {
|
||||
merged.APIKeyEnv = override.Web.APIKeyEnv
|
||||
}
|
||||
if override.Web.MaxResults > 0 {
|
||||
merged.MaxResults = override.Web.MaxResults
|
||||
}
|
||||
result.Web = &merged
|
||||
}
|
||||
}
|
||||
|
||||
// Merge KB config
|
||||
if override.KB != nil {
|
||||
if result.KB == nil {
|
||||
result.KB = override.KB
|
||||
} else {
|
||||
merged := *result.KB
|
||||
if len(override.KB.Collections) > 0 {
|
||||
merged.Collections = override.KB.Collections
|
||||
}
|
||||
if override.KB.Threshold > 0 {
|
||||
merged.Threshold = override.KB.Threshold
|
||||
}
|
||||
if override.KB.Graph {
|
||||
merged.Graph = override.KB.Graph
|
||||
}
|
||||
result.KB = &merged
|
||||
}
|
||||
}
|
||||
|
||||
// Merge DB config
|
||||
if override.DB != nil {
|
||||
if result.DB == nil {
|
||||
result.DB = override.DB
|
||||
} else {
|
||||
merged := *result.DB
|
||||
if len(override.DB.Models) > 0 {
|
||||
merged.Models = override.DB.Models
|
||||
}
|
||||
if override.DB.MaxResults > 0 {
|
||||
merged.MaxResults = override.DB.MaxResults
|
||||
}
|
||||
result.DB = &merged
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Keyword config
|
||||
if override.Keyword != nil {
|
||||
if result.Keyword == nil {
|
||||
result.Keyword = override.Keyword
|
||||
} else {
|
||||
merged := *result.Keyword
|
||||
if override.Keyword.MaxKeywords > 0 {
|
||||
merged.MaxKeywords = override.Keyword.MaxKeywords
|
||||
}
|
||||
if override.Keyword.Language != "" {
|
||||
merged.Language = override.Keyword.Language
|
||||
}
|
||||
result.Keyword = &merged
|
||||
}
|
||||
}
|
||||
|
||||
// Merge QueryDSL config
|
||||
if override.QueryDSL != nil {
|
||||
if result.QueryDSL == nil {
|
||||
result.QueryDSL = override.QueryDSL
|
||||
} else {
|
||||
merged := *result.QueryDSL
|
||||
if override.QueryDSL.Strict {
|
||||
merged.Strict = override.QueryDSL.Strict
|
||||
}
|
||||
result.QueryDSL = &merged
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Rerank config
|
||||
if override.Rerank != nil {
|
||||
if result.Rerank == nil {
|
||||
result.Rerank = override.Rerank
|
||||
} else {
|
||||
merged := *result.Rerank
|
||||
if override.Rerank.TopN > 0 {
|
||||
merged.TopN = override.Rerank.TopN
|
||||
}
|
||||
result.Rerank = &merged
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Citation config
|
||||
if override.Citation != nil {
|
||||
if result.Citation == nil {
|
||||
result.Citation = override.Citation
|
||||
} else {
|
||||
merged := *result.Citation
|
||||
if override.Citation.Format != "" {
|
||||
merged.Format = override.Citation.Format
|
||||
}
|
||||
// AutoInjectPrompt is a bool, so we check if it's explicitly set
|
||||
// by checking if the whole Citation block was provided
|
||||
merged.AutoInjectPrompt = override.Citation.AutoInjectPrompt
|
||||
if override.Citation.CustomPrompt != "" {
|
||||
merged.CustomPrompt = override.Citation.CustomPrompt
|
||||
}
|
||||
result.Citation = &merged
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Weights config
|
||||
if override.Weights != nil {
|
||||
if result.Weights == nil {
|
||||
result.Weights = override.Weights
|
||||
} else {
|
||||
merged := *result.Weights
|
||||
if override.Weights.User > 0 {
|
||||
merged.User = override.Weights.User
|
||||
}
|
||||
if override.Weights.Hook > 0 {
|
||||
merged.Hook = override.Weights.Hook
|
||||
}
|
||||
if override.Weights.Auto > 0 {
|
||||
merged.Auto = override.Weights.Auto
|
||||
}
|
||||
result.Weights = &merged
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Options config
|
||||
if override.Options != nil {
|
||||
if result.Options == nil {
|
||||
result.Options = override.Options
|
||||
} else {
|
||||
merged := *result.Options
|
||||
if override.Options.SkipThreshold > 0 {
|
||||
merged.SkipThreshold = override.Options.SkipThreshold
|
||||
}
|
||||
result.Options = &merged
|
||||
}
|
||||
}
|
||||
|
||||
return &result
|
||||
}
|
||||
|
|
|
|||
366
agent/assistant/load_merge_test.go
Normal file
366
agent/assistant/load_merge_test.go
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// TestLoadPathMerge tests loading the merge test assistant
|
||||
// This verifies that global config is properly merged with assistant-specific config
|
||||
func TestLoadPathMerge(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/merge")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
assert.Equal(t, "tests.merge", ast.ID)
|
||||
assert.Equal(t, "Merge Config Test Assistant", ast.Name)
|
||||
|
||||
// Uses configuration - should merge global with assistant-specific
|
||||
// Global (from agent/agent.yml):
|
||||
// vision: "workers.system.vision"
|
||||
// search: "workers.system.search"
|
||||
// fetch: "workers.system.fetch"
|
||||
// audio: (not set)
|
||||
// querydsl: (not set)
|
||||
// rerank: (not set)
|
||||
// Assistant:
|
||||
// web: "mcp:custom-web"
|
||||
// keyword: "mcp:custom-keyword"
|
||||
// Result: assistant values override, global values inherited
|
||||
assert.NotNil(t, ast.Uses)
|
||||
|
||||
// Assistant overrides
|
||||
assert.Equal(t, "mcp:custom-web", ast.Uses.Web) // overridden by assistant
|
||||
assert.Equal(t, "mcp:custom-keyword", ast.Uses.Keyword) // overridden by assistant
|
||||
|
||||
// Inherited from global (agent/agent.yml)
|
||||
assert.Equal(t, "workers.system.vision", ast.Uses.Vision) // inherited from global
|
||||
assert.Equal(t, "workers.system.search", ast.Uses.Search) // inherited from global
|
||||
assert.Equal(t, "workers.system.fetch", ast.Uses.Fetch) // inherited from global
|
||||
|
||||
// Not set in either global or assistant (should be empty)
|
||||
assert.Empty(t, ast.Uses.Audio) // not set anywhere
|
||||
assert.Empty(t, ast.Uses.QueryDSL) // not set anywhere
|
||||
assert.Empty(t, ast.Uses.Rerank) // not set anywhere
|
||||
|
||||
// Search configuration - should merge global with assistant-specific
|
||||
// Global (from agent/search.yml):
|
||||
// web.provider=tavily, web.max_results=10
|
||||
// kb.threshold=0.7, kb.graph=false
|
||||
// db.max_results=20
|
||||
// keyword.max_keywords=10, keyword.language=auto
|
||||
// rerank.top_n=10
|
||||
// citation.format=#ref:{id}, citation.auto_inject_prompt=true
|
||||
// weights: user=1.0, hook=0.8, auto=0.6
|
||||
// options.skip_threshold=5
|
||||
// Assistant:
|
||||
// web.provider=custom-provider, web.max_results=25
|
||||
// kb.collections=[merge-test-kb], kb.threshold=0.85
|
||||
assert.NotNil(t, ast.Search)
|
||||
|
||||
// Web config - assistant overrides global
|
||||
assert.NotNil(t, ast.Search.Web)
|
||||
assert.Equal(t, "custom-provider", ast.Search.Web.Provider) // overridden
|
||||
assert.Equal(t, 25, ast.Search.Web.MaxResults) // overridden
|
||||
|
||||
// KB config - assistant overrides global
|
||||
assert.NotNil(t, ast.Search.KB)
|
||||
assert.Equal(t, []string{"merge-test-kb"}, ast.Search.KB.Collections) // overridden
|
||||
assert.Equal(t, 0.85, ast.Search.KB.Threshold) // overridden
|
||||
assert.False(t, ast.Search.KB.Graph) // inherited from global
|
||||
|
||||
// DB config - should inherit from global (assistant doesn't define it)
|
||||
assert.NotNil(t, ast.Search.DB)
|
||||
assert.Equal(t, 20, ast.Search.DB.MaxResults) // inherited from global
|
||||
|
||||
// Keyword config - should inherit from global
|
||||
assert.NotNil(t, ast.Search.Keyword)
|
||||
assert.Equal(t, 10, ast.Search.Keyword.MaxKeywords) // inherited from global
|
||||
assert.Equal(t, "auto", ast.Search.Keyword.Language) // inherited from global
|
||||
|
||||
// Rerank config - should inherit from global
|
||||
assert.NotNil(t, ast.Search.Rerank)
|
||||
assert.Equal(t, 10, ast.Search.Rerank.TopN) // inherited from global
|
||||
|
||||
// Citation config - should inherit from global
|
||||
assert.NotNil(t, ast.Search.Citation)
|
||||
assert.Equal(t, "#ref:{id}", ast.Search.Citation.Format) // inherited from global
|
||||
assert.True(t, ast.Search.Citation.AutoInjectPrompt) // inherited from global
|
||||
|
||||
// Weights config - should inherit from global
|
||||
assert.NotNil(t, ast.Search.Weights)
|
||||
assert.Equal(t, 1.0, ast.Search.Weights.User) // inherited from global
|
||||
assert.Equal(t, 0.8, ast.Search.Weights.Hook) // inherited from global
|
||||
assert.Equal(t, 0.6, ast.Search.Weights.Auto) // inherited from global
|
||||
|
||||
// Options config - should inherit from global
|
||||
assert.NotNil(t, ast.Search.Options)
|
||||
assert.Equal(t, 5, ast.Search.Options.SkipThreshold) // inherited from global
|
||||
}
|
||||
|
||||
// TestLoadPathMergeOverride tests loading the merge-override test assistant
|
||||
// This verifies that assistant config completely overrides global config
|
||||
func TestLoadPathMergeOverride(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/merge-override")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
assert.Equal(t, "tests.merge-override", ast.ID)
|
||||
assert.Equal(t, "Merge Override Test Assistant", ast.Name)
|
||||
|
||||
// Uses configuration - all fields should be overridden by assistant
|
||||
assert.NotNil(t, ast.Uses)
|
||||
assert.Equal(t, "mcp:custom-vision", ast.Uses.Vision)
|
||||
assert.Equal(t, "mcp:custom-audio", ast.Uses.Audio)
|
||||
assert.Equal(t, "mcp:custom-search", ast.Uses.Search)
|
||||
assert.Equal(t, "mcp:custom-fetch", ast.Uses.Fetch)
|
||||
assert.Equal(t, "mcp:custom-web", ast.Uses.Web)
|
||||
assert.Equal(t, "mcp:custom-keyword", ast.Uses.Keyword)
|
||||
assert.Equal(t, "mcp:custom-querydsl", ast.Uses.QueryDSL)
|
||||
assert.Equal(t, "mcp:custom-rerank", ast.Uses.Rerank)
|
||||
|
||||
// Search configuration - all fields should be overridden by assistant
|
||||
assert.NotNil(t, ast.Search)
|
||||
|
||||
// Web config - all overridden
|
||||
assert.NotNil(t, ast.Search.Web)
|
||||
assert.Equal(t, "override-provider", ast.Search.Web.Provider)
|
||||
assert.Equal(t, "$ENV.OVERRIDE_API_KEY", ast.Search.Web.APIKeyEnv)
|
||||
assert.Equal(t, 100, ast.Search.Web.MaxResults)
|
||||
|
||||
// KB config - all overridden
|
||||
assert.NotNil(t, ast.Search.KB)
|
||||
assert.Equal(t, []string{"override-kb"}, ast.Search.KB.Collections)
|
||||
assert.Equal(t, 0.99, ast.Search.KB.Threshold)
|
||||
assert.True(t, ast.Search.KB.Graph)
|
||||
|
||||
// DB config - all overridden
|
||||
assert.NotNil(t, ast.Search.DB)
|
||||
assert.Equal(t, []string{"override-model"}, ast.Search.DB.Models)
|
||||
assert.Equal(t, 200, ast.Search.DB.MaxResults)
|
||||
|
||||
// Keyword config - all overridden
|
||||
assert.NotNil(t, ast.Search.Keyword)
|
||||
assert.Equal(t, 20, ast.Search.Keyword.MaxKeywords)
|
||||
assert.Equal(t, "zh", ast.Search.Keyword.Language)
|
||||
|
||||
// QueryDSL config - overridden
|
||||
assert.NotNil(t, ast.Search.QueryDSL)
|
||||
assert.True(t, ast.Search.QueryDSL.Strict)
|
||||
|
||||
// Rerank config - overridden
|
||||
assert.NotNil(t, ast.Search.Rerank)
|
||||
assert.Equal(t, 20, ast.Search.Rerank.TopN)
|
||||
|
||||
// Citation config - all overridden
|
||||
assert.NotNil(t, ast.Search.Citation)
|
||||
assert.Equal(t, "[override:{id}]", ast.Search.Citation.Format)
|
||||
assert.False(t, ast.Search.Citation.AutoInjectPrompt)
|
||||
assert.Equal(t, "Override citation prompt", ast.Search.Citation.CustomPrompt)
|
||||
|
||||
// Weights config - all overridden
|
||||
assert.NotNil(t, ast.Search.Weights)
|
||||
assert.Equal(t, 2.0, ast.Search.Weights.User)
|
||||
assert.Equal(t, 1.5, ast.Search.Weights.Hook)
|
||||
assert.Equal(t, 1.0, ast.Search.Weights.Auto)
|
||||
|
||||
// Options config - overridden
|
||||
assert.NotNil(t, ast.Search.Options)
|
||||
assert.Equal(t, 10, ast.Search.Options.SkipThreshold)
|
||||
}
|
||||
|
||||
// TestLoadPathMergeEmpty tests loading the merge-empty test assistant
|
||||
// This verifies that assistant with no uses/search config inherits all from global
|
||||
func TestLoadPathMergeEmpty(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/merge-empty")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
assert.Equal(t, "tests.merge-empty", ast.ID)
|
||||
assert.Equal(t, "Merge Empty Test Assistant", ast.Name)
|
||||
|
||||
// Uses configuration - all inherited from global (agent/agent.yml)
|
||||
assert.NotNil(t, ast.Uses)
|
||||
assert.Equal(t, "workers.system.vision", ast.Uses.Vision) // from global
|
||||
assert.Equal(t, "workers.system.search", ast.Uses.Search) // from global
|
||||
assert.Equal(t, "workers.system.fetch", ast.Uses.Fetch) // from global
|
||||
assert.Empty(t, ast.Uses.Audio) // not set in global
|
||||
assert.Empty(t, ast.Uses.Web) // not set in global
|
||||
assert.Empty(t, ast.Uses.Keyword) // not set in global
|
||||
assert.Empty(t, ast.Uses.QueryDSL) // not set in global
|
||||
assert.Empty(t, ast.Uses.Rerank) // not set in global
|
||||
|
||||
// Search configuration - all inherited from global (agent/search.yml)
|
||||
assert.NotNil(t, ast.Search)
|
||||
|
||||
// Web config - from global
|
||||
assert.NotNil(t, ast.Search.Web)
|
||||
assert.Equal(t, "tavily", ast.Search.Web.Provider)
|
||||
assert.Equal(t, 10, ast.Search.Web.MaxResults)
|
||||
|
||||
// KB config - from global
|
||||
assert.NotNil(t, ast.Search.KB)
|
||||
assert.Equal(t, 0.7, ast.Search.KB.Threshold)
|
||||
assert.False(t, ast.Search.KB.Graph)
|
||||
|
||||
// DB config - from global
|
||||
assert.NotNil(t, ast.Search.DB)
|
||||
assert.Equal(t, 20, ast.Search.DB.MaxResults)
|
||||
|
||||
// Keyword config - from global
|
||||
assert.NotNil(t, ast.Search.Keyword)
|
||||
assert.Equal(t, 10, ast.Search.Keyword.MaxKeywords)
|
||||
assert.Equal(t, "auto", ast.Search.Keyword.Language)
|
||||
|
||||
// Rerank config - from global
|
||||
assert.NotNil(t, ast.Search.Rerank)
|
||||
assert.Equal(t, 10, ast.Search.Rerank.TopN)
|
||||
|
||||
// Citation config - from global
|
||||
assert.NotNil(t, ast.Search.Citation)
|
||||
assert.Equal(t, "#ref:{id}", ast.Search.Citation.Format)
|
||||
assert.True(t, ast.Search.Citation.AutoInjectPrompt)
|
||||
|
||||
// Weights config - from global
|
||||
assert.NotNil(t, ast.Search.Weights)
|
||||
assert.Equal(t, 1.0, ast.Search.Weights.User)
|
||||
assert.Equal(t, 0.8, ast.Search.Weights.Hook)
|
||||
assert.Equal(t, 0.6, ast.Search.Weights.Auto)
|
||||
|
||||
// Options config - from global
|
||||
assert.NotNil(t, ast.Search.Options)
|
||||
assert.Equal(t, 5, ast.Search.Options.SkipThreshold)
|
||||
}
|
||||
|
||||
// TestLoadPathUsesAndSearchMerge tests loading fullfields assistant
|
||||
// This verifies that uses and search configs are properly loaded and merged
|
||||
func TestLoadPathUsesAndSearchMerge(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Uses configuration - assistant-specific values
|
||||
assert.NotNil(t, ast.Uses)
|
||||
assert.Equal(t, "agent", ast.Uses.Vision)
|
||||
assert.Equal(t, "mcp:audio-server", ast.Uses.Audio)
|
||||
assert.Equal(t, "agent", ast.Uses.Fetch)
|
||||
assert.Equal(t, "builtin", ast.Uses.Web)
|
||||
assert.Equal(t, "builtin", ast.Uses.Keyword)
|
||||
assert.Equal(t, "builtin", ast.Uses.QueryDSL)
|
||||
assert.Equal(t, "builtin", ast.Uses.Rerank)
|
||||
|
||||
// Search configuration - assistant-specific values
|
||||
assert.NotNil(t, ast.Search)
|
||||
|
||||
// Web config - from assistant
|
||||
assert.NotNil(t, ast.Search.Web)
|
||||
assert.Equal(t, "tavily", ast.Search.Web.Provider)
|
||||
assert.Equal(t, 15, ast.Search.Web.MaxResults)
|
||||
|
||||
// KB config - from assistant
|
||||
assert.NotNil(t, ast.Search.KB)
|
||||
assert.Equal(t, []string{"docs", "faq"}, ast.Search.KB.Collections)
|
||||
assert.Equal(t, 0.8, ast.Search.KB.Threshold)
|
||||
assert.True(t, ast.Search.KB.Graph)
|
||||
|
||||
// DB config - from assistant
|
||||
assert.NotNil(t, ast.Search.DB)
|
||||
assert.Equal(t, []string{"user", "product"}, ast.Search.DB.Models)
|
||||
assert.Equal(t, 50, ast.Search.DB.MaxResults)
|
||||
|
||||
// Citation config - from assistant
|
||||
assert.NotNil(t, ast.Search.Citation)
|
||||
assert.Equal(t, "#ref:{id}", ast.Search.Citation.Format)
|
||||
assert.True(t, ast.Search.Citation.AutoInjectPrompt)
|
||||
|
||||
// Weights config - from assistant
|
||||
assert.NotNil(t, ast.Search.Weights)
|
||||
assert.Equal(t, 1.0, ast.Search.Weights.User)
|
||||
assert.Equal(t, 0.9, ast.Search.Weights.Hook)
|
||||
assert.Equal(t, 0.7, ast.Search.Weights.Auto)
|
||||
}
|
||||
|
||||
// TestLoadPathSearchAssistant tests loading the dedicated search test assistant
|
||||
func TestLoadPathSearchAssistant(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/search")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
assert.Equal(t, "tests.search", ast.ID)
|
||||
assert.Equal(t, "Search Config Test Assistant", ast.Name)
|
||||
|
||||
// Uses configuration
|
||||
assert.NotNil(t, ast.Uses)
|
||||
assert.Equal(t, "builtin", ast.Uses.Web)
|
||||
assert.Equal(t, "builtin", ast.Uses.Keyword)
|
||||
assert.Equal(t, "builtin", ast.Uses.QueryDSL)
|
||||
assert.Equal(t, "builtin", ast.Uses.Rerank)
|
||||
|
||||
// Search configuration
|
||||
assert.NotNil(t, ast.Search)
|
||||
|
||||
// Web config
|
||||
assert.NotNil(t, ast.Search.Web)
|
||||
assert.Equal(t, "serper", ast.Search.Web.Provider)
|
||||
assert.Equal(t, "$ENV.SERPER_API_KEY", ast.Search.Web.APIKeyEnv)
|
||||
assert.Equal(t, 20, ast.Search.Web.MaxResults)
|
||||
|
||||
// KB config
|
||||
assert.NotNil(t, ast.Search.KB)
|
||||
assert.Equal(t, []string{"knowledge-base", "documents"}, ast.Search.KB.Collections)
|
||||
assert.Equal(t, 0.75, ast.Search.KB.Threshold)
|
||||
assert.False(t, ast.Search.KB.Graph)
|
||||
|
||||
// DB config
|
||||
assert.NotNil(t, ast.Search.DB)
|
||||
assert.Equal(t, []string{"article", "comment"}, ast.Search.DB.Models)
|
||||
assert.Equal(t, 30, ast.Search.DB.MaxResults)
|
||||
|
||||
// Keyword config
|
||||
assert.NotNil(t, ast.Search.Keyword)
|
||||
assert.Equal(t, 8, ast.Search.Keyword.MaxKeywords)
|
||||
assert.Equal(t, "auto", ast.Search.Keyword.Language)
|
||||
|
||||
// QueryDSL config
|
||||
assert.NotNil(t, ast.Search.QueryDSL)
|
||||
assert.True(t, ast.Search.QueryDSL.Strict)
|
||||
|
||||
// Rerank config
|
||||
assert.NotNil(t, ast.Search.Rerank)
|
||||
assert.Equal(t, 5, ast.Search.Rerank.TopN)
|
||||
|
||||
// Citation config
|
||||
assert.NotNil(t, ast.Search.Citation)
|
||||
assert.Equal(t, "#cite:{id}", ast.Search.Citation.Format)
|
||||
assert.False(t, ast.Search.Citation.AutoInjectPrompt)
|
||||
assert.Equal(t, "Please cite sources using #cite:{id} format.", ast.Search.Citation.CustomPrompt)
|
||||
|
||||
// Weights config
|
||||
assert.NotNil(t, ast.Search.Weights)
|
||||
assert.Equal(t, 1.0, ast.Search.Weights.User)
|
||||
assert.Equal(t, 0.85, ast.Search.Weights.Hook)
|
||||
assert.Equal(t, 0.65, ast.Search.Weights.Auto)
|
||||
|
||||
// Options config
|
||||
assert.NotNil(t, ast.Search.Options)
|
||||
assert.Equal(t, 3, ast.Search.Options.SkipThreshold)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
|
|
@ -933,3 +934,246 @@ function Create(ctx: any, messages: any[]): any {
|
|||
assert.False(t, *res.DisableGlobalPrompts)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadStoreWithSearchConfig tests loading assistant with search configuration from database
|
||||
func TestLoadStoreWithSearchConfig(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
assistantID := "test.store-with-search"
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
ast := &assistant.Assistant{
|
||||
AssistantModel: store.AssistantModel{
|
||||
ID: assistantID,
|
||||
Name: "Test With Search Config",
|
||||
Type: "assistant",
|
||||
Connector: "gpt-4o",
|
||||
Uses: &context.Uses{
|
||||
Vision: "agent",
|
||||
Audio: "mcp:audio-server",
|
||||
Fetch: "agent",
|
||||
Web: "builtin",
|
||||
Keyword: "builtin",
|
||||
QueryDSL: "builtin",
|
||||
Rerank: "builtin",
|
||||
},
|
||||
Search: &searchTypes.Config{
|
||||
Web: &searchTypes.WebConfig{
|
||||
Provider: "tavily",
|
||||
MaxResults: 15,
|
||||
},
|
||||
KB: &searchTypes.KBConfig{
|
||||
Collections: []string{"docs", "faq"},
|
||||
Threshold: 0.8,
|
||||
Graph: true,
|
||||
},
|
||||
DB: &searchTypes.DBConfig{
|
||||
Models: []string{"user", "product"},
|
||||
MaxResults: 50,
|
||||
},
|
||||
Keyword: &searchTypes.KeywordConfig{
|
||||
MaxKeywords: 8,
|
||||
Language: "auto",
|
||||
},
|
||||
QueryDSL: &searchTypes.QueryDSLConfig{
|
||||
Strict: true,
|
||||
},
|
||||
Rerank: &searchTypes.RerankConfig{
|
||||
TopN: 5,
|
||||
},
|
||||
Citation: &searchTypes.CitationConfig{
|
||||
Format: "#cite:{id}",
|
||||
AutoInjectPrompt: false,
|
||||
CustomPrompt: "Please cite sources.",
|
||||
},
|
||||
Weights: &searchTypes.WeightsConfig{
|
||||
User: 1.0,
|
||||
Hook: 0.85,
|
||||
Auto: 0.65,
|
||||
},
|
||||
Options: &searchTypes.OptionsConfig{
|
||||
SkipThreshold: 3,
|
||||
},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
err := ast.Save()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
storage := assistant.GetStorage()
|
||||
if storage != nil {
|
||||
storage.DeleteAssistant(assistantID)
|
||||
}
|
||||
assistant.GetCache().Clear()
|
||||
}()
|
||||
|
||||
assistant.GetCache().Clear()
|
||||
|
||||
loaded, err := assistant.Get(assistantID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded)
|
||||
|
||||
// Verify Uses
|
||||
require.NotNil(t, loaded.Uses)
|
||||
assert.Equal(t, "agent", loaded.Uses.Vision)
|
||||
assert.Equal(t, "mcp:audio-server", loaded.Uses.Audio)
|
||||
assert.Equal(t, "agent", loaded.Uses.Fetch)
|
||||
assert.Equal(t, "builtin", loaded.Uses.Web)
|
||||
assert.Equal(t, "builtin", loaded.Uses.Keyword)
|
||||
assert.Equal(t, "builtin", loaded.Uses.QueryDSL)
|
||||
assert.Equal(t, "builtin", loaded.Uses.Rerank)
|
||||
|
||||
// Verify Search config
|
||||
require.NotNil(t, loaded.Search)
|
||||
|
||||
// Web config
|
||||
require.NotNil(t, loaded.Search.Web)
|
||||
assert.Equal(t, "tavily", loaded.Search.Web.Provider)
|
||||
assert.Equal(t, 15, loaded.Search.Web.MaxResults)
|
||||
|
||||
// KB config
|
||||
require.NotNil(t, loaded.Search.KB)
|
||||
assert.Equal(t, []string{"docs", "faq"}, loaded.Search.KB.Collections)
|
||||
assert.Equal(t, 0.8, loaded.Search.KB.Threshold)
|
||||
assert.True(t, loaded.Search.KB.Graph)
|
||||
|
||||
// DB config
|
||||
require.NotNil(t, loaded.Search.DB)
|
||||
assert.Equal(t, []string{"user", "product"}, loaded.Search.DB.Models)
|
||||
assert.Equal(t, 50, loaded.Search.DB.MaxResults)
|
||||
|
||||
// Keyword config
|
||||
require.NotNil(t, loaded.Search.Keyword)
|
||||
assert.Equal(t, 8, loaded.Search.Keyword.MaxKeywords)
|
||||
assert.Equal(t, "auto", loaded.Search.Keyword.Language)
|
||||
|
||||
// QueryDSL config
|
||||
require.NotNil(t, loaded.Search.QueryDSL)
|
||||
assert.True(t, loaded.Search.QueryDSL.Strict)
|
||||
|
||||
// Rerank config
|
||||
require.NotNil(t, loaded.Search.Rerank)
|
||||
assert.Equal(t, 5, loaded.Search.Rerank.TopN)
|
||||
|
||||
// Citation config
|
||||
require.NotNil(t, loaded.Search.Citation)
|
||||
assert.Equal(t, "#cite:{id}", loaded.Search.Citation.Format)
|
||||
assert.False(t, loaded.Search.Citation.AutoInjectPrompt)
|
||||
assert.Equal(t, "Please cite sources.", loaded.Search.Citation.CustomPrompt)
|
||||
|
||||
// Weights config
|
||||
require.NotNil(t, loaded.Search.Weights)
|
||||
assert.Equal(t, 1.0, loaded.Search.Weights.User)
|
||||
assert.Equal(t, 0.85, loaded.Search.Weights.Hook)
|
||||
assert.Equal(t, 0.65, loaded.Search.Weights.Auto)
|
||||
|
||||
// Options config
|
||||
require.NotNil(t, loaded.Search.Options)
|
||||
assert.Equal(t, 3, loaded.Search.Options.SkipThreshold)
|
||||
}
|
||||
|
||||
// TestLoadStoreWithPartialSearchConfig tests loading assistant with partial search configuration
|
||||
func TestLoadStoreWithPartialSearchConfig(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
assistantID := "test.store-partial-search"
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
ast := &assistant.Assistant{
|
||||
AssistantModel: store.AssistantModel{
|
||||
ID: assistantID,
|
||||
Name: "Test Partial Search Config",
|
||||
Type: "assistant",
|
||||
Connector: "gpt-4o",
|
||||
Search: &searchTypes.Config{
|
||||
Web: &searchTypes.WebConfig{
|
||||
Provider: "serper",
|
||||
},
|
||||
// Only web config, others are nil
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
err := ast.Save()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
storage := assistant.GetStorage()
|
||||
if storage != nil {
|
||||
storage.DeleteAssistant(assistantID)
|
||||
}
|
||||
assistant.GetCache().Clear()
|
||||
}()
|
||||
|
||||
assistant.GetCache().Clear()
|
||||
|
||||
loaded, err := assistant.Get(assistantID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded)
|
||||
|
||||
// Verify Search config
|
||||
require.NotNil(t, loaded.Search)
|
||||
|
||||
// Web config should be set
|
||||
require.NotNil(t, loaded.Search.Web)
|
||||
assert.Equal(t, "serper", loaded.Search.Web.Provider)
|
||||
|
||||
// Other configs should be nil
|
||||
assert.Nil(t, loaded.Search.KB)
|
||||
assert.Nil(t, loaded.Search.DB)
|
||||
assert.Nil(t, loaded.Search.Keyword)
|
||||
assert.Nil(t, loaded.Search.QueryDSL)
|
||||
assert.Nil(t, loaded.Search.Rerank)
|
||||
assert.Nil(t, loaded.Search.Citation)
|
||||
assert.Nil(t, loaded.Search.Weights)
|
||||
assert.Nil(t, loaded.Search.Options)
|
||||
}
|
||||
|
||||
// TestLoadStoreWithoutSearchConfig tests loading assistant without search configuration
|
||||
func TestLoadStoreWithoutSearchConfig(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
assistantID := "test.store-no-search"
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
ast := &assistant.Assistant{
|
||||
AssistantModel: store.AssistantModel{
|
||||
ID: assistantID,
|
||||
Name: "Test No Search Config",
|
||||
Type: "assistant",
|
||||
Connector: "gpt-4o",
|
||||
// No Search config
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
err := ast.Save()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
storage := assistant.GetStorage()
|
||||
if storage != nil {
|
||||
storage.DeleteAssistant(assistantID)
|
||||
}
|
||||
assistant.GetCache().Clear()
|
||||
}()
|
||||
|
||||
assistant.GetCache().Clear()
|
||||
|
||||
loaded, err := assistant.Get(assistantID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, loaded)
|
||||
|
||||
// Search config should be nil
|
||||
assert.Nil(t, loaded.Search)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,6 @@ type API interface {
|
|||
GetPlaceholder(locale string) *store.Placeholder
|
||||
}
|
||||
|
||||
// SearchOption the search option
|
||||
type SearchOption struct {
|
||||
WebSearch *bool `json:"web_search,omitempty" yaml:"web_search,omitempty"` // Whether to search the web
|
||||
Knowledge *bool `json:"knowledge,omitempty" yaml:"knowledge,omitempty"` // Whether to search the knowledge
|
||||
}
|
||||
|
||||
// Script the script scripts except hook script
|
||||
type Script struct {
|
||||
*v8.Script
|
||||
|
|
@ -35,16 +29,13 @@ type Script struct {
|
|||
// Assistant the assistant
|
||||
type Assistant struct {
|
||||
store.AssistantModel
|
||||
Search *SearchOption `json:"search,omitempty" yaml:"search,omitempty"` // Whether this assistant supports search
|
||||
HookScript *hook.Script `json:"-" yaml:"-"` // Hook Script (index.ts)
|
||||
Scripts map[string]*Script `json:"-" yaml:"-"` // Other scripts
|
||||
HookScript *hook.Script `json:"-" yaml:"-"` // Hook Script (index.ts)
|
||||
Scripts map[string]*Script `json:"-" yaml:"-"` // Other scripts
|
||||
|
||||
// Internal
|
||||
// ===============================
|
||||
openai *api.OpenAI // OpenAI API
|
||||
search bool // Whether this assistant supports search
|
||||
vision bool // Whether this assistant supports vision
|
||||
// toolCalls bool // Whether this assistant supports tool_calls
|
||||
}
|
||||
|
||||
// MCPTool represents a simplified MCP tool for building LLM requests
|
||||
|
|
|
|||
|
|
@ -10,8 +10,14 @@ import (
|
|||
type Uses struct {
|
||||
Vision string `json:"vision,omitempty"` // Vision processing tool. Format: "agent" or "mcp:server_id"
|
||||
Audio string `json:"audio,omitempty"` // Audio processing tool. Format: "agent" or "mcp:server_id"
|
||||
Search string `json:"search,omitempty"` // Search tool. Format: "agent" or "mcp:server_id"
|
||||
Search string `json:"search,omitempty"` // Search tool. Format: "builtin", "disabled", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
Fetch string `json:"fetch,omitempty"` // Fetch/retrieval tool. Format: "agent" or "mcp:server_id"
|
||||
|
||||
// Search-related processing tools (NLP)
|
||||
Web string `json:"web,omitempty"` // Web search handler: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
Keyword string `json:"keyword,omitempty"` // Keyword extraction: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
QueryDSL string `json:"querydsl,omitempty"` // QueryDSL generation: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
Rerank string `json:"rerank,omitempty"` // Result reranking: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
}
|
||||
|
||||
// VisionFormat specifies the vision input format
|
||||
|
|
|
|||
183
agent/load.go
183
agent/load.go
|
|
@ -10,6 +10,8 @@ import (
|
|||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
searchDefaults "github.com/yaoapp/yao/agent/search/defaults"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
storeMongo "github.com/yaoapp/yao/agent/store/mongo"
|
||||
storeRedis "github.com/yaoapp/yao/agent/store/redis"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
|
|
@ -92,6 +94,12 @@ func Load(cfg config.Config) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Initialize Search Configuration
|
||||
err = initSearchConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize Assistant
|
||||
err = initAssistant()
|
||||
if err != nil {
|
||||
|
|
@ -222,6 +230,10 @@ func initAssistant() error {
|
|||
assistant.SetGlobalKBSetting(agentDSL.KB)
|
||||
}
|
||||
|
||||
if agentDSL.Search != nil {
|
||||
assistant.SetGlobalSearchConfig(agentDSL.Search)
|
||||
}
|
||||
|
||||
// Load Built-in Assistants
|
||||
err := assistant.LoadBuiltIn()
|
||||
if err != nil {
|
||||
|
|
@ -261,6 +273,177 @@ func initKBConfig() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// initSearchConfig initialize the search configuration from agent/search.yml
|
||||
func initSearchConfig() error {
|
||||
// Start with system defaults
|
||||
agentDSL.Search = searchDefaults.SystemDefaults
|
||||
|
||||
path := filepath.Join("agent", "search.yml")
|
||||
if exists, _ := application.App.Exists(path); !exists {
|
||||
return nil // Search config is optional, use defaults
|
||||
}
|
||||
|
||||
// Read the search configuration
|
||||
bytes, err := application.App.Read(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var searchConfig searchTypes.Config
|
||||
err = application.Parse("search.yml", bytes, &searchConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Merge with defaults
|
||||
agentDSL.Search = mergeSearchConfig(searchDefaults.SystemDefaults, &searchConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeSearchConfig merges two search configs (base < override)
|
||||
func mergeSearchConfig(base, override *searchTypes.Config) *searchTypes.Config {
|
||||
if base == nil {
|
||||
return override
|
||||
}
|
||||
if override == nil {
|
||||
return base
|
||||
}
|
||||
|
||||
result := *base // Copy base
|
||||
|
||||
// Merge Web config
|
||||
if override.Web != nil {
|
||||
if result.Web == nil {
|
||||
result.Web = override.Web
|
||||
} else {
|
||||
if override.Web.Provider != "" {
|
||||
result.Web.Provider = override.Web.Provider
|
||||
}
|
||||
if override.Web.APIKeyEnv != "" {
|
||||
result.Web.APIKeyEnv = override.Web.APIKeyEnv
|
||||
}
|
||||
if override.Web.MaxResults > 0 {
|
||||
result.Web.MaxResults = override.Web.MaxResults
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge KB config
|
||||
if override.KB != nil {
|
||||
if result.KB == nil {
|
||||
result.KB = override.KB
|
||||
} else {
|
||||
if len(override.KB.Collections) > 0 {
|
||||
result.KB.Collections = override.KB.Collections
|
||||
}
|
||||
if override.KB.Threshold > 0 {
|
||||
result.KB.Threshold = override.KB.Threshold
|
||||
}
|
||||
if override.KB.Graph {
|
||||
result.KB.Graph = override.KB.Graph
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge DB config
|
||||
if override.DB != nil {
|
||||
if result.DB == nil {
|
||||
result.DB = override.DB
|
||||
} else {
|
||||
if len(override.DB.Models) > 0 {
|
||||
result.DB.Models = override.DB.Models
|
||||
}
|
||||
if override.DB.MaxResults > 0 {
|
||||
result.DB.MaxResults = override.DB.MaxResults
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Keyword config
|
||||
if override.Keyword != nil {
|
||||
if result.Keyword == nil {
|
||||
result.Keyword = override.Keyword
|
||||
} else {
|
||||
if override.Keyword.MaxKeywords > 0 {
|
||||
result.Keyword.MaxKeywords = override.Keyword.MaxKeywords
|
||||
}
|
||||
if override.Keyword.Language != "" {
|
||||
result.Keyword.Language = override.Keyword.Language
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge QueryDSL config
|
||||
if override.QueryDSL != nil {
|
||||
result.QueryDSL = override.QueryDSL
|
||||
}
|
||||
|
||||
// Merge Rerank config
|
||||
if override.Rerank != nil {
|
||||
if result.Rerank == nil {
|
||||
result.Rerank = override.Rerank
|
||||
} else {
|
||||
if override.Rerank.TopN > 0 {
|
||||
result.Rerank.TopN = override.Rerank.TopN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Citation config
|
||||
if override.Citation != nil {
|
||||
if result.Citation == nil {
|
||||
result.Citation = override.Citation
|
||||
} else {
|
||||
if override.Citation.Format != "" {
|
||||
result.Citation.Format = override.Citation.Format
|
||||
}
|
||||
// AutoInjectPrompt is a bool, need to check if explicitly set
|
||||
result.Citation.AutoInjectPrompt = override.Citation.AutoInjectPrompt
|
||||
if override.Citation.CustomPrompt != "" {
|
||||
result.Citation.CustomPrompt = override.Citation.CustomPrompt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Weights config
|
||||
if override.Weights != nil {
|
||||
if result.Weights == nil {
|
||||
result.Weights = override.Weights
|
||||
} else {
|
||||
if override.Weights.User > 0 {
|
||||
result.Weights.User = override.Weights.User
|
||||
}
|
||||
if override.Weights.Hook > 0 {
|
||||
result.Weights.Hook = override.Weights.Hook
|
||||
}
|
||||
if override.Weights.Auto > 0 {
|
||||
result.Weights.Auto = override.Weights.Auto
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Options config
|
||||
if override.Options != nil {
|
||||
if result.Options == nil {
|
||||
result.Options = override.Options
|
||||
} else {
|
||||
if override.Options.SkipThreshold > 0 {
|
||||
result.Options.SkipThreshold = override.Options.SkipThreshold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &result
|
||||
}
|
||||
|
||||
// GetSearchConfig returns the global search configuration
|
||||
func GetSearchConfig() *searchTypes.Config {
|
||||
if agentDSL == nil {
|
||||
return searchDefaults.SystemDefaults
|
||||
}
|
||||
return agentDSL.Search
|
||||
}
|
||||
|
||||
// defaultAssistant get the default assistant
|
||||
func defaultAssistant() (*assistant.Assistant, error) {
|
||||
if agentDSL.Uses == nil || agentDSL.Uses.Default == "" {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,49 @@ func TestLoad(t *testing.T) {
|
|||
assert.Equal(t, "__yao.utf8", agent.KB.Chat.DocumentDefaults.Converter.ProviderID)
|
||||
assert.Equal(t, "standard-text", agent.KB.Chat.DocumentDefaults.Converter.OptionID)
|
||||
})
|
||||
|
||||
t.Run("LoadSearchConfig", func(t *testing.T) {
|
||||
// Search configuration should be loaded from agent/search.yml
|
||||
assert.NotNil(t, agent.Search)
|
||||
|
||||
// Verify web config
|
||||
assert.NotNil(t, agent.Search.Web)
|
||||
assert.Equal(t, "tavily", agent.Search.Web.Provider)
|
||||
assert.Equal(t, 10, agent.Search.Web.MaxResults)
|
||||
|
||||
// Verify KB config
|
||||
assert.NotNil(t, agent.Search.KB)
|
||||
assert.Equal(t, 0.7, agent.Search.KB.Threshold)
|
||||
assert.False(t, agent.Search.KB.Graph)
|
||||
|
||||
// Verify DB config
|
||||
assert.NotNil(t, agent.Search.DB)
|
||||
assert.Equal(t, 20, agent.Search.DB.MaxResults)
|
||||
|
||||
// Verify keyword config
|
||||
assert.NotNil(t, agent.Search.Keyword)
|
||||
assert.Equal(t, 10, agent.Search.Keyword.MaxKeywords)
|
||||
assert.Equal(t, "auto", agent.Search.Keyword.Language)
|
||||
|
||||
// Verify rerank config
|
||||
assert.NotNil(t, agent.Search.Rerank)
|
||||
assert.Equal(t, 10, agent.Search.Rerank.TopN)
|
||||
|
||||
// Verify citation config
|
||||
assert.NotNil(t, agent.Search.Citation)
|
||||
assert.Equal(t, "#ref:{id}", agent.Search.Citation.Format)
|
||||
assert.True(t, agent.Search.Citation.AutoInjectPrompt)
|
||||
|
||||
// Verify weights config
|
||||
assert.NotNil(t, agent.Search.Weights)
|
||||
assert.Equal(t, 1.0, agent.Search.Weights.User)
|
||||
assert.Equal(t, 0.8, agent.Search.Weights.Hook)
|
||||
assert.Equal(t, 0.6, agent.Search.Weights.Auto)
|
||||
|
||||
// Verify options config
|
||||
assert.NotNil(t, agent.Search.Options)
|
||||
assert.Equal(t, 5, agent.Search.Options.SkipThreshold)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetGlobalPrompts(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1089,7 +1089,7 @@ function Create(ctx, messages, options) {
|
|||
Configuration follows a three-layer hierarchy (later overrides earlier):
|
||||
|
||||
1. **System Built-in Defaults** - Hardcoded sensible defaults
|
||||
2. **Global Configuration** - `agent/agent.yml` (uses) + `agent/search.yao` (search options)
|
||||
2. **Global Configuration** - `agent/agent.yml` (uses) + `agent/search.yml` (search options)
|
||||
3. **Assistant Configuration** - `assistants/<assistant-id>/package.yao` (uses + search options)
|
||||
|
||||
### Uses Configuration
|
||||
|
|
@ -1158,7 +1158,7 @@ package defaults
|
|||
import "github.com/yaoapp/yao/agent/search/types"
|
||||
|
||||
// SystemDefaults provides hardcoded default values
|
||||
// Used by agent/load.go for merging with agent/search.yao
|
||||
// Used by agent/load.go for merging with agent/search.yml
|
||||
var SystemDefaults = &types.Config{
|
||||
// Web search defaults
|
||||
Web: &types.WebConfig{
|
||||
|
|
@ -1251,12 +1251,12 @@ import (
|
|||
|
||||
var searchConfig *searchTypes.Config
|
||||
|
||||
// initSearchConfig initialize the search configuration from agent/search.yao
|
||||
// initSearchConfig initialize the search configuration from agent/search.yml
|
||||
func initSearchConfig() error {
|
||||
// Start with system defaults
|
||||
searchConfig = searchDefaults.SystemDefaults
|
||||
|
||||
path := filepath.Join("agent", "search.yao")
|
||||
path := filepath.Join("agent", "search.yml")
|
||||
if exists, _ := application.App.Exists(path); !exists {
|
||||
return nil // Use defaults
|
||||
}
|
||||
|
|
@ -1268,7 +1268,7 @@ func initSearchConfig() error {
|
|||
}
|
||||
|
||||
var cfg searchTypes.Config
|
||||
err = application.Parse("search.yao", bytes, &cfg)
|
||||
err = application.Parse("search.yml", bytes, &cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -1304,62 +1304,54 @@ func (ast *Assistant) GetMergedSearchConfig() *searchTypes.Config {
|
|||
|
||||
### Global Configuration
|
||||
|
||||
`agent/search.yao` - Override system defaults for all assistants:
|
||||
`agent/search.yml` - Override system defaults for all assistants:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// Web search settings
|
||||
"web": {
|
||||
"provider": "tavily", // "tavily", "serper" (builtin providers only)
|
||||
"api_key_env": "TAVILY_API_KEY",
|
||||
"max_results": 10
|
||||
},
|
||||
```yaml
|
||||
# Global Search Configuration
|
||||
# These settings apply to all assistants unless overridden by assistant-specific configurations.
|
||||
|
||||
// Knowledge base search settings
|
||||
"kb": {
|
||||
"threshold": 0.7, // Similarity threshold
|
||||
"graph": false // Enable GraphRAG association
|
||||
},
|
||||
# Web search settings
|
||||
web:
|
||||
provider: "tavily" # "tavily", "serper" (builtin providers only)
|
||||
api_key_env: "TAVILY_API_KEY"
|
||||
max_results: 10
|
||||
|
||||
// Database search settings
|
||||
"db": {
|
||||
"max_results": 20
|
||||
},
|
||||
# Knowledge base search settings
|
||||
kb:
|
||||
threshold: 0.7 # Similarity threshold
|
||||
graph: false # Enable GraphRAG association
|
||||
|
||||
// Keyword extraction options (uses.keyword)
|
||||
"keyword": {
|
||||
"max_keywords": 10,
|
||||
"language": "auto" // "auto", "en", "zh", etc.
|
||||
},
|
||||
# Database search settings
|
||||
db:
|
||||
max_results: 20
|
||||
|
||||
// QueryDSL generation options (uses.querydsl)
|
||||
"querydsl": {
|
||||
"strict": false // Strict mode: fail if generation fails
|
||||
},
|
||||
# Keyword extraction options (uses.keyword)
|
||||
keyword:
|
||||
max_keywords: 10
|
||||
language: "auto" # "auto", "en", "zh", etc.
|
||||
|
||||
// Rerank options (uses.rerank)
|
||||
"rerank": {
|
||||
"top_n": 10 // Return top N results after reranking
|
||||
},
|
||||
# QueryDSL generation options (uses.querydsl)
|
||||
querydsl:
|
||||
strict: false # Strict mode: fail if generation fails
|
||||
|
||||
// Citation format for LLM references
|
||||
"citation": {
|
||||
"format": "#ref:{id}",
|
||||
"auto_inject_prompt": true // Auto-inject citation instructions to system prompt
|
||||
},
|
||||
# Rerank options (uses.rerank)
|
||||
rerank:
|
||||
top_n: 10 # Return top N results after reranking
|
||||
|
||||
// Source weighting for result merging
|
||||
"weights": {
|
||||
"user": 1.0, // User-provided DataContent (highest priority)
|
||||
"hook": 0.8, // Hook ctx.search.*() results
|
||||
"auto": 0.6 // Auto search results
|
||||
},
|
||||
# Citation format for LLM references
|
||||
citation:
|
||||
format: "#ref:{id}"
|
||||
auto_inject_prompt: true # Auto-inject citation instructions to system prompt
|
||||
|
||||
// Search behavior options
|
||||
"options": {
|
||||
"skip_threshold": 5 // Skip auto search if user provides >= N results
|
||||
}
|
||||
}
|
||||
# Source weighting for result merging
|
||||
weights:
|
||||
user: 1.0 # User-provided DataContent (highest priority)
|
||||
hook: 0.8 # Hook ctx.search.*() results
|
||||
auto: 0.6 # Auto search results
|
||||
|
||||
# Search behavior options
|
||||
options:
|
||||
skip_threshold: 5 # Skip auto search if user provides >= N results
|
||||
```
|
||||
|
||||
### Assistant Configuration
|
||||
|
|
@ -1380,7 +1372,7 @@ func (ast *Assistant) GetMergedSearchConfig() *searchTypes.Config {
|
|||
"rerank": "mcp:my-server.rerank" // Use MCP tool for reranking
|
||||
},
|
||||
|
||||
// Search configuration (overrides agent/search.yao)
|
||||
// Search configuration (overrides agent/search.yml)
|
||||
"search": {
|
||||
// Overrides global web settings
|
||||
"web": {
|
||||
|
|
@ -2017,7 +2009,7 @@ if (result.error) {
|
|||
Configuration is merged with later layers overriding earlier ones:
|
||||
|
||||
1. **System Built-in** - Hardcoded defaults (lowest priority)
|
||||
2. **Global-level** - `agent/agent.yml` (uses) + `agent/search.yao` (search options)
|
||||
2. **Global-level** - `agent/agent.yml` (uses) + `agent/search.yml` (search options)
|
||||
3. **Assistant-level** - `assistants/<assistant-id>/package.yao` (uses + search)
|
||||
4. **Hook-level** - CreateHook return `uses.search` value
|
||||
5. **Request-level** - `options.uses.search` in Stream() call (highest priority)
|
||||
|
|
@ -2301,19 +2293,15 @@ Stream()
|
|||
|
||||
**Configuration:**
|
||||
|
||||
Global defaults (`agent/search.yao`):
|
||||
Global defaults (`agent/search.yml`):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"weights": {
|
||||
"user": 1.0, // User-provided DataContent
|
||||
"hook": 0.8, // Hook ctx.search.*() results
|
||||
"auto": 0.6 // Auto search results
|
||||
},
|
||||
"options": {
|
||||
"skip_threshold": 5 // Skip auto search if user provides >= N results
|
||||
}
|
||||
}
|
||||
```yaml
|
||||
weights:
|
||||
user: 1.0 # User-provided DataContent
|
||||
hook: 0.8 # Hook ctx.search.*() results
|
||||
auto: 0.6 # Auto search results
|
||||
options:
|
||||
skip_threshold: 5 # Skip auto search if user provides >= N results
|
||||
```
|
||||
|
||||
Assistant-level override (`assistants/<assistant-id>/package.yao`):
|
||||
|
|
|
|||
27
agent/search/citation.go
Normal file
27
agent/search/citation.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// CitationGenerator generates unique citation IDs
|
||||
type CitationGenerator struct {
|
||||
counter uint64
|
||||
}
|
||||
|
||||
// NewCitationGenerator creates a new citation generator
|
||||
func NewCitationGenerator() *CitationGenerator {
|
||||
return &CitationGenerator{}
|
||||
}
|
||||
|
||||
// Next generates the next citation ID
|
||||
func (g *CitationGenerator) Next() string {
|
||||
n := atomic.AddUint64(&g.counter, 1)
|
||||
return fmt.Sprintf("ref_%03d", n)
|
||||
}
|
||||
|
||||
// Reset resets the counter (for testing)
|
||||
func (g *CitationGenerator) Reset() {
|
||||
atomic.StoreUint64(&g.counter, 0)
|
||||
}
|
||||
63
agent/search/defaults/defaults.go
Normal file
63
agent/search/defaults/defaults.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package defaults
|
||||
|
||||
import "github.com/yaoapp/yao/agent/search/types"
|
||||
|
||||
// SystemDefaults provides hardcoded default values
|
||||
// Used by agent/load.go for merging with agent/search.yao
|
||||
var SystemDefaults = &types.Config{
|
||||
// Web search defaults
|
||||
Web: &types.WebConfig{
|
||||
Provider: "tavily",
|
||||
MaxResults: 10,
|
||||
},
|
||||
|
||||
// KB search defaults
|
||||
KB: &types.KBConfig{
|
||||
Threshold: 0.7,
|
||||
Graph: false,
|
||||
},
|
||||
|
||||
// DB search defaults
|
||||
DB: &types.DBConfig{
|
||||
MaxResults: 20,
|
||||
},
|
||||
|
||||
// Keyword extraction options (uses.keyword)
|
||||
Keyword: &types.KeywordConfig{
|
||||
MaxKeywords: 10,
|
||||
Language: "auto",
|
||||
},
|
||||
|
||||
// QueryDSL generation options (uses.querydsl)
|
||||
QueryDSL: &types.QueryDSLConfig{
|
||||
Strict: false,
|
||||
},
|
||||
|
||||
// Rerank options (uses.rerank)
|
||||
Rerank: &types.RerankConfig{
|
||||
TopN: 10,
|
||||
},
|
||||
|
||||
// Citation
|
||||
Citation: &types.CitationConfig{
|
||||
Format: "#ref:{id}",
|
||||
AutoInjectPrompt: true,
|
||||
},
|
||||
|
||||
// Source weights
|
||||
Weights: &types.WeightsConfig{
|
||||
User: 1.0,
|
||||
Hook: 0.8,
|
||||
Auto: 0.6,
|
||||
},
|
||||
|
||||
// Behavior options
|
||||
Options: &types.OptionsConfig{
|
||||
SkipThreshold: 5,
|
||||
},
|
||||
}
|
||||
|
||||
// GetWeight returns the weight for a source type using default config
|
||||
func GetWeight(source types.SourceType) float64 {
|
||||
return SystemDefaults.GetWeight(source)
|
||||
}
|
||||
34
agent/search/handlers/db/handler.go
Normal file
34
agent/search/handlers/db/handler.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// Handler implements DB search
|
||||
type Handler struct {
|
||||
usesQueryDSL string // "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
config *types.DBConfig // DB search configuration
|
||||
}
|
||||
|
||||
// NewHandler creates a new DB search handler
|
||||
func NewHandler(usesQueryDSL string, cfg *types.DBConfig) *Handler {
|
||||
return &Handler{usesQueryDSL: usesQueryDSL, config: cfg}
|
||||
}
|
||||
|
||||
// Type returns the search type this handler supports
|
||||
func (h *Handler) Type() types.SearchType {
|
||||
return types.SearchTypeDB
|
||||
}
|
||||
|
||||
// Search converts NL to QueryDSL and executes
|
||||
// TODO: Implement actual search logic
|
||||
func (h *Handler) Search(req *types.Request) (*types.Result, error) {
|
||||
// Skeleton implementation - returns empty result
|
||||
return &types.Result{
|
||||
Type: types.SearchTypeDB,
|
||||
Query: req.Query,
|
||||
Source: req.Source,
|
||||
Items: []*types.ResultItem{},
|
||||
Total: 0,
|
||||
}, nil
|
||||
}
|
||||
33
agent/search/handlers/kb/handler.go
Normal file
33
agent/search/handlers/kb/handler.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package kb
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// Handler implements KB search
|
||||
type Handler struct {
|
||||
config *types.KBConfig // KB search configuration
|
||||
}
|
||||
|
||||
// NewHandler creates a new KB search handler
|
||||
func NewHandler(cfg *types.KBConfig) *Handler {
|
||||
return &Handler{config: cfg}
|
||||
}
|
||||
|
||||
// Type returns the search type this handler supports
|
||||
func (h *Handler) Type() types.SearchType {
|
||||
return types.SearchTypeKB
|
||||
}
|
||||
|
||||
// Search executes vector search and optional graph association
|
||||
// TODO: Implement actual search logic
|
||||
func (h *Handler) Search(req *types.Request) (*types.Result, error) {
|
||||
// Skeleton implementation - returns empty result
|
||||
return &types.Result{
|
||||
Type: types.SearchTypeKB,
|
||||
Query: req.Query,
|
||||
Source: req.Source,
|
||||
Items: []*types.ResultItem{},
|
||||
Total: 0,
|
||||
}, nil
|
||||
}
|
||||
34
agent/search/handlers/web/handler.go
Normal file
34
agent/search/handlers/web/handler.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// Handler implements web search
|
||||
type Handler struct {
|
||||
usesWeb string // "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
config *types.WebConfig // Web search configuration
|
||||
}
|
||||
|
||||
// NewHandler creates a new web search handler
|
||||
func NewHandler(usesWeb string, cfg *types.WebConfig) *Handler {
|
||||
return &Handler{usesWeb: usesWeb, config: cfg}
|
||||
}
|
||||
|
||||
// Type returns the search type this handler supports
|
||||
func (h *Handler) Type() types.SearchType {
|
||||
return types.SearchTypeWeb
|
||||
}
|
||||
|
||||
// Search executes web search based on uses.web mode
|
||||
// TODO: Implement actual search logic
|
||||
func (h *Handler) Search(req *types.Request) (*types.Result, error) {
|
||||
// Skeleton implementation - returns empty result
|
||||
return &types.Result{
|
||||
Type: types.SearchTypeWeb,
|
||||
Query: req.Query,
|
||||
Source: req.Source,
|
||||
Items: []*types.ResultItem{},
|
||||
Total: 0,
|
||||
}, nil
|
||||
}
|
||||
14
agent/search/interfaces/handler.go
Normal file
14
agent/search/interfaces/handler.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package interfaces
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// Handler defines the interface for search implementations
|
||||
type Handler interface {
|
||||
// Type returns the search type this handler supports
|
||||
Type() types.SearchType
|
||||
|
||||
// Search executes the search and returns results
|
||||
Search(req *types.Request) (*types.Result, error)
|
||||
}
|
||||
20
agent/search/interfaces/nlp.go
Normal file
20
agent/search/interfaces/nlp.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package interfaces
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// KeywordExtractor extracts keywords for web search
|
||||
type KeywordExtractor interface {
|
||||
// Extract extracts search keywords from user message
|
||||
Extract(content string, opts *types.KeywordOptions) ([]string, error)
|
||||
}
|
||||
|
||||
// QueryDSLGenerator generates QueryDSL for DB search
|
||||
type QueryDSLGenerator interface {
|
||||
// Generate converts natural language to QueryDSL
|
||||
Generate(query string, schemas []*types.ModelSchema) (*types.QueryDSL, error)
|
||||
}
|
||||
|
||||
// Note: Embedding is handled by KB collection's own config (embedding provider + model),
|
||||
// not defined here. See KB handler for details.
|
||||
11
agent/search/interfaces/reranker.go
Normal file
11
agent/search/interfaces/reranker.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package interfaces
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// Reranker reorders search results by relevance
|
||||
type Reranker interface {
|
||||
// Rerank reorders results based on query relevance
|
||||
Rerank(query string, items []*types.ResultItem, opts *types.RerankOptions) ([]*types.ResultItem, error)
|
||||
}
|
||||
17
agent/search/interfaces/searcher.go
Normal file
17
agent/search/interfaces/searcher.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package interfaces
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// Searcher is the main interface exposed to external callers
|
||||
type Searcher interface {
|
||||
// Search executes a single search request
|
||||
Search(req *types.Request) (*types.Result, error)
|
||||
|
||||
// SearchMultiple executes multiple searches (potentially in parallel)
|
||||
SearchMultiple(reqs []*types.Request) ([]*types.Result, error)
|
||||
|
||||
// BuildReferences converts search results to unified Reference format for LLM
|
||||
BuildReferences(results []*types.Result) []*types.Reference
|
||||
}
|
||||
102
agent/search/reference.go
Normal file
102
agent/search/reference.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// DefaultCitationPrompt is the default prompt for citation instructions
|
||||
const DefaultCitationPrompt = `You have access to reference data in <references> tags. Each <ref> has:
|
||||
- id: Citation identifier
|
||||
- type: Data type (web/kb/db)
|
||||
- weight: Relevance weight (1.0=highest priority, 0.6=lowest)
|
||||
- source: Origin (user=user-provided, hook=assistant-searched, auto=auto-searched)
|
||||
|
||||
Prioritize higher-weight references when answering.
|
||||
|
||||
When citing a reference, use this exact HTML format:
|
||||
<a class="ref" data-ref-id="{id}" data-ref-type="{type}" href="#ref:{id}">[{id}]</a>
|
||||
|
||||
Example: According to the product data<a class="ref" data-ref-id="ref_001" data-ref-type="db" href="#ref:ref_001">[ref_001]</a>, the price is $999.`
|
||||
|
||||
// BuildReferences converts search results to unified Reference format
|
||||
func BuildReferences(results []*types.Result) []*types.Reference {
|
||||
var refs []*types.Reference
|
||||
for _, result := range results {
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
for _, item := range result.Items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, &types.Reference{
|
||||
ID: item.CitationID,
|
||||
Type: item.Type,
|
||||
Source: item.Source,
|
||||
Weight: item.Weight,
|
||||
Score: item.Score,
|
||||
Title: item.Title,
|
||||
Content: item.Content,
|
||||
URL: item.URL,
|
||||
})
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// FormatReferencesXML formats references as XML for LLM context
|
||||
func FormatReferencesXML(refs []*types.Reference) string {
|
||||
if len(refs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<references>\n")
|
||||
|
||||
for _, ref := range refs {
|
||||
if ref == nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(`<ref id="%s" type="%s" weight="%.1f" source="%s">`,
|
||||
ref.ID, ref.Type, ref.Weight, ref.Source))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if ref.Title != "" {
|
||||
sb.WriteString(ref.Title)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(ref.Content)
|
||||
if ref.URL != "" {
|
||||
sb.WriteString("\nURL: ")
|
||||
sb.WriteString(ref.URL)
|
||||
}
|
||||
sb.WriteString("\n</ref>\n")
|
||||
}
|
||||
|
||||
sb.WriteString("</references>")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GetCitationPrompt returns the citation instruction prompt
|
||||
func GetCitationPrompt(cfg *types.CitationConfig) string {
|
||||
if cfg == nil {
|
||||
return DefaultCitationPrompt
|
||||
}
|
||||
if cfg.CustomPrompt != "" {
|
||||
return cfg.CustomPrompt
|
||||
}
|
||||
return DefaultCitationPrompt
|
||||
}
|
||||
|
||||
// BuildReferenceContext builds the complete reference context for LLM
|
||||
func BuildReferenceContext(results []*types.Result, cfg *types.CitationConfig) *types.ReferenceContext {
|
||||
refs := BuildReferences(results)
|
||||
return &types.ReferenceContext{
|
||||
References: refs,
|
||||
XML: FormatReferencesXML(refs),
|
||||
Prompt: GetCitationPrompt(cfg),
|
||||
}
|
||||
}
|
||||
29
agent/search/registry.go
Normal file
29
agent/search/registry.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/search/interfaces"
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// Registry manages search handlers
|
||||
type Registry struct {
|
||||
handlers map[types.SearchType]interfaces.Handler
|
||||
}
|
||||
|
||||
// NewRegistry creates a new handler registry
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
handlers: make(map[types.SearchType]interfaces.Handler),
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers a handler for a search type
|
||||
func (r *Registry) Register(handler interfaces.Handler) {
|
||||
r.handlers[handler.Type()] = handler
|
||||
}
|
||||
|
||||
// Get returns the handler for a search type
|
||||
func (r *Registry) Get(t types.SearchType) (interfaces.Handler, bool) {
|
||||
h, ok := r.handlers[t]
|
||||
return h, ok
|
||||
}
|
||||
125
agent/search/search.go
Normal file
125
agent/search/search.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/yaoapp/yao/agent/search/handlers/db"
|
||||
"github.com/yaoapp/yao/agent/search/handlers/kb"
|
||||
"github.com/yaoapp/yao/agent/search/handlers/web"
|
||||
"github.com/yaoapp/yao/agent/search/interfaces"
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// Searcher is the main search implementation
|
||||
type Searcher struct {
|
||||
config *types.Config // Merged config (global + assistant)
|
||||
handlers map[types.SearchType]interfaces.Handler
|
||||
reranker interfaces.Reranker
|
||||
citation *CitationGenerator
|
||||
}
|
||||
|
||||
// Uses contains the search-specific uses configuration
|
||||
// These are extracted from context.Uses and search config
|
||||
type Uses struct {
|
||||
Search string // "builtin", "disabled", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
Web string // "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
Keyword string // "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
QueryDSL string // "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
Rerank string // "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
}
|
||||
|
||||
// New creates a new Searcher instance
|
||||
// cfg: merged config from agent/load.go + assistant config
|
||||
// uses: merged uses configuration (global → assistant → hook)
|
||||
func New(cfg *types.Config, uses *Uses) *Searcher {
|
||||
if uses == nil {
|
||||
uses = &Uses{}
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &types.Config{}
|
||||
}
|
||||
|
||||
return &Searcher{
|
||||
config: cfg,
|
||||
handlers: map[types.SearchType]interfaces.Handler{
|
||||
types.SearchTypeWeb: web.NewHandler(uses.Web, cfg.Web),
|
||||
types.SearchTypeKB: kb.NewHandler(cfg.KB),
|
||||
types.SearchTypeDB: db.NewHandler(uses.QueryDSL, cfg.DB),
|
||||
},
|
||||
reranker: newBuiltinReranker(), // TODO: use uses.Rerank to select reranker
|
||||
citation: NewCitationGenerator(),
|
||||
}
|
||||
}
|
||||
|
||||
// Search executes a single search request
|
||||
func (s *Searcher) Search(req *types.Request) (*types.Result, error) {
|
||||
handler, ok := s.handlers[req.Type]
|
||||
if !ok {
|
||||
return &types.Result{Error: "unsupported search type"}, nil
|
||||
}
|
||||
|
||||
// Execute search
|
||||
result, err := handler.Search(req)
|
||||
if err != nil {
|
||||
return &types.Result{Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
// Assign weights based on source
|
||||
for _, item := range result.Items {
|
||||
item.Weight = s.config.GetWeight(req.Source)
|
||||
}
|
||||
|
||||
// Rerank if requested
|
||||
if req.Rerank != nil && s.reranker != nil {
|
||||
result.Items, _ = s.reranker.Rerank(req.Query, result.Items, req.Rerank)
|
||||
}
|
||||
|
||||
// Generate citation IDs
|
||||
for _, item := range result.Items {
|
||||
item.CitationID = s.citation.Next()
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SearchMultiple executes multiple searches in parallel
|
||||
func (s *Searcher) SearchMultiple(reqs []*types.Request) ([]*types.Result, error) {
|
||||
results := make([]*types.Result, len(reqs))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for i, req := range reqs {
|
||||
wg.Add(1)
|
||||
go func(idx int, r *types.Request) {
|
||||
defer wg.Done()
|
||||
result, _ := s.Search(r)
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
}(i, req)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// BuildReferences converts search results to unified Reference format
|
||||
func (s *Searcher) BuildReferences(results []*types.Result) []*types.Reference {
|
||||
return BuildReferences(results)
|
||||
}
|
||||
|
||||
// builtinReranker is a simple score-based reranker
|
||||
type builtinReranker struct{}
|
||||
|
||||
func newBuiltinReranker() *builtinReranker {
|
||||
return &builtinReranker{}
|
||||
}
|
||||
|
||||
func (r *builtinReranker) Rerank(query string, items []*types.ResultItem, opts *types.RerankOptions) ([]*types.ResultItem, error) {
|
||||
// Simple implementation: sort by score (already sorted in most cases)
|
||||
// TODO: Implement proper reranking logic
|
||||
if opts != nil && opts.TopN > 0 && opts.TopN < len(items) {
|
||||
return items[:opts.TopN], nil
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
117
agent/search/types/config.go
Normal file
117
agent/search/types/config.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package types
|
||||
|
||||
// Config represents the complete search configuration
|
||||
type Config struct {
|
||||
Web *WebConfig `json:"web,omitempty" yaml:"web,omitempty"`
|
||||
KB *KBConfig `json:"kb,omitempty" yaml:"kb,omitempty"`
|
||||
DB *DBConfig `json:"db,omitempty" yaml:"db,omitempty"`
|
||||
Keyword *KeywordConfig `json:"keyword,omitempty" yaml:"keyword,omitempty"`
|
||||
QueryDSL *QueryDSLConfig `json:"querydsl,omitempty" yaml:"querydsl,omitempty"`
|
||||
Rerank *RerankConfig `json:"rerank,omitempty" yaml:"rerank,omitempty"`
|
||||
Citation *CitationConfig `json:"citation,omitempty" yaml:"citation,omitempty"`
|
||||
Weights *WeightsConfig `json:"weights,omitempty" yaml:"weights,omitempty"`
|
||||
Options *OptionsConfig `json:"options,omitempty" yaml:"options,omitempty"`
|
||||
}
|
||||
|
||||
// WebConfig for web search settings
|
||||
// Note: uses.web determines the mode (builtin/agent/mcp)
|
||||
// Provider is only used when uses.web = "builtin"
|
||||
type WebConfig struct {
|
||||
Provider string `json:"provider,omitempty" yaml:"provider,omitempty"` // "tavily" or "serper" (for builtin mode)
|
||||
APIKeyEnv string `json:"api_key_env,omitempty" yaml:"api_key_env,omitempty"` // Environment variable for API key
|
||||
MaxResults int `json:"max_results,omitempty" yaml:"max_results,omitempty"` // Max results (default: 10)
|
||||
}
|
||||
|
||||
// KBConfig for knowledge base search settings
|
||||
type KBConfig struct {
|
||||
Collections []string `json:"collections,omitempty" yaml:"collections,omitempty"` // Default collections
|
||||
Threshold float64 `json:"threshold,omitempty" yaml:"threshold,omitempty"` // Similarity threshold (default: 0.7)
|
||||
Graph bool `json:"graph,omitempty" yaml:"graph,omitempty"` // Enable GraphRAG (default: false)
|
||||
}
|
||||
|
||||
// DBConfig for database search settings
|
||||
type DBConfig struct {
|
||||
Models []string `json:"models,omitempty" yaml:"models,omitempty"` // Default models
|
||||
MaxResults int `json:"max_results,omitempty" yaml:"max_results,omitempty"` // Max results (default: 20)
|
||||
}
|
||||
|
||||
// KeywordConfig for keyword extraction
|
||||
type KeywordConfig struct {
|
||||
MaxKeywords int `json:"max_keywords,omitempty" yaml:"max_keywords,omitempty"` // Max keywords (default: 10)
|
||||
Language string `json:"language,omitempty" yaml:"language,omitempty"` // "auto", "en", "zh", etc.
|
||||
}
|
||||
|
||||
// KeywordOptions for keyword extraction (runtime options)
|
||||
type KeywordOptions struct {
|
||||
MaxKeywords int `json:"max_keywords,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// QueryDSLConfig for QueryDSL generation from natural language
|
||||
type QueryDSLConfig struct {
|
||||
Strict bool `json:"strict,omitempty" yaml:"strict,omitempty"` // Fail if generation fails (default: false)
|
||||
}
|
||||
|
||||
// RerankConfig for reranking
|
||||
type RerankConfig struct {
|
||||
TopN int `json:"top_n,omitempty" yaml:"top_n,omitempty"` // Return top N (default: 10)
|
||||
}
|
||||
|
||||
// CitationConfig for citation format
|
||||
type CitationConfig struct {
|
||||
Format string `json:"format,omitempty" yaml:"format,omitempty"` // Default: "#ref:{id}"
|
||||
AutoInjectPrompt bool `json:"auto_inject_prompt,omitempty" yaml:"auto_inject_prompt,omitempty"` // Auto-inject prompt (default: true)
|
||||
CustomPrompt string `json:"custom_prompt,omitempty" yaml:"custom_prompt,omitempty"` // Custom prompt template
|
||||
}
|
||||
|
||||
// WeightsConfig for source weighting
|
||||
type WeightsConfig struct {
|
||||
User float64 `json:"user,omitempty" yaml:"user,omitempty"` // User-provided (default: 1.0)
|
||||
Hook float64 `json:"hook,omitempty" yaml:"hook,omitempty"` // Hook results (default: 0.8)
|
||||
Auto float64 `json:"auto,omitempty" yaml:"auto,omitempty"` // Auto search (default: 0.6)
|
||||
}
|
||||
|
||||
// OptionsConfig for search behavior
|
||||
type OptionsConfig struct {
|
||||
SkipThreshold int `json:"skip_threshold,omitempty" yaml:"skip_threshold,omitempty"` // Skip auto search if user provides >= N results
|
||||
}
|
||||
|
||||
// GetWeight returns the weight for a source type
|
||||
func (c *Config) GetWeight(source SourceType) float64 {
|
||||
if c == nil || c.Weights == nil {
|
||||
return getDefaultWeight(source)
|
||||
}
|
||||
switch source {
|
||||
case SourceUser:
|
||||
if c.Weights.User > 0 {
|
||||
return c.Weights.User
|
||||
}
|
||||
return 1.0
|
||||
case SourceHook:
|
||||
if c.Weights.Hook > 0 {
|
||||
return c.Weights.Hook
|
||||
}
|
||||
return 0.8
|
||||
case SourceAuto:
|
||||
if c.Weights.Auto > 0 {
|
||||
return c.Weights.Auto
|
||||
}
|
||||
return 0.6
|
||||
default:
|
||||
return 0.6
|
||||
}
|
||||
}
|
||||
|
||||
// getDefaultWeight returns default weight for a source type
|
||||
func getDefaultWeight(source SourceType) float64 {
|
||||
switch source {
|
||||
case SourceUser:
|
||||
return 1.0
|
||||
case SourceHook:
|
||||
return 0.8
|
||||
case SourceAuto:
|
||||
return 0.6
|
||||
default:
|
||||
return 0.6
|
||||
}
|
||||
}
|
||||
12
agent/search/types/graph.go
Normal file
12
agent/search/types/graph.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package types
|
||||
|
||||
// GraphNode represents a related entity from knowledge graph
|
||||
type GraphNode struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // Entity type
|
||||
Name string `json:"name"` // Entity name
|
||||
Description string `json:"description,omitempty"` // Entity description
|
||||
Relation string `json:"relation,omitempty"` // Relationship to query
|
||||
Score float64 `json:"score,omitempty"` // Relevance score
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
22
agent/search/types/reference.go
Normal file
22
agent/search/types/reference.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package types
|
||||
|
||||
// Reference is the unified structure for all data sources
|
||||
// Used to build LLM context from search results
|
||||
type Reference struct {
|
||||
ID string `json:"id"` // Unique citation ID: "ref_001", "ref_002"
|
||||
Type SearchType `json:"type"` // Data type: "web", "kb", "db"
|
||||
Source SourceType `json:"source"` // Origin: "user", "hook", "auto"
|
||||
Weight float64 `json:"weight"` // Relevance weight (1.0=highest, 0.6=lowest)
|
||||
Score float64 `json:"score"` // Relevance score (0-1)
|
||||
Title string `json:"title"` // Optional title
|
||||
Content string `json:"content"` // Main content
|
||||
URL string `json:"url"` // Optional URL
|
||||
Meta map[string]interface{} `json:"meta"` // Additional metadata
|
||||
}
|
||||
|
||||
// ReferenceContext holds the formatted references for LLM input
|
||||
type ReferenceContext struct {
|
||||
References []*Reference `json:"references"` // All references
|
||||
XML string `json:"xml"` // Formatted <references> XML
|
||||
Prompt string `json:"prompt"` // Citation instruction prompt
|
||||
}
|
||||
141
agent/search/types/types.go
Normal file
141
agent/search/types/types.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package types
|
||||
|
||||
// SearchType represents the type of search
|
||||
type SearchType string
|
||||
|
||||
const (
|
||||
SearchTypeWeb SearchType = "web" // Web/Internet search
|
||||
SearchTypeKB SearchType = "kb" // Knowledge base vector search
|
||||
SearchTypeDB SearchType = "db" // Database search (Yao Model/QueryDSL)
|
||||
)
|
||||
|
||||
// SourceType represents where the search result came from
|
||||
type SourceType string
|
||||
|
||||
const (
|
||||
SourceUser SourceType = "user" // User-provided DataContent (highest priority)
|
||||
SourceHook SourceType = "hook" // Hook ctx.search.*() results
|
||||
SourceAuto SourceType = "auto" // Auto search results (lowest priority)
|
||||
)
|
||||
|
||||
// Request represents a search request
|
||||
type Request struct {
|
||||
// Common fields
|
||||
Query string `json:"query"` // Search query (natural language)
|
||||
Type SearchType `json:"type"` // Search type: "web", "kb", or "db"
|
||||
Limit int `json:"limit,omitempty"` // Max results (default: 10)
|
||||
Source SourceType `json:"source"` // Source of this request (user/hook/auto)
|
||||
|
||||
// Web search specific
|
||||
Sites []string `json:"sites,omitempty"` // Restrict to specific sites
|
||||
TimeRange string `json:"time_range,omitempty"` // "day", "week", "month", "year"
|
||||
|
||||
// Knowledge base specific
|
||||
Collections []string `json:"collections,omitempty"` // KB collection IDs
|
||||
Threshold float64 `json:"threshold,omitempty"` // Similarity threshold (0-1)
|
||||
Graph bool `json:"graph,omitempty"` // Enable graph association
|
||||
|
||||
// Database search specific
|
||||
Models []string `json:"models,omitempty"` // Model IDs (e.g., "user", "agents.mybot.product")
|
||||
Wheres []QueryWhere `json:"wheres,omitempty"` // Pre-defined filters (optional)
|
||||
Orders []QueryOrder `json:"orders,omitempty"` // Sort orders (optional)
|
||||
Select []string `json:"select,omitempty"` // Fields to return (optional)
|
||||
|
||||
// Reranking
|
||||
Rerank *RerankOptions `json:"rerank,omitempty"`
|
||||
}
|
||||
|
||||
// QueryWhere represents a filter condition for DB search
|
||||
type QueryWhere struct {
|
||||
Field string `json:"field"` // Field name
|
||||
Op string `json:"op,omitempty"` // Operator: "=", "like", ">", "<", "in", etc. (default: "=")
|
||||
Value interface{} `json:"value"` // Filter value
|
||||
}
|
||||
|
||||
// QueryOrder represents a sort order for DB search
|
||||
type QueryOrder struct {
|
||||
Field string `json:"field"` // Field name
|
||||
Order string `json:"order,omitempty"` // "asc" or "desc" (default: "desc")
|
||||
}
|
||||
|
||||
// RerankOptions controls result reranking
|
||||
// Reranker type is determined by uses.rerank in agent/agent.yml
|
||||
type RerankOptions struct {
|
||||
TopN int `json:"top_n,omitempty"` // Return top N after reranking
|
||||
}
|
||||
|
||||
// Result represents the search result
|
||||
type Result struct {
|
||||
Type SearchType `json:"type"` // Search type
|
||||
Query string `json:"query"` // Original query
|
||||
Source SourceType `json:"source"` // Source of this result
|
||||
Items []*ResultItem `json:"items"` // Result items
|
||||
Total int `json:"total"` // Total matches
|
||||
Duration int64 `json:"duration_ms"` // Search duration in ms
|
||||
Error string `json:"error,omitempty"` // Error message if failed
|
||||
|
||||
// Graph associations (KB only, if enabled)
|
||||
GraphNodes []*GraphNode `json:"graph_nodes,omitempty"`
|
||||
}
|
||||
|
||||
// ResultItem represents a single search result item
|
||||
type ResultItem struct {
|
||||
// Citation
|
||||
CitationID string `json:"citation_id"` // Unique ID for LLM reference: "ref_001"
|
||||
|
||||
// Weighting
|
||||
Source SourceType `json:"source"` // Source type: "user", "hook", "auto"
|
||||
Weight float64 `json:"weight"` // Source weight (from config)
|
||||
Score float64 `json:"score,omitempty"` // Relevance score (0-1)
|
||||
|
||||
// Common fields
|
||||
Type SearchType `json:"type"` // Search type for this item
|
||||
Title string `json:"title,omitempty"` // Title/headline
|
||||
Content string `json:"content"` // Main content/snippet
|
||||
URL string `json:"url,omitempty"` // Source URL
|
||||
|
||||
// KB specific
|
||||
DocumentID string `json:"document_id,omitempty"` // Source document ID
|
||||
Collection string `json:"collection,omitempty"` // Collection name
|
||||
|
||||
// DB specific
|
||||
Model string `json:"model,omitempty"` // Model ID
|
||||
RecordID interface{} `json:"record_id,omitempty"` // Record primary key
|
||||
Data map[string]interface{} `json:"data,omitempty"` // Full record data
|
||||
|
||||
// Metadata
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional metadata
|
||||
}
|
||||
|
||||
// ProcessedQuery represents a processed query ready for execution
|
||||
type ProcessedQuery struct {
|
||||
Type SearchType `json:"type"`
|
||||
Keywords []string `json:"keywords,omitempty"` // For web search
|
||||
Vector []float32 `json:"vector,omitempty"` // For KB search
|
||||
DSL *QueryDSL `json:"dsl,omitempty"` // For DB search
|
||||
}
|
||||
|
||||
// QueryDSL represents a Yao QueryDSL for database search
|
||||
type QueryDSL struct {
|
||||
Model string `json:"model"` // Target model
|
||||
Select []string `json:"select,omitempty"` // Fields to return
|
||||
Wheres []QueryWhere `json:"wheres,omitempty"` // Filter conditions
|
||||
Orders []QueryOrder `json:"orders,omitempty"` // Sort orders
|
||||
Limit int `json:"limit,omitempty"` // Max results
|
||||
}
|
||||
|
||||
// ModelSchema represents a Yao Model schema for DSL generation
|
||||
type ModelSchema struct {
|
||||
ID string `json:"id"` // Model ID
|
||||
Name string `json:"name"` // Model name
|
||||
Description string `json:"description"` // Model description
|
||||
Fields []FieldSchema `json:"fields"` // Field definitions
|
||||
}
|
||||
|
||||
// FieldSchema represents a field in the model schema
|
||||
type FieldSchema struct {
|
||||
Name string `json:"name"` // Field name
|
||||
Type string `json:"type"` // Field type
|
||||
Description string `json:"description"` // Field description
|
||||
Searchable bool `json:"searchable"` // Whether field is searchable
|
||||
}
|
||||
|
|
@ -9,7 +9,9 @@ import (
|
|||
"github.com/spf13/cast"
|
||||
"github.com/yaoapp/gou/connector"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// ToKnowledgeBase converts various types to KnowledgeBase
|
||||
|
|
@ -616,3 +618,59 @@ func ToPromptPresets(v interface{}) (map[string][]Prompt, error) {
|
|||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ToUses converts various types to context.Uses
|
||||
func ToUses(v interface{}) (*context.Uses, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch uses := v.(type) {
|
||||
case *context.Uses:
|
||||
return uses, nil
|
||||
|
||||
case context.Uses:
|
||||
return &uses, nil
|
||||
|
||||
default:
|
||||
raw, err := jsoniter.Marshal(uses)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uses format error: %s", err.Error())
|
||||
}
|
||||
|
||||
var result context.Uses
|
||||
err = jsoniter.Unmarshal(raw, &result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uses format error: %s", err.Error())
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ToSearchConfig converts various types to searchTypes.Config
|
||||
func ToSearchConfig(v interface{}) (*searchTypes.Config, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch cfg := v.(type) {
|
||||
case *searchTypes.Config:
|
||||
return cfg, nil
|
||||
|
||||
case searchTypes.Config:
|
||||
return &cfg, nil
|
||||
|
||||
default:
|
||||
raw, err := jsoniter.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search config format error: %s", err.Error())
|
||||
}
|
||||
|
||||
var result searchTypes.Config
|
||||
err = jsoniter.Unmarshal(raw, &result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search config format error: %s", err.Error())
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ var AssistantAllowedFields = map[string]bool{
|
|||
"share": true,
|
||||
"locales": true,
|
||||
"uses": true,
|
||||
"search": true,
|
||||
"automated": true,
|
||||
"mentionable": true,
|
||||
"created_at": true,
|
||||
|
|
@ -104,6 +105,7 @@ var AssistantFullFields = []string{
|
|||
"share",
|
||||
"locales",
|
||||
"uses",
|
||||
"search",
|
||||
"automated",
|
||||
"mentionable",
|
||||
"created_at",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/yaoapp/xun/dbal/query"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
// Setting represents the conversation configuration structure
|
||||
|
|
@ -436,6 +437,7 @@ type AssistantModel struct {
|
|||
Source string `json:"source,omitempty"` // Hook script source code
|
||||
Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales
|
||||
Uses *context.Uses `json:"uses,omitempty"` // Assistant-specific wrapper configurations for vision, audio, etc. If not set, use global settings
|
||||
Search *searchTypes.Config `json:"search,omitempty"` // Search configuration (web, kb, db, citation, weights, etc.)
|
||||
CreatedAt int64 `json:"created_at"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at"` // Last update timestamp
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/yaoapp/xun/dbal/query"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
"github.com/yaoapp/yao/agent/store/types"
|
||||
)
|
||||
|
||||
|
|
@ -183,6 +184,7 @@ func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error)
|
|||
"placeholder": assistant.Placeholder,
|
||||
"locales": assistant.Locales,
|
||||
"uses": assistant.Uses,
|
||||
"search": assistant.Search,
|
||||
}
|
||||
|
||||
for field, value := range jsonFields {
|
||||
|
|
@ -241,7 +243,7 @@ func (store *Xun) UpdateAssistant(assistantID string, updates map[string]interfa
|
|||
data := make(map[string]interface{})
|
||||
|
||||
// List of fields that need JSON marshaling
|
||||
jsonFields := []string{"options", "tags", "modes", "prompts", "prompt_presets", "connector_options", "kb", "db", "mcp", "workflow", "placeholder", "locales", "uses"}
|
||||
jsonFields := []string{"options", "tags", "modes", "prompts", "prompt_presets", "connector_options", "kb", "db", "mcp", "workflow", "placeholder", "locales", "uses", "search"}
|
||||
jsonFieldSet := make(map[string]bool)
|
||||
for _, field := range jsonFields {
|
||||
jsonFieldSet[field] = true
|
||||
|
|
@ -441,7 +443,7 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
|
|||
|
||||
// Convert rows to types.AssistantModel slice
|
||||
assistants := make([]*types.AssistantModel, 0, len(rows))
|
||||
jsonFields := []string{"tags", "options", "prompts", "prompt_presets", "connector_options", "workflow", "kb", "mcp", "placeholder", "locales", "uses"}
|
||||
jsonFields := []string{"tags", "options", "prompts", "prompt_presets", "connector_options", "workflow", "kb", "mcp", "placeholder", "locales", "uses", "search"}
|
||||
|
||||
for _, row := range rows {
|
||||
data := row.ToMap()
|
||||
|
|
@ -514,7 +516,7 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
|
|||
}
|
||||
|
||||
// Parse JSON fields
|
||||
jsonFields := []string{"tags", "modes", "options", "prompts", "prompt_presets", "connector_options", "workflow", "kb", "db", "mcp", "placeholder", "locales", "uses"}
|
||||
jsonFields := []string{"tags", "modes", "options", "prompts", "prompt_presets", "connector_options", "workflow", "kb", "db", "mcp", "placeholder", "locales", "uses", "search"}
|
||||
store.parseJSONFields(data, jsonFields)
|
||||
|
||||
// Convert map to types.AssistantModel
|
||||
|
|
@ -659,6 +661,16 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
|
|||
}
|
||||
}
|
||||
|
||||
if search, has := data["search"]; has && search != nil {
|
||||
raw, err := jsoniter.Marshal(search)
|
||||
if err == nil {
|
||||
var s searchTypes.Config
|
||||
if err := jsoniter.Unmarshal(raw, &s); err == nil {
|
||||
model.Search = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply i18n translation if locale is provided
|
||||
if len(locale) > 0 && locale[0] != "" {
|
||||
store.translate(model, assistantID, locale[0])
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/yaoapp/xun/dbal/query"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
"github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/agent/store/xun"
|
||||
"github.com/yaoapp/yao/config"
|
||||
|
|
@ -452,6 +453,198 @@ func TestSaveAssistant(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("SearchConfiguration", func(t *testing.T) {
|
||||
// Test assistant with Search configuration
|
||||
assistant := &types.AssistantModel{
|
||||
Name: "Search Config Test Assistant",
|
||||
Type: "assistant",
|
||||
Connector: "openai",
|
||||
Share: "private",
|
||||
Search: &searchTypes.Config{
|
||||
Web: &searchTypes.WebConfig{
|
||||
Provider: "tavily",
|
||||
MaxResults: 15,
|
||||
},
|
||||
KB: &searchTypes.KBConfig{
|
||||
Collections: []string{"docs", "faq"},
|
||||
Threshold: 0.8,
|
||||
Graph: true,
|
||||
},
|
||||
DB: &searchTypes.DBConfig{
|
||||
Models: []string{"user", "product"},
|
||||
MaxResults: 50,
|
||||
},
|
||||
Citation: &searchTypes.CitationConfig{
|
||||
Format: "#ref:{id}",
|
||||
AutoInjectPrompt: true,
|
||||
},
|
||||
Weights: &searchTypes.WeightsConfig{
|
||||
User: 1.0,
|
||||
Hook: 0.9,
|
||||
Auto: 0.7,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
id, err := store.SaveAssistant(assistant)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save assistant with search config: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve and verify search configuration - search is NOT in default fields
|
||||
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||
}
|
||||
|
||||
if retrieved.Search == nil {
|
||||
t.Fatal("Expected search to be set")
|
||||
}
|
||||
|
||||
// Verify Web config
|
||||
if retrieved.Search.Web == nil {
|
||||
t.Fatal("Expected search.web to be set")
|
||||
}
|
||||
if retrieved.Search.Web.Provider != "tavily" {
|
||||
t.Errorf("Expected web provider 'tavily', got '%s'", retrieved.Search.Web.Provider)
|
||||
}
|
||||
if retrieved.Search.Web.MaxResults != 15 {
|
||||
t.Errorf("Expected web max_results 15, got %d", retrieved.Search.Web.MaxResults)
|
||||
}
|
||||
|
||||
// Verify KB config
|
||||
if retrieved.Search.KB == nil {
|
||||
t.Fatal("Expected search.kb to be set")
|
||||
}
|
||||
if len(retrieved.Search.KB.Collections) != 2 {
|
||||
t.Errorf("Expected 2 KB collections, got %d", len(retrieved.Search.KB.Collections))
|
||||
}
|
||||
if retrieved.Search.KB.Collections[0] != "docs" {
|
||||
t.Errorf("Expected first collection 'docs', got '%s'", retrieved.Search.KB.Collections[0])
|
||||
}
|
||||
if retrieved.Search.KB.Threshold != 0.8 {
|
||||
t.Errorf("Expected KB threshold 0.8, got %f", retrieved.Search.KB.Threshold)
|
||||
}
|
||||
if !retrieved.Search.KB.Graph {
|
||||
t.Error("Expected KB graph to be true")
|
||||
}
|
||||
|
||||
// Verify DB config
|
||||
if retrieved.Search.DB == nil {
|
||||
t.Fatal("Expected search.db to be set")
|
||||
}
|
||||
if len(retrieved.Search.DB.Models) != 2 {
|
||||
t.Errorf("Expected 2 DB models, got %d", len(retrieved.Search.DB.Models))
|
||||
}
|
||||
if retrieved.Search.DB.MaxResults != 50 {
|
||||
t.Errorf("Expected DB max_results 50, got %d", retrieved.Search.DB.MaxResults)
|
||||
}
|
||||
|
||||
// Verify Citation config
|
||||
if retrieved.Search.Citation == nil {
|
||||
t.Fatal("Expected search.citation to be set")
|
||||
}
|
||||
if retrieved.Search.Citation.Format != "#ref:{id}" {
|
||||
t.Errorf("Expected citation format '#ref:{id}', got '%s'", retrieved.Search.Citation.Format)
|
||||
}
|
||||
if !retrieved.Search.Citation.AutoInjectPrompt {
|
||||
t.Error("Expected citation auto_inject_prompt to be true")
|
||||
}
|
||||
|
||||
// Verify Weights config
|
||||
if retrieved.Search.Weights == nil {
|
||||
t.Fatal("Expected search.weights to be set")
|
||||
}
|
||||
if retrieved.Search.Weights.User != 1.0 {
|
||||
t.Errorf("Expected weights.user 1.0, got %f", retrieved.Search.Weights.User)
|
||||
}
|
||||
if retrieved.Search.Weights.Hook != 0.9 {
|
||||
t.Errorf("Expected weights.hook 0.9, got %f", retrieved.Search.Weights.Hook)
|
||||
}
|
||||
if retrieved.Search.Weights.Auto != 0.7 {
|
||||
t.Errorf("Expected weights.auto 0.7, got %f", retrieved.Search.Weights.Auto)
|
||||
}
|
||||
|
||||
t.Logf("Successfully saved and retrieved assistant with search configuration")
|
||||
})
|
||||
|
||||
t.Run("NilSearchConfiguration", func(t *testing.T) {
|
||||
// Test assistant without Search configuration
|
||||
assistant := &types.AssistantModel{
|
||||
Name: "No Search Config Assistant",
|
||||
Type: "assistant",
|
||||
Connector: "openai",
|
||||
Share: "private",
|
||||
}
|
||||
|
||||
id, err := store.SaveAssistant(assistant)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save assistant without search: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve and verify search is nil - request all fields to check search
|
||||
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||
}
|
||||
|
||||
if retrieved.Search != nil {
|
||||
t.Errorf("Expected search to be nil, got %+v", retrieved.Search)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PartialSearchConfiguration", func(t *testing.T) {
|
||||
// Test assistant with partial Search configuration
|
||||
assistant := &types.AssistantModel{
|
||||
Name: "Partial Search Config Assistant",
|
||||
Type: "assistant",
|
||||
Connector: "openai",
|
||||
Share: "private",
|
||||
Search: &searchTypes.Config{
|
||||
Web: &searchTypes.WebConfig{
|
||||
Provider: "serper",
|
||||
},
|
||||
// KB, DB, Citation, Weights not set
|
||||
},
|
||||
}
|
||||
|
||||
id, err := store.SaveAssistant(assistant)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save assistant with partial search: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve and verify - request all fields for search
|
||||
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||
}
|
||||
|
||||
if retrieved.Search == nil {
|
||||
t.Fatal("Expected search to be set")
|
||||
}
|
||||
|
||||
if retrieved.Search.Web == nil {
|
||||
t.Fatal("Expected search.web to be set")
|
||||
}
|
||||
if retrieved.Search.Web.Provider != "serper" {
|
||||
t.Errorf("Expected web provider 'serper', got '%s'", retrieved.Search.Web.Provider)
|
||||
}
|
||||
|
||||
// Other fields should be nil
|
||||
if retrieved.Search.KB != nil {
|
||||
t.Errorf("Expected search.kb to be nil, got %+v", retrieved.Search.KB)
|
||||
}
|
||||
if retrieved.Search.DB != nil {
|
||||
t.Errorf("Expected search.db to be nil, got %+v", retrieved.Search.DB)
|
||||
}
|
||||
if retrieved.Search.Citation != nil {
|
||||
t.Errorf("Expected search.citation to be nil, got %+v", retrieved.Search.Citation)
|
||||
}
|
||||
if retrieved.Search.Weights != nil {
|
||||
t.Errorf("Expected search.weights to be nil, got %+v", retrieved.Search.Weights)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ConnectorOptions", func(t *testing.T) {
|
||||
// Test assistant with connector options
|
||||
optionalTrue := true
|
||||
|
|
@ -2725,6 +2918,128 @@ func TestUpdateAssistant(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("UpdateSearch", func(t *testing.T) {
|
||||
// Create assistant without search
|
||||
assistant := &types.AssistantModel{
|
||||
Name: "Search Update Test",
|
||||
Type: "assistant",
|
||||
Connector: "openai",
|
||||
Share: "private",
|
||||
}
|
||||
|
||||
id, err := store.SaveAssistant(assistant)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create assistant: %v", err)
|
||||
}
|
||||
|
||||
// Update with search configuration
|
||||
updates := map[string]interface{}{
|
||||
"search": &searchTypes.Config{
|
||||
Web: &searchTypes.WebConfig{
|
||||
Provider: "tavily",
|
||||
MaxResults: 20,
|
||||
},
|
||||
KB: &searchTypes.KBConfig{
|
||||
Collections: []string{"knowledge"},
|
||||
Threshold: 0.75,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = store.UpdateAssistant(id, updates)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update search: %v", err)
|
||||
}
|
||||
|
||||
// Verify updates - search is NOT in default fields
|
||||
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||
}
|
||||
|
||||
if retrieved.Search == nil {
|
||||
t.Fatal("Expected search to be set")
|
||||
}
|
||||
|
||||
if retrieved.Search.Web == nil {
|
||||
t.Fatal("Expected search.web to be set")
|
||||
}
|
||||
if retrieved.Search.Web.Provider != "tavily" {
|
||||
t.Errorf("Expected web provider 'tavily', got '%s'", retrieved.Search.Web.Provider)
|
||||
}
|
||||
if retrieved.Search.Web.MaxResults != 20 {
|
||||
t.Errorf("Expected web max_results 20, got %d", retrieved.Search.Web.MaxResults)
|
||||
}
|
||||
|
||||
if retrieved.Search.KB == nil {
|
||||
t.Fatal("Expected search.kb to be set")
|
||||
}
|
||||
if len(retrieved.Search.KB.Collections) != 1 {
|
||||
t.Errorf("Expected 1 KB collection, got %d", len(retrieved.Search.KB.Collections))
|
||||
}
|
||||
if retrieved.Search.KB.Threshold != 0.75 {
|
||||
t.Errorf("Expected KB threshold 0.75, got %f", retrieved.Search.KB.Threshold)
|
||||
}
|
||||
|
||||
// Update to change search configuration
|
||||
updates2 := map[string]interface{}{
|
||||
"search": &searchTypes.Config{
|
||||
Web: &searchTypes.WebConfig{
|
||||
Provider: "serper",
|
||||
MaxResults: 30,
|
||||
},
|
||||
Citation: &searchTypes.CitationConfig{
|
||||
Format: "#cite:{id}",
|
||||
AutoInjectPrompt: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = store.UpdateAssistant(id, updates2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update search again: %v", err)
|
||||
}
|
||||
|
||||
// Verify second update - search is NOT in default fields
|
||||
retrieved2, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||
}
|
||||
|
||||
if retrieved2.Search.Web.Provider != "serper" {
|
||||
t.Errorf("Expected web provider 'serper', got '%s'", retrieved2.Search.Web.Provider)
|
||||
}
|
||||
if retrieved2.Search.Web.MaxResults != 30 {
|
||||
t.Errorf("Expected web max_results 30, got %d", retrieved2.Search.Web.MaxResults)
|
||||
}
|
||||
if retrieved2.Search.Citation == nil {
|
||||
t.Fatal("Expected search.citation to be set")
|
||||
}
|
||||
if retrieved2.Search.Citation.Format != "#cite:{id}" {
|
||||
t.Errorf("Expected citation format '#cite:{id}', got '%s'", retrieved2.Search.Citation.Format)
|
||||
}
|
||||
|
||||
// Update to remove search (set to nil)
|
||||
updates3 := map[string]interface{}{
|
||||
"search": nil,
|
||||
}
|
||||
|
||||
err = store.UpdateAssistant(id, updates3)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set search to nil: %v", err)
|
||||
}
|
||||
|
||||
// Verify search is nil - search is NOT in default fields
|
||||
retrieved3, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||
}
|
||||
|
||||
if retrieved3.Search != nil {
|
||||
t.Errorf("Expected search to be nil, got %+v", retrieved3.Search)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UpdatePermissionFields", func(t *testing.T) {
|
||||
// Create assistant with permission fields
|
||||
assistant := &types.AssistantModel{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package types
|
|||
import (
|
||||
"github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
)
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ type DSL struct {
|
|||
// ===============================
|
||||
Models map[string]openai.Capabilities `json:"models,omitempty" yaml:"models,omitempty"` // The model capabilities configuration
|
||||
KB *store.KBSetting `json:"kb,omitempty" yaml:"kb,omitempty"` // The knowledge base configuration loaded from agent/kb.yml
|
||||
Search *searchTypes.Config `json:"search,omitempty" yaml:"search,omitempty"` // The search configuration loaded from agent/search.yao
|
||||
|
||||
// Internal
|
||||
// ===============================
|
||||
|
|
@ -38,6 +40,12 @@ type Uses struct {
|
|||
Audio string `json:"audio,omitempty" yaml:"audio,omitempty"` // The assistant for processing audio (speech-to-text, text-to-speech). If the model doesn't support audio, use this to convert audio to text. Format: "agent" or "mcp:mcp_server_id"
|
||||
Search string `json:"search,omitempty" yaml:"search,omitempty"` // The assistant for searching the knowledge, global web search. If not set, and the assistant enable the knowledge, it will search the result from the knowledge automatically.
|
||||
Fetch string `json:"fetch,omitempty" yaml:"fetch,omitempty"` // The assistant for fetching the http/https/ftp/sftp/etc. file, and return the file's content. if not set, use the http process to fetch the file.
|
||||
|
||||
// Search-related processing tools (NLP)
|
||||
Web string `json:"web,omitempty" yaml:"web,omitempty"` // Web search handler: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Keyword extraction: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // QueryDSL generation: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
Rerank string `json:"rerank,omitempty" yaml:"rerank,omitempty"` // Result reranking: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||
}
|
||||
|
||||
// Mention Structure
|
||||
|
|
|
|||
286
data/bindata.go
286
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -235,6 +235,13 @@
|
|||
"comment": "Assistant-specific wrapper configurations for vision, audio, etc. If not set, use global settings",
|
||||
"nullable": true
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"type": "json",
|
||||
"label": "Search",
|
||||
"comment": "Search configuration (web, kb, db, citation, weights, etc.)",
|
||||
"nullable": true
|
||||
},
|
||||
{
|
||||
"name": "automated",
|
||||
"type": "boolean",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue