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
|
*-unit-test
|
||||||
docker/build/test
|
docker/build/test
|
||||||
db
|
db
|
||||||
|
!agent/search/handlers/db
|
||||||
*.sh
|
*.sh
|
||||||
data/bindata.go.bak
|
data/bindata.go.bak
|
||||||
share/const.go.bak
|
share/const.go.bak
|
||||||
|
|
@ -49,3 +50,4 @@ share/const.goe
|
||||||
openapi/*.md
|
openapi/*.md
|
||||||
coverage.html
|
coverage.html
|
||||||
agent/assistant/hook/*.test.md
|
agent/assistant/hook/*.test.md
|
||||||
|
agent/search/TODO.md
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"github.com/yaoapp/yao/agent/content"
|
"github.com/yaoapp/yao/agent/content"
|
||||||
agentContext "github.com/yaoapp/yao/agent/context"
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||||
store "github.com/yaoapp/yao/agent/store/types"
|
store "github.com/yaoapp/yao/agent/store/types"
|
||||||
sui "github.com/yaoapp/yao/sui/core"
|
sui "github.com/yaoapp/yao/sui/core"
|
||||||
)
|
)
|
||||||
|
|
@ -115,6 +116,8 @@ func (ast *Assistant) Map() map[string]interface{} {
|
||||||
"automated": ast.Automated,
|
"automated": ast.Automated,
|
||||||
"placeholder": ast.Placeholder,
|
"placeholder": ast.Placeholder,
|
||||||
"locales": ast.Locales,
|
"locales": ast.Locales,
|
||||||
|
"uses": ast.Uses,
|
||||||
|
"search": ast.Search,
|
||||||
"created_at": store.ToMySQLTime(ast.CreatedAt),
|
"created_at": store.ToMySQLTime(ast.CreatedAt),
|
||||||
"updated_at": store.ToMySQLTime(ast.UpdatedAt),
|
"updated_at": store.ToMySQLTime(ast.UpdatedAt),
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +186,6 @@ func (ast *Assistant) Clone() *Assistant {
|
||||||
CreatedAt: ast.CreatedAt,
|
CreatedAt: ast.CreatedAt,
|
||||||
UpdatedAt: ast.UpdatedAt,
|
UpdatedAt: ast.UpdatedAt,
|
||||||
},
|
},
|
||||||
Search: ast.Search,
|
|
||||||
HookScript: ast.HookScript,
|
HookScript: ast.HookScript,
|
||||||
openai: ast.openai,
|
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
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -512,5 +594,29 @@ func (ast *Assistant) Update(data map[string]interface{}) error {
|
||||||
ast.Workflow = workflow
|
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()
|
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
|
// getUses get the Uses configuration with priority: assistant.Uses > global settings
|
||||||
// Note: createResponse.Uses (applied in applyCreateResponseOptions) has even higher priority
|
// 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 {
|
func (ast *Assistant) getUses() *context.Uses {
|
||||||
// Priority 1: Assistant-specific Uses configuration
|
return ast.Uses
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyMCPTools adds MCP tools to completion options and returns samples prompt
|
// applyMCPTools adds MCP tools to completion options and returns samples prompt
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/yaoapp/gou/fs"
|
"github.com/yaoapp/gou/fs"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||||
store "github.com/yaoapp/yao/agent/store/types"
|
store "github.com/yaoapp/yao/agent/store/types"
|
||||||
"github.com/yaoapp/yao/openai"
|
"github.com/yaoapp/yao/openai"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -22,12 +23,12 @@ import (
|
||||||
var loaded = NewCache(200) // 200 is the default capacity
|
var loaded = NewCache(200) // 200 is the default capacity
|
||||||
var storage store.Store = nil
|
var storage store.Store = nil
|
||||||
var storeSetting *store.Setting = nil // store setting from agent.yml
|
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 modelCapabilities map[string]gouOpenAI.Capabilities = map[string]gouOpenAI.Capabilities{}
|
||||||
var defaultConnector string = "" // default connector
|
var defaultConnector string = "" // default connector
|
||||||
var globalUses *context.Uses = nil // global uses configuration from agent.yml
|
var globalUses *context.Uses = nil // global uses configuration from agent.yml
|
||||||
var globalPrompts []store.Prompt = nil // global prompts from agent/prompts.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 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
|
// LoadBuiltIn load the built-in assistants
|
||||||
func LoadBuiltIn() error {
|
func LoadBuiltIn() error {
|
||||||
|
|
@ -183,6 +184,16 @@ func GetGlobalKBSetting() *store.KBSetting {
|
||||||
return globalKBSetting
|
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
|
// SetCache set the cache
|
||||||
func SetCache(capacity int) {
|
func SetCache(capacity int) {
|
||||||
ClearCache()
|
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 {
|
if v, ok := data["search"].(map[string]interface{}); ok {
|
||||||
assistant.Search = &SearchOption{}
|
var assistantSearch searchTypes.Config
|
||||||
raw, err := jsoniter.Marshal(v)
|
raw, err := jsoniter.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
err = jsoniter.Unmarshal(raw, &assistantSearch)
|
||||||
// Unmarshal the raw data
|
|
||||||
err = jsoniter.Unmarshal(raw, assistant.Search)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// prompts
|
||||||
|
|
@ -681,12 +697,14 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// uses (wrapper configurations for vision, audio, etc.)
|
// uses (wrapper configurations for vision, audio, etc.)
|
||||||
|
// Merge hierarchy: global uses < assistant uses
|
||||||
if uses, has := data["uses"]; has {
|
if uses, has := data["uses"]; has {
|
||||||
|
var assistantUses *context.Uses
|
||||||
switch v := uses.(type) {
|
switch v := uses.(type) {
|
||||||
case *context.Uses:
|
case *context.Uses:
|
||||||
assistant.Uses = v
|
assistantUses = v
|
||||||
case context.Uses:
|
case context.Uses:
|
||||||
assistant.Uses = &v
|
assistantUses = &v
|
||||||
default:
|
default:
|
||||||
raw, err := jsoniter.Marshal(v)
|
raw, err := jsoniter.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -697,8 +715,13 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
// Load scripts (hook script and other scripts)
|
||||||
|
|
@ -761,3 +784,204 @@ func (ast *Assistant) initialize() error {
|
||||||
|
|
||||||
return nil
|
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/stretchr/testify/require"
|
||||||
"github.com/yaoapp/yao/agent/assistant"
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||||
store "github.com/yaoapp/yao/agent/store/types"
|
store "github.com/yaoapp/yao/agent/store/types"
|
||||||
"github.com/yaoapp/yao/agent/testutils"
|
"github.com/yaoapp/yao/agent/testutils"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
|
@ -933,3 +934,246 @@ function Create(ctx: any, messages: any[]): any {
|
||||||
assert.False(t, *res.DisableGlobalPrompts)
|
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
|
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
|
// Script the script scripts except hook script
|
||||||
type Script struct {
|
type Script struct {
|
||||||
*v8.Script
|
*v8.Script
|
||||||
|
|
@ -35,16 +29,13 @@ type Script struct {
|
||||||
// Assistant the assistant
|
// Assistant the assistant
|
||||||
type Assistant struct {
|
type Assistant struct {
|
||||||
store.AssistantModel
|
store.AssistantModel
|
||||||
Search *SearchOption `json:"search,omitempty" yaml:"search,omitempty"` // Whether this assistant supports search
|
HookScript *hook.Script `json:"-" yaml:"-"` // Hook Script (index.ts)
|
||||||
HookScript *hook.Script `json:"-" yaml:"-"` // Hook Script (index.ts)
|
Scripts map[string]*Script `json:"-" yaml:"-"` // Other scripts
|
||||||
Scripts map[string]*Script `json:"-" yaml:"-"` // Other scripts
|
|
||||||
|
|
||||||
// Internal
|
// Internal
|
||||||
// ===============================
|
// ===============================
|
||||||
openai *api.OpenAI // OpenAI API
|
openai *api.OpenAI // OpenAI API
|
||||||
search bool // Whether this assistant supports search
|
|
||||||
vision bool // Whether this assistant supports vision
|
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
|
// MCPTool represents a simplified MCP tool for building LLM requests
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,14 @@ import (
|
||||||
type Uses struct {
|
type Uses struct {
|
||||||
Vision string `json:"vision,omitempty"` // Vision processing tool. Format: "agent" or "mcp:server_id"
|
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"
|
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"
|
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
|
// 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/assistant"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"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"
|
storeMongo "github.com/yaoapp/yao/agent/store/mongo"
|
||||||
storeRedis "github.com/yaoapp/yao/agent/store/redis"
|
storeRedis "github.com/yaoapp/yao/agent/store/redis"
|
||||||
store "github.com/yaoapp/yao/agent/store/types"
|
store "github.com/yaoapp/yao/agent/store/types"
|
||||||
|
|
@ -92,6 +94,12 @@ func Load(cfg config.Config) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize Search Configuration
|
||||||
|
err = initSearchConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Assistant
|
// Initialize Assistant
|
||||||
err = initAssistant()
|
err = initAssistant()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -222,6 +230,10 @@ func initAssistant() error {
|
||||||
assistant.SetGlobalKBSetting(agentDSL.KB)
|
assistant.SetGlobalKBSetting(agentDSL.KB)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if agentDSL.Search != nil {
|
||||||
|
assistant.SetGlobalSearchConfig(agentDSL.Search)
|
||||||
|
}
|
||||||
|
|
||||||
// Load Built-in Assistants
|
// Load Built-in Assistants
|
||||||
err := assistant.LoadBuiltIn()
|
err := assistant.LoadBuiltIn()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -261,6 +273,177 @@ func initKBConfig() error {
|
||||||
return nil
|
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
|
// defaultAssistant get the default assistant
|
||||||
func defaultAssistant() (*assistant.Assistant, error) {
|
func defaultAssistant() (*assistant.Assistant, error) {
|
||||||
if agentDSL.Uses == nil || agentDSL.Uses.Default == "" {
|
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, "__yao.utf8", agent.KB.Chat.DocumentDefaults.Converter.ProviderID)
|
||||||
assert.Equal(t, "standard-text", agent.KB.Chat.DocumentDefaults.Converter.OptionID)
|
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) {
|
func TestGetGlobalPrompts(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -1089,7 +1089,7 @@ function Create(ctx, messages, options) {
|
||||||
Configuration follows a three-layer hierarchy (later overrides earlier):
|
Configuration follows a three-layer hierarchy (later overrides earlier):
|
||||||
|
|
||||||
1. **System Built-in Defaults** - Hardcoded sensible defaults
|
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)
|
3. **Assistant Configuration** - `assistants/<assistant-id>/package.yao` (uses + search options)
|
||||||
|
|
||||||
### Uses Configuration
|
### Uses Configuration
|
||||||
|
|
@ -1158,7 +1158,7 @@ package defaults
|
||||||
import "github.com/yaoapp/yao/agent/search/types"
|
import "github.com/yaoapp/yao/agent/search/types"
|
||||||
|
|
||||||
// SystemDefaults provides hardcoded default values
|
// 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{
|
var SystemDefaults = &types.Config{
|
||||||
// Web search defaults
|
// Web search defaults
|
||||||
Web: &types.WebConfig{
|
Web: &types.WebConfig{
|
||||||
|
|
@ -1251,12 +1251,12 @@ import (
|
||||||
|
|
||||||
var searchConfig *searchTypes.Config
|
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 {
|
func initSearchConfig() error {
|
||||||
// Start with system defaults
|
// Start with system defaults
|
||||||
searchConfig = searchDefaults.SystemDefaults
|
searchConfig = searchDefaults.SystemDefaults
|
||||||
|
|
||||||
path := filepath.Join("agent", "search.yao")
|
path := filepath.Join("agent", "search.yml")
|
||||||
if exists, _ := application.App.Exists(path); !exists {
|
if exists, _ := application.App.Exists(path); !exists {
|
||||||
return nil // Use defaults
|
return nil // Use defaults
|
||||||
}
|
}
|
||||||
|
|
@ -1268,7 +1268,7 @@ func initSearchConfig() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg searchTypes.Config
|
var cfg searchTypes.Config
|
||||||
err = application.Parse("search.yao", bytes, &cfg)
|
err = application.Parse("search.yml", bytes, &cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1304,62 +1304,54 @@ func (ast *Assistant) GetMergedSearchConfig() *searchTypes.Config {
|
||||||
|
|
||||||
### Global Configuration
|
### Global Configuration
|
||||||
|
|
||||||
`agent/search.yao` - Override system defaults for all assistants:
|
`agent/search.yml` - Override system defaults for all assistants:
|
||||||
|
|
||||||
```jsonc
|
```yaml
|
||||||
{
|
# Global Search Configuration
|
||||||
// Web search settings
|
# These settings apply to all assistants unless overridden by assistant-specific configurations.
|
||||||
"web": {
|
|
||||||
"provider": "tavily", // "tavily", "serper" (builtin providers only)
|
|
||||||
"api_key_env": "TAVILY_API_KEY",
|
|
||||||
"max_results": 10
|
|
||||||
},
|
|
||||||
|
|
||||||
// Knowledge base search settings
|
# Web search settings
|
||||||
"kb": {
|
web:
|
||||||
"threshold": 0.7, // Similarity threshold
|
provider: "tavily" # "tavily", "serper" (builtin providers only)
|
||||||
"graph": false // Enable GraphRAG association
|
api_key_env: "TAVILY_API_KEY"
|
||||||
},
|
max_results: 10
|
||||||
|
|
||||||
// Database search settings
|
# Knowledge base search settings
|
||||||
"db": {
|
kb:
|
||||||
"max_results": 20
|
threshold: 0.7 # Similarity threshold
|
||||||
},
|
graph: false # Enable GraphRAG association
|
||||||
|
|
||||||
// Keyword extraction options (uses.keyword)
|
# Database search settings
|
||||||
"keyword": {
|
db:
|
||||||
"max_keywords": 10,
|
max_results: 20
|
||||||
"language": "auto" // "auto", "en", "zh", etc.
|
|
||||||
},
|
|
||||||
|
|
||||||
// QueryDSL generation options (uses.querydsl)
|
# Keyword extraction options (uses.keyword)
|
||||||
"querydsl": {
|
keyword:
|
||||||
"strict": false // Strict mode: fail if generation fails
|
max_keywords: 10
|
||||||
},
|
language: "auto" # "auto", "en", "zh", etc.
|
||||||
|
|
||||||
// Rerank options (uses.rerank)
|
# QueryDSL generation options (uses.querydsl)
|
||||||
"rerank": {
|
querydsl:
|
||||||
"top_n": 10 // Return top N results after reranking
|
strict: false # Strict mode: fail if generation fails
|
||||||
},
|
|
||||||
|
|
||||||
// Citation format for LLM references
|
# Rerank options (uses.rerank)
|
||||||
"citation": {
|
rerank:
|
||||||
"format": "#ref:{id}",
|
top_n: 10 # Return top N results after reranking
|
||||||
"auto_inject_prompt": true // Auto-inject citation instructions to system prompt
|
|
||||||
},
|
|
||||||
|
|
||||||
// Source weighting for result merging
|
# Citation format for LLM references
|
||||||
"weights": {
|
citation:
|
||||||
"user": 1.0, // User-provided DataContent (highest priority)
|
format: "#ref:{id}"
|
||||||
"hook": 0.8, // Hook ctx.search.*() results
|
auto_inject_prompt: true # Auto-inject citation instructions to system prompt
|
||||||
"auto": 0.6 // Auto search results
|
|
||||||
},
|
|
||||||
|
|
||||||
// Search behavior options
|
# Source weighting for result merging
|
||||||
"options": {
|
weights:
|
||||||
"skip_threshold": 5 // Skip auto search if user provides >= N results
|
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
|
### Assistant Configuration
|
||||||
|
|
@ -1380,7 +1372,7 @@ func (ast *Assistant) GetMergedSearchConfig() *searchTypes.Config {
|
||||||
"rerank": "mcp:my-server.rerank" // Use MCP tool for reranking
|
"rerank": "mcp:my-server.rerank" // Use MCP tool for reranking
|
||||||
},
|
},
|
||||||
|
|
||||||
// Search configuration (overrides agent/search.yao)
|
// Search configuration (overrides agent/search.yml)
|
||||||
"search": {
|
"search": {
|
||||||
// Overrides global web settings
|
// Overrides global web settings
|
||||||
"web": {
|
"web": {
|
||||||
|
|
@ -2017,7 +2009,7 @@ if (result.error) {
|
||||||
Configuration is merged with later layers overriding earlier ones:
|
Configuration is merged with later layers overriding earlier ones:
|
||||||
|
|
||||||
1. **System Built-in** - Hardcoded defaults (lowest priority)
|
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)
|
3. **Assistant-level** - `assistants/<assistant-id>/package.yao` (uses + search)
|
||||||
4. **Hook-level** - CreateHook return `uses.search` value
|
4. **Hook-level** - CreateHook return `uses.search` value
|
||||||
5. **Request-level** - `options.uses.search` in Stream() call (highest priority)
|
5. **Request-level** - `options.uses.search` in Stream() call (highest priority)
|
||||||
|
|
@ -2301,19 +2293,15 @@ Stream()
|
||||||
|
|
||||||
**Configuration:**
|
**Configuration:**
|
||||||
|
|
||||||
Global defaults (`agent/search.yao`):
|
Global defaults (`agent/search.yml`):
|
||||||
|
|
||||||
```jsonc
|
```yaml
|
||||||
{
|
weights:
|
||||||
"weights": {
|
user: 1.0 # User-provided DataContent
|
||||||
"user": 1.0, // User-provided DataContent
|
hook: 0.8 # Hook ctx.search.*() results
|
||||||
"hook": 0.8, // Hook ctx.search.*() results
|
auto: 0.6 # Auto search results
|
||||||
"auto": 0.6 // Auto search results
|
options:
|
||||||
},
|
skip_threshold: 5 # Skip auto search if user provides >= N results
|
||||||
"options": {
|
|
||||||
"skip_threshold": 5 // Skip auto search if user provides >= N results
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Assistant-level override (`assistants/<assistant-id>/package.yao`):
|
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/spf13/cast"
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToKnowledgeBase converts various types to KnowledgeBase
|
// ToKnowledgeBase converts various types to KnowledgeBase
|
||||||
|
|
@ -616,3 +618,59 @@ func ToPromptPresets(v interface{}) (map[string][]Prompt, error) {
|
||||||
return result, nil
|
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,
|
"share": true,
|
||||||
"locales": true,
|
"locales": true,
|
||||||
"uses": true,
|
"uses": true,
|
||||||
|
"search": true,
|
||||||
"automated": true,
|
"automated": true,
|
||||||
"mentionable": true,
|
"mentionable": true,
|
||||||
"created_at": true,
|
"created_at": true,
|
||||||
|
|
@ -104,6 +105,7 @@ var AssistantFullFields = []string{
|
||||||
"share",
|
"share",
|
||||||
"locales",
|
"locales",
|
||||||
"uses",
|
"uses",
|
||||||
|
"search",
|
||||||
"automated",
|
"automated",
|
||||||
"mentionable",
|
"mentionable",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/yaoapp/xun/dbal/query"
|
"github.com/yaoapp/xun/dbal/query"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Setting represents the conversation configuration structure
|
// Setting represents the conversation configuration structure
|
||||||
|
|
@ -436,6 +437,7 @@ type AssistantModel struct {
|
||||||
Source string `json:"source,omitempty"` // Hook script source code
|
Source string `json:"source,omitempty"` // Hook script source code
|
||||||
Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales
|
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
|
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
|
CreatedAt int64 `json:"created_at"` // Creation timestamp
|
||||||
UpdatedAt int64 `json:"updated_at"` // Last update timestamp
|
UpdatedAt int64 `json:"updated_at"` // Last update timestamp
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/yaoapp/xun/dbal/query"
|
"github.com/yaoapp/xun/dbal/query"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"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/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -183,6 +184,7 @@ func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error)
|
||||||
"placeholder": assistant.Placeholder,
|
"placeholder": assistant.Placeholder,
|
||||||
"locales": assistant.Locales,
|
"locales": assistant.Locales,
|
||||||
"uses": assistant.Uses,
|
"uses": assistant.Uses,
|
||||||
|
"search": assistant.Search,
|
||||||
}
|
}
|
||||||
|
|
||||||
for field, value := range jsonFields {
|
for field, value := range jsonFields {
|
||||||
|
|
@ -241,7 +243,7 @@ func (store *Xun) UpdateAssistant(assistantID string, updates map[string]interfa
|
||||||
data := make(map[string]interface{})
|
data := make(map[string]interface{})
|
||||||
|
|
||||||
// List of fields that need JSON marshaling
|
// 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)
|
jsonFieldSet := make(map[string]bool)
|
||||||
for _, field := range jsonFields {
|
for _, field := range jsonFields {
|
||||||
jsonFieldSet[field] = true
|
jsonFieldSet[field] = true
|
||||||
|
|
@ -441,7 +443,7 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
|
||||||
|
|
||||||
// Convert rows to types.AssistantModel slice
|
// Convert rows to types.AssistantModel slice
|
||||||
assistants := make([]*types.AssistantModel, 0, len(rows))
|
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 {
|
for _, row := range rows {
|
||||||
data := row.ToMap()
|
data := row.ToMap()
|
||||||
|
|
@ -514,7 +516,7 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON fields
|
// 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)
|
store.parseJSONFields(data, jsonFields)
|
||||||
|
|
||||||
// Convert map to types.AssistantModel
|
// 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
|
// Apply i18n translation if locale is provided
|
||||||
if len(locale) > 0 && locale[0] != "" {
|
if len(locale) > 0 && locale[0] != "" {
|
||||||
store.translate(model, assistantID, locale[0])
|
store.translate(model, assistantID, locale[0])
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/yaoapp/xun/dbal/query"
|
"github.com/yaoapp/xun/dbal/query"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"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/types"
|
||||||
"github.com/yaoapp/yao/agent/store/xun"
|
"github.com/yaoapp/yao/agent/store/xun"
|
||||||
"github.com/yaoapp/yao/config"
|
"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) {
|
t.Run("ConnectorOptions", func(t *testing.T) {
|
||||||
// Test assistant with connector options
|
// Test assistant with connector options
|
||||||
optionalTrue := true
|
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) {
|
t.Run("UpdatePermissionFields", func(t *testing.T) {
|
||||||
// Create assistant with permission fields
|
// Create assistant with permission fields
|
||||||
assistant := &types.AssistantModel{
|
assistant := &types.AssistantModel{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package types
|
||||||
import (
|
import (
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
"github.com/yaoapp/gou/connector/openai"
|
||||||
"github.com/yaoapp/yao/agent/assistant"
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||||
store "github.com/yaoapp/yao/agent/store/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
|
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
|
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
|
// 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"
|
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.
|
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.
|
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
|
// 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",
|
"comment": "Assistant-specific wrapper configurations for vision, audio, etc. If not set, use global settings",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "search",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Search",
|
||||||
|
"comment": "Search configuration (web, kb, db, citation, weights, etc.)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "automated",
|
"name": "automated",
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue