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:
Max 2025-12-13 11:19:20 +08:00
parent f7af652aab
commit d264c7a784
35 changed files with 2599 additions and 271 deletions

2
.gitignore vendored
View file

@ -41,6 +41,7 @@ xgen/v1.0/*
*-unit-test
docker/build/test
db
!agent/search/handlers/db
*.sh
data/bindata.go.bak
share/const.go.bak
@ -49,3 +50,4 @@ share/const.goe
openapi/*.md
coverage.html
agent/assistant/hook/*.test.md
agent/search/TODO.md

View file

@ -8,6 +8,7 @@ import (
"github.com/yaoapp/yao/agent/content"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
searchTypes "github.com/yaoapp/yao/agent/search/types"
store "github.com/yaoapp/yao/agent/store/types"
sui "github.com/yaoapp/yao/sui/core"
)
@ -115,6 +116,8 @@ func (ast *Assistant) Map() map[string]interface{} {
"automated": ast.Automated,
"placeholder": ast.Placeholder,
"locales": ast.Locales,
"uses": ast.Uses,
"search": ast.Search,
"created_at": store.ToMySQLTime(ast.CreatedAt),
"updated_at": store.ToMySQLTime(ast.UpdatedAt),
}
@ -183,7 +186,6 @@ func (ast *Assistant) Clone() *Assistant {
CreatedAt: ast.CreatedAt,
UpdatedAt: ast.UpdatedAt,
},
Search: ast.Search,
HookScript: ast.HookScript,
openai: ast.openai,
}
@ -346,6 +348,86 @@ func (ast *Assistant) Clone() *Assistant {
}
}
// Deep copy uses
if ast.Uses != nil {
clone.Uses = &agentContext.Uses{
Vision: ast.Uses.Vision,
Audio: ast.Uses.Audio,
Search: ast.Uses.Search,
Fetch: ast.Uses.Fetch,
Web: ast.Uses.Web,
Keyword: ast.Uses.Keyword,
QueryDSL: ast.Uses.QueryDSL,
Rerank: ast.Uses.Rerank,
}
}
// Deep copy search config
if ast.Search != nil {
clone.Search = &searchTypes.Config{}
if ast.Search.Web != nil {
clone.Search.Web = &searchTypes.WebConfig{
Provider: ast.Search.Web.Provider,
APIKeyEnv: ast.Search.Web.APIKeyEnv,
MaxResults: ast.Search.Web.MaxResults,
}
}
if ast.Search.KB != nil {
clone.Search.KB = &searchTypes.KBConfig{
Threshold: ast.Search.KB.Threshold,
Graph: ast.Search.KB.Graph,
}
if ast.Search.KB.Collections != nil {
clone.Search.KB.Collections = make([]string, len(ast.Search.KB.Collections))
copy(clone.Search.KB.Collections, ast.Search.KB.Collections)
}
}
if ast.Search.DB != nil {
clone.Search.DB = &searchTypes.DBConfig{
MaxResults: ast.Search.DB.MaxResults,
}
if ast.Search.DB.Models != nil {
clone.Search.DB.Models = make([]string, len(ast.Search.DB.Models))
copy(clone.Search.DB.Models, ast.Search.DB.Models)
}
}
if ast.Search.Keyword != nil {
clone.Search.Keyword = &searchTypes.KeywordConfig{
MaxKeywords: ast.Search.Keyword.MaxKeywords,
Language: ast.Search.Keyword.Language,
}
}
if ast.Search.QueryDSL != nil {
clone.Search.QueryDSL = &searchTypes.QueryDSLConfig{
Strict: ast.Search.QueryDSL.Strict,
}
}
if ast.Search.Rerank != nil {
clone.Search.Rerank = &searchTypes.RerankConfig{
TopN: ast.Search.Rerank.TopN,
}
}
if ast.Search.Citation != nil {
clone.Search.Citation = &searchTypes.CitationConfig{
Format: ast.Search.Citation.Format,
AutoInjectPrompt: ast.Search.Citation.AutoInjectPrompt,
CustomPrompt: ast.Search.Citation.CustomPrompt,
}
}
if ast.Search.Weights != nil {
clone.Search.Weights = &searchTypes.WeightsConfig{
User: ast.Search.Weights.User,
Hook: ast.Search.Weights.Hook,
Auto: ast.Search.Weights.Auto,
}
}
if ast.Search.Options != nil {
clone.Search.Options = &searchTypes.OptionsConfig{
SkipThreshold: ast.Search.Options.SkipThreshold,
}
}
}
return clone
}
@ -512,5 +594,29 @@ func (ast *Assistant) Update(data map[string]interface{}) error {
ast.Workflow = workflow
}
// Uses
if v, has := data["uses"]; has {
uses, err := store.ToUses(v)
if err != nil {
return err
}
ast.Uses = uses
}
// Search
if v, has := data["search"]; has {
search, err := store.ToSearchConfig(v)
if err != nil {
return err
}
ast.Search = search
}
return ast.Validate()
}
// GetMergedSearchConfig returns the search config for this assistant
// Note: The config is already merged with global config during loading (loadMap)
func (ast *Assistant) GetMergedSearchConfig() *searchTypes.Config {
return ast.Search
}

View file

@ -532,40 +532,10 @@ func (ast *Assistant) applyCreateResponseOptions(options *context.CompletionOpti
// getUses get the Uses configuration with priority: assistant.Uses > global settings
// Note: createResponse.Uses (applied in applyCreateResponseOptions) has even higher priority
// Final priority order: createResponse.Uses > assistant.Uses > global settings
// getUses returns the Uses config for this assistant
// Note: The config is already merged with global config during loading (loadMap)
func (ast *Assistant) getUses() *context.Uses {
// Priority 1: Assistant-specific Uses configuration
if ast.Uses != nil {
// Create a merged Uses by starting with global, then override with assistant-specific
merged := &context.Uses{}
// Start with global settings
if globalUses != nil {
merged.Vision = globalUses.Vision
merged.Audio = globalUses.Audio
merged.Search = globalUses.Search
merged.Fetch = globalUses.Fetch
}
// Override with assistant-specific settings (only if not empty)
if ast.Uses.Vision != "" {
merged.Vision = ast.Uses.Vision
}
if ast.Uses.Audio != "" {
merged.Audio = ast.Uses.Audio
}
if ast.Uses.Search != "" {
merged.Search = ast.Uses.Search
}
if ast.Uses.Fetch != "" {
merged.Fetch = ast.Uses.Fetch
}
return merged
}
// Priority 2: Global settings only
return globalUses
return ast.Uses
}
// applyMCPTools adds MCP tools to completion options and returns samples prompt

View file

@ -13,6 +13,7 @@ import (
"github.com/yaoapp/gou/fs"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
searchTypes "github.com/yaoapp/yao/agent/search/types"
store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/openai"
"gopkg.in/yaml.v3"
@ -22,12 +23,12 @@ import (
var loaded = NewCache(200) // 200 is the default capacity
var storage store.Store = nil
var storeSetting *store.Setting = nil // store setting from agent.yml
var search interface{} = nil
var modelCapabilities map[string]gouOpenAI.Capabilities = map[string]gouOpenAI.Capabilities{}
var defaultConnector string = "" // default connector
var globalUses *context.Uses = nil // global uses configuration from agent.yml
var globalPrompts []store.Prompt = nil // global prompts from agent/prompts.yml
var globalKBSetting *store.KBSetting = nil // global KB setting from agent/kb.yml
var defaultConnector string = "" // default connector
var globalUses *context.Uses = nil // global uses configuration from agent.yml
var globalPrompts []store.Prompt = nil // global prompts from agent/prompts.yml
var globalKBSetting *store.KBSetting = nil // global KB setting from agent/kb.yml
var globalSearchConfig *searchTypes.Config = nil // global search config from agent/search.yml
// LoadBuiltIn load the built-in assistants
func LoadBuiltIn() error {
@ -183,6 +184,16 @@ func GetGlobalKBSetting() *store.KBSetting {
return globalKBSetting
}
// SetGlobalSearchConfig set the global search config from agent/search.yml
func SetGlobalSearchConfig(config *searchTypes.Config) {
globalSearchConfig = config
}
// GetGlobalSearchConfig returns the global search config
func GetGlobalSearchConfig() *searchTypes.Config {
return globalSearchConfig
}
// SetCache set the cache
func SetCache(capacity int) {
ClearCache()
@ -585,19 +596,24 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
}
}
// Search options
// Search configuration (from package.yao search block)
// This contains search options like web.max_results, kb.threshold, citation.format, etc.
// Merge hierarchy: global config < assistant config
if v, ok := data["search"].(map[string]interface{}); ok {
assistant.Search = &SearchOption{}
var assistantSearch searchTypes.Config
raw, err := jsoniter.Marshal(v)
if err != nil {
return nil, err
}
// Unmarshal the raw data
err = jsoniter.Unmarshal(raw, assistant.Search)
err = jsoniter.Unmarshal(raw, &assistantSearch)
if err != nil {
return nil, err
}
// Merge with global search config
assistant.Search = mergeSearchConfig(globalSearchConfig, &assistantSearch)
} else if globalSearchConfig != nil {
// No assistant-specific config, use global
assistant.Search = globalSearchConfig
}
// prompts
@ -681,12 +697,14 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
}
// uses (wrapper configurations for vision, audio, etc.)
// Merge hierarchy: global uses < assistant uses
if uses, has := data["uses"]; has {
var assistantUses *context.Uses
switch v := uses.(type) {
case *context.Uses:
assistant.Uses = v
assistantUses = v
case context.Uses:
assistant.Uses = &v
assistantUses = &v
default:
raw, err := jsoniter.Marshal(v)
if err != nil {
@ -697,8 +715,13 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
if err != nil {
return nil, err
}
assistant.Uses = &usesConfig
assistantUses = &usesConfig
}
// Merge with global uses
assistant.Uses = mergeUses(globalUses, assistantUses)
} else if globalUses != nil {
// No assistant-specific uses, use global
assistant.Uses = globalUses
}
// Load scripts (hook script and other scripts)
@ -761,3 +784,204 @@ func (ast *Assistant) initialize() error {
return nil
}
// mergeUses merges two Uses configs (base < override)
func mergeUses(base, override *context.Uses) *context.Uses {
if base == nil {
return override
}
if override == nil {
return base
}
result := *base // Copy base
// Override with non-empty values
if override.Vision != "" {
result.Vision = override.Vision
}
if override.Audio != "" {
result.Audio = override.Audio
}
if override.Search != "" {
result.Search = override.Search
}
if override.Fetch != "" {
result.Fetch = override.Fetch
}
if override.Web != "" {
result.Web = override.Web
}
if override.Keyword != "" {
result.Keyword = override.Keyword
}
if override.QueryDSL != "" {
result.QueryDSL = override.QueryDSL
}
if override.Rerank != "" {
result.Rerank = override.Rerank
}
return &result
}
// mergeSearchConfig merges two search configs (base < override)
func mergeSearchConfig(base, override *searchTypes.Config) *searchTypes.Config {
if base == nil {
return override
}
if override == nil {
return base
}
result := *base // Copy base
// Merge Web config
if override.Web != nil {
if result.Web == nil {
result.Web = override.Web
} else {
merged := *result.Web
if override.Web.Provider != "" {
merged.Provider = override.Web.Provider
}
if override.Web.APIKeyEnv != "" {
merged.APIKeyEnv = override.Web.APIKeyEnv
}
if override.Web.MaxResults > 0 {
merged.MaxResults = override.Web.MaxResults
}
result.Web = &merged
}
}
// Merge KB config
if override.KB != nil {
if result.KB == nil {
result.KB = override.KB
} else {
merged := *result.KB
if len(override.KB.Collections) > 0 {
merged.Collections = override.KB.Collections
}
if override.KB.Threshold > 0 {
merged.Threshold = override.KB.Threshold
}
if override.KB.Graph {
merged.Graph = override.KB.Graph
}
result.KB = &merged
}
}
// Merge DB config
if override.DB != nil {
if result.DB == nil {
result.DB = override.DB
} else {
merged := *result.DB
if len(override.DB.Models) > 0 {
merged.Models = override.DB.Models
}
if override.DB.MaxResults > 0 {
merged.MaxResults = override.DB.MaxResults
}
result.DB = &merged
}
}
// Merge Keyword config
if override.Keyword != nil {
if result.Keyword == nil {
result.Keyword = override.Keyword
} else {
merged := *result.Keyword
if override.Keyword.MaxKeywords > 0 {
merged.MaxKeywords = override.Keyword.MaxKeywords
}
if override.Keyword.Language != "" {
merged.Language = override.Keyword.Language
}
result.Keyword = &merged
}
}
// Merge QueryDSL config
if override.QueryDSL != nil {
if result.QueryDSL == nil {
result.QueryDSL = override.QueryDSL
} else {
merged := *result.QueryDSL
if override.QueryDSL.Strict {
merged.Strict = override.QueryDSL.Strict
}
result.QueryDSL = &merged
}
}
// Merge Rerank config
if override.Rerank != nil {
if result.Rerank == nil {
result.Rerank = override.Rerank
} else {
merged := *result.Rerank
if override.Rerank.TopN > 0 {
merged.TopN = override.Rerank.TopN
}
result.Rerank = &merged
}
}
// Merge Citation config
if override.Citation != nil {
if result.Citation == nil {
result.Citation = override.Citation
} else {
merged := *result.Citation
if override.Citation.Format != "" {
merged.Format = override.Citation.Format
}
// AutoInjectPrompt is a bool, so we check if it's explicitly set
// by checking if the whole Citation block was provided
merged.AutoInjectPrompt = override.Citation.AutoInjectPrompt
if override.Citation.CustomPrompt != "" {
merged.CustomPrompt = override.Citation.CustomPrompt
}
result.Citation = &merged
}
}
// Merge Weights config
if override.Weights != nil {
if result.Weights == nil {
result.Weights = override.Weights
} else {
merged := *result.Weights
if override.Weights.User > 0 {
merged.User = override.Weights.User
}
if override.Weights.Hook > 0 {
merged.Hook = override.Weights.Hook
}
if override.Weights.Auto > 0 {
merged.Auto = override.Weights.Auto
}
result.Weights = &merged
}
}
// Merge Options config
if override.Options != nil {
if result.Options == nil {
result.Options = override.Options
} else {
merged := *result.Options
if override.Options.SkipThreshold > 0 {
merged.SkipThreshold = override.Options.SkipThreshold
}
result.Options = &merged
}
}
return &result
}

View 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)
}

View file

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
searchTypes "github.com/yaoapp/yao/agent/search/types"
store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
@ -933,3 +934,246 @@ function Create(ctx: any, messages: any[]): any {
assert.False(t, *res.DisableGlobalPrompts)
})
}
// TestLoadStoreWithSearchConfig tests loading assistant with search configuration from database
func TestLoadStoreWithSearchConfig(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
assistantID := "test.store-with-search"
now := time.Now().UnixNano()
ast := &assistant.Assistant{
AssistantModel: store.AssistantModel{
ID: assistantID,
Name: "Test With Search Config",
Type: "assistant",
Connector: "gpt-4o",
Uses: &context.Uses{
Vision: "agent",
Audio: "mcp:audio-server",
Fetch: "agent",
Web: "builtin",
Keyword: "builtin",
QueryDSL: "builtin",
Rerank: "builtin",
},
Search: &searchTypes.Config{
Web: &searchTypes.WebConfig{
Provider: "tavily",
MaxResults: 15,
},
KB: &searchTypes.KBConfig{
Collections: []string{"docs", "faq"},
Threshold: 0.8,
Graph: true,
},
DB: &searchTypes.DBConfig{
Models: []string{"user", "product"},
MaxResults: 50,
},
Keyword: &searchTypes.KeywordConfig{
MaxKeywords: 8,
Language: "auto",
},
QueryDSL: &searchTypes.QueryDSLConfig{
Strict: true,
},
Rerank: &searchTypes.RerankConfig{
TopN: 5,
},
Citation: &searchTypes.CitationConfig{
Format: "#cite:{id}",
AutoInjectPrompt: false,
CustomPrompt: "Please cite sources.",
},
Weights: &searchTypes.WeightsConfig{
User: 1.0,
Hook: 0.85,
Auto: 0.65,
},
Options: &searchTypes.OptionsConfig{
SkipThreshold: 3,
},
},
CreatedAt: now,
UpdatedAt: now,
},
}
err := ast.Save()
require.NoError(t, err)
defer func() {
storage := assistant.GetStorage()
if storage != nil {
storage.DeleteAssistant(assistantID)
}
assistant.GetCache().Clear()
}()
assistant.GetCache().Clear()
loaded, err := assistant.Get(assistantID)
require.NoError(t, err)
require.NotNil(t, loaded)
// Verify Uses
require.NotNil(t, loaded.Uses)
assert.Equal(t, "agent", loaded.Uses.Vision)
assert.Equal(t, "mcp:audio-server", loaded.Uses.Audio)
assert.Equal(t, "agent", loaded.Uses.Fetch)
assert.Equal(t, "builtin", loaded.Uses.Web)
assert.Equal(t, "builtin", loaded.Uses.Keyword)
assert.Equal(t, "builtin", loaded.Uses.QueryDSL)
assert.Equal(t, "builtin", loaded.Uses.Rerank)
// Verify Search config
require.NotNil(t, loaded.Search)
// Web config
require.NotNil(t, loaded.Search.Web)
assert.Equal(t, "tavily", loaded.Search.Web.Provider)
assert.Equal(t, 15, loaded.Search.Web.MaxResults)
// KB config
require.NotNil(t, loaded.Search.KB)
assert.Equal(t, []string{"docs", "faq"}, loaded.Search.KB.Collections)
assert.Equal(t, 0.8, loaded.Search.KB.Threshold)
assert.True(t, loaded.Search.KB.Graph)
// DB config
require.NotNil(t, loaded.Search.DB)
assert.Equal(t, []string{"user", "product"}, loaded.Search.DB.Models)
assert.Equal(t, 50, loaded.Search.DB.MaxResults)
// Keyword config
require.NotNil(t, loaded.Search.Keyword)
assert.Equal(t, 8, loaded.Search.Keyword.MaxKeywords)
assert.Equal(t, "auto", loaded.Search.Keyword.Language)
// QueryDSL config
require.NotNil(t, loaded.Search.QueryDSL)
assert.True(t, loaded.Search.QueryDSL.Strict)
// Rerank config
require.NotNil(t, loaded.Search.Rerank)
assert.Equal(t, 5, loaded.Search.Rerank.TopN)
// Citation config
require.NotNil(t, loaded.Search.Citation)
assert.Equal(t, "#cite:{id}", loaded.Search.Citation.Format)
assert.False(t, loaded.Search.Citation.AutoInjectPrompt)
assert.Equal(t, "Please cite sources.", loaded.Search.Citation.CustomPrompt)
// Weights config
require.NotNil(t, loaded.Search.Weights)
assert.Equal(t, 1.0, loaded.Search.Weights.User)
assert.Equal(t, 0.85, loaded.Search.Weights.Hook)
assert.Equal(t, 0.65, loaded.Search.Weights.Auto)
// Options config
require.NotNil(t, loaded.Search.Options)
assert.Equal(t, 3, loaded.Search.Options.SkipThreshold)
}
// TestLoadStoreWithPartialSearchConfig tests loading assistant with partial search configuration
func TestLoadStoreWithPartialSearchConfig(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
assistantID := "test.store-partial-search"
now := time.Now().UnixNano()
ast := &assistant.Assistant{
AssistantModel: store.AssistantModel{
ID: assistantID,
Name: "Test Partial Search Config",
Type: "assistant",
Connector: "gpt-4o",
Search: &searchTypes.Config{
Web: &searchTypes.WebConfig{
Provider: "serper",
},
// Only web config, others are nil
},
CreatedAt: now,
UpdatedAt: now,
},
}
err := ast.Save()
require.NoError(t, err)
defer func() {
storage := assistant.GetStorage()
if storage != nil {
storage.DeleteAssistant(assistantID)
}
assistant.GetCache().Clear()
}()
assistant.GetCache().Clear()
loaded, err := assistant.Get(assistantID)
require.NoError(t, err)
require.NotNil(t, loaded)
// Verify Search config
require.NotNil(t, loaded.Search)
// Web config should be set
require.NotNil(t, loaded.Search.Web)
assert.Equal(t, "serper", loaded.Search.Web.Provider)
// Other configs should be nil
assert.Nil(t, loaded.Search.KB)
assert.Nil(t, loaded.Search.DB)
assert.Nil(t, loaded.Search.Keyword)
assert.Nil(t, loaded.Search.QueryDSL)
assert.Nil(t, loaded.Search.Rerank)
assert.Nil(t, loaded.Search.Citation)
assert.Nil(t, loaded.Search.Weights)
assert.Nil(t, loaded.Search.Options)
}
// TestLoadStoreWithoutSearchConfig tests loading assistant without search configuration
func TestLoadStoreWithoutSearchConfig(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
assistantID := "test.store-no-search"
now := time.Now().UnixNano()
ast := &assistant.Assistant{
AssistantModel: store.AssistantModel{
ID: assistantID,
Name: "Test No Search Config",
Type: "assistant",
Connector: "gpt-4o",
// No Search config
CreatedAt: now,
UpdatedAt: now,
},
}
err := ast.Save()
require.NoError(t, err)
defer func() {
storage := assistant.GetStorage()
if storage != nil {
storage.DeleteAssistant(assistantID)
}
assistant.GetCache().Clear()
}()
assistant.GetCache().Clear()
loaded, err := assistant.Get(assistantID)
require.NoError(t, err)
require.NotNil(t, loaded)
// Search config should be nil
assert.Nil(t, loaded.Search)
}

View file

@ -21,12 +21,6 @@ type API interface {
GetPlaceholder(locale string) *store.Placeholder
}
// SearchOption the search option
type SearchOption struct {
WebSearch *bool `json:"web_search,omitempty" yaml:"web_search,omitempty"` // Whether to search the web
Knowledge *bool `json:"knowledge,omitempty" yaml:"knowledge,omitempty"` // Whether to search the knowledge
}
// Script the script scripts except hook script
type Script struct {
*v8.Script
@ -35,16 +29,13 @@ type Script struct {
// Assistant the assistant
type Assistant struct {
store.AssistantModel
Search *SearchOption `json:"search,omitempty" yaml:"search,omitempty"` // Whether this assistant supports search
HookScript *hook.Script `json:"-" yaml:"-"` // Hook Script (index.ts)
Scripts map[string]*Script `json:"-" yaml:"-"` // Other scripts
HookScript *hook.Script `json:"-" yaml:"-"` // Hook Script (index.ts)
Scripts map[string]*Script `json:"-" yaml:"-"` // Other scripts
// Internal
// ===============================
openai *api.OpenAI // OpenAI API
search bool // Whether this assistant supports search
vision bool // Whether this assistant supports vision
// toolCalls bool // Whether this assistant supports tool_calls
}
// MCPTool represents a simplified MCP tool for building LLM requests

View file

@ -10,8 +10,14 @@ import (
type Uses struct {
Vision string `json:"vision,omitempty"` // Vision processing tool. Format: "agent" or "mcp:server_id"
Audio string `json:"audio,omitempty"` // Audio processing tool. Format: "agent" or "mcp:server_id"
Search string `json:"search,omitempty"` // Search tool. Format: "agent" or "mcp:server_id"
Search string `json:"search,omitempty"` // Search tool. Format: "builtin", "disabled", "<assistant-id>", "mcp:<server>.<tool>"
Fetch string `json:"fetch,omitempty"` // Fetch/retrieval tool. Format: "agent" or "mcp:server_id"
// Search-related processing tools (NLP)
Web string `json:"web,omitempty"` // Web search handler: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
Keyword string `json:"keyword,omitempty"` // Keyword extraction: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
QueryDSL string `json:"querydsl,omitempty"` // QueryDSL generation: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
Rerank string `json:"rerank,omitempty"` // Result reranking: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
}
// VisionFormat specifies the vision input format

View file

@ -10,6 +10,8 @@ import (
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
searchDefaults "github.com/yaoapp/yao/agent/search/defaults"
searchTypes "github.com/yaoapp/yao/agent/search/types"
storeMongo "github.com/yaoapp/yao/agent/store/mongo"
storeRedis "github.com/yaoapp/yao/agent/store/redis"
store "github.com/yaoapp/yao/agent/store/types"
@ -92,6 +94,12 @@ func Load(cfg config.Config) error {
return err
}
// Initialize Search Configuration
err = initSearchConfig()
if err != nil {
return err
}
// Initialize Assistant
err = initAssistant()
if err != nil {
@ -222,6 +230,10 @@ func initAssistant() error {
assistant.SetGlobalKBSetting(agentDSL.KB)
}
if agentDSL.Search != nil {
assistant.SetGlobalSearchConfig(agentDSL.Search)
}
// Load Built-in Assistants
err := assistant.LoadBuiltIn()
if err != nil {
@ -261,6 +273,177 @@ func initKBConfig() error {
return nil
}
// initSearchConfig initialize the search configuration from agent/search.yml
func initSearchConfig() error {
// Start with system defaults
agentDSL.Search = searchDefaults.SystemDefaults
path := filepath.Join("agent", "search.yml")
if exists, _ := application.App.Exists(path); !exists {
return nil // Search config is optional, use defaults
}
// Read the search configuration
bytes, err := application.App.Read(path)
if err != nil {
return err
}
var searchConfig searchTypes.Config
err = application.Parse("search.yml", bytes, &searchConfig)
if err != nil {
return err
}
// Merge with defaults
agentDSL.Search = mergeSearchConfig(searchDefaults.SystemDefaults, &searchConfig)
return nil
}
// mergeSearchConfig merges two search configs (base < override)
func mergeSearchConfig(base, override *searchTypes.Config) *searchTypes.Config {
if base == nil {
return override
}
if override == nil {
return base
}
result := *base // Copy base
// Merge Web config
if override.Web != nil {
if result.Web == nil {
result.Web = override.Web
} else {
if override.Web.Provider != "" {
result.Web.Provider = override.Web.Provider
}
if override.Web.APIKeyEnv != "" {
result.Web.APIKeyEnv = override.Web.APIKeyEnv
}
if override.Web.MaxResults > 0 {
result.Web.MaxResults = override.Web.MaxResults
}
}
}
// Merge KB config
if override.KB != nil {
if result.KB == nil {
result.KB = override.KB
} else {
if len(override.KB.Collections) > 0 {
result.KB.Collections = override.KB.Collections
}
if override.KB.Threshold > 0 {
result.KB.Threshold = override.KB.Threshold
}
if override.KB.Graph {
result.KB.Graph = override.KB.Graph
}
}
}
// Merge DB config
if override.DB != nil {
if result.DB == nil {
result.DB = override.DB
} else {
if len(override.DB.Models) > 0 {
result.DB.Models = override.DB.Models
}
if override.DB.MaxResults > 0 {
result.DB.MaxResults = override.DB.MaxResults
}
}
}
// Merge Keyword config
if override.Keyword != nil {
if result.Keyword == nil {
result.Keyword = override.Keyword
} else {
if override.Keyword.MaxKeywords > 0 {
result.Keyword.MaxKeywords = override.Keyword.MaxKeywords
}
if override.Keyword.Language != "" {
result.Keyword.Language = override.Keyword.Language
}
}
}
// Merge QueryDSL config
if override.QueryDSL != nil {
result.QueryDSL = override.QueryDSL
}
// Merge Rerank config
if override.Rerank != nil {
if result.Rerank == nil {
result.Rerank = override.Rerank
} else {
if override.Rerank.TopN > 0 {
result.Rerank.TopN = override.Rerank.TopN
}
}
}
// Merge Citation config
if override.Citation != nil {
if result.Citation == nil {
result.Citation = override.Citation
} else {
if override.Citation.Format != "" {
result.Citation.Format = override.Citation.Format
}
// AutoInjectPrompt is a bool, need to check if explicitly set
result.Citation.AutoInjectPrompt = override.Citation.AutoInjectPrompt
if override.Citation.CustomPrompt != "" {
result.Citation.CustomPrompt = override.Citation.CustomPrompt
}
}
}
// Merge Weights config
if override.Weights != nil {
if result.Weights == nil {
result.Weights = override.Weights
} else {
if override.Weights.User > 0 {
result.Weights.User = override.Weights.User
}
if override.Weights.Hook > 0 {
result.Weights.Hook = override.Weights.Hook
}
if override.Weights.Auto > 0 {
result.Weights.Auto = override.Weights.Auto
}
}
}
// Merge Options config
if override.Options != nil {
if result.Options == nil {
result.Options = override.Options
} else {
if override.Options.SkipThreshold > 0 {
result.Options.SkipThreshold = override.Options.SkipThreshold
}
}
}
return &result
}
// GetSearchConfig returns the global search configuration
func GetSearchConfig() *searchTypes.Config {
if agentDSL == nil {
return searchDefaults.SystemDefaults
}
return agentDSL.Search
}
// defaultAssistant get the default assistant
func defaultAssistant() (*assistant.Assistant, error) {
if agentDSL.Uses == nil || agentDSL.Uses.Default == "" {

View file

@ -94,6 +94,49 @@ func TestLoad(t *testing.T) {
assert.Equal(t, "__yao.utf8", agent.KB.Chat.DocumentDefaults.Converter.ProviderID)
assert.Equal(t, "standard-text", agent.KB.Chat.DocumentDefaults.Converter.OptionID)
})
t.Run("LoadSearchConfig", func(t *testing.T) {
// Search configuration should be loaded from agent/search.yml
assert.NotNil(t, agent.Search)
// Verify web config
assert.NotNil(t, agent.Search.Web)
assert.Equal(t, "tavily", agent.Search.Web.Provider)
assert.Equal(t, 10, agent.Search.Web.MaxResults)
// Verify KB config
assert.NotNil(t, agent.Search.KB)
assert.Equal(t, 0.7, agent.Search.KB.Threshold)
assert.False(t, agent.Search.KB.Graph)
// Verify DB config
assert.NotNil(t, agent.Search.DB)
assert.Equal(t, 20, agent.Search.DB.MaxResults)
// Verify keyword config
assert.NotNil(t, agent.Search.Keyword)
assert.Equal(t, 10, agent.Search.Keyword.MaxKeywords)
assert.Equal(t, "auto", agent.Search.Keyword.Language)
// Verify rerank config
assert.NotNil(t, agent.Search.Rerank)
assert.Equal(t, 10, agent.Search.Rerank.TopN)
// Verify citation config
assert.NotNil(t, agent.Search.Citation)
assert.Equal(t, "#ref:{id}", agent.Search.Citation.Format)
assert.True(t, agent.Search.Citation.AutoInjectPrompt)
// Verify weights config
assert.NotNil(t, agent.Search.Weights)
assert.Equal(t, 1.0, agent.Search.Weights.User)
assert.Equal(t, 0.8, agent.Search.Weights.Hook)
assert.Equal(t, 0.6, agent.Search.Weights.Auto)
// Verify options config
assert.NotNil(t, agent.Search.Options)
assert.Equal(t, 5, agent.Search.Options.SkipThreshold)
})
}
func TestGetGlobalPrompts(t *testing.T) {

View file

@ -1089,7 +1089,7 @@ function Create(ctx, messages, options) {
Configuration follows a three-layer hierarchy (later overrides earlier):
1. **System Built-in Defaults** - Hardcoded sensible defaults
2. **Global Configuration** - `agent/agent.yml` (uses) + `agent/search.yao` (search options)
2. **Global Configuration** - `agent/agent.yml` (uses) + `agent/search.yml` (search options)
3. **Assistant Configuration** - `assistants/<assistant-id>/package.yao` (uses + search options)
### Uses Configuration
@ -1158,7 +1158,7 @@ package defaults
import "github.com/yaoapp/yao/agent/search/types"
// SystemDefaults provides hardcoded default values
// Used by agent/load.go for merging with agent/search.yao
// Used by agent/load.go for merging with agent/search.yml
var SystemDefaults = &types.Config{
// Web search defaults
Web: &types.WebConfig{
@ -1251,12 +1251,12 @@ import (
var searchConfig *searchTypes.Config
// initSearchConfig initialize the search configuration from agent/search.yao
// initSearchConfig initialize the search configuration from agent/search.yml
func initSearchConfig() error {
// Start with system defaults
searchConfig = searchDefaults.SystemDefaults
path := filepath.Join("agent", "search.yao")
path := filepath.Join("agent", "search.yml")
if exists, _ := application.App.Exists(path); !exists {
return nil // Use defaults
}
@ -1268,7 +1268,7 @@ func initSearchConfig() error {
}
var cfg searchTypes.Config
err = application.Parse("search.yao", bytes, &cfg)
err = application.Parse("search.yml", bytes, &cfg)
if err != nil {
return err
}
@ -1304,62 +1304,54 @@ func (ast *Assistant) GetMergedSearchConfig() *searchTypes.Config {
### Global Configuration
`agent/search.yao` - Override system defaults for all assistants:
`agent/search.yml` - Override system defaults for all assistants:
```jsonc
{
// Web search settings
"web": {
"provider": "tavily", // "tavily", "serper" (builtin providers only)
"api_key_env": "TAVILY_API_KEY",
"max_results": 10
},
```yaml
# Global Search Configuration
# These settings apply to all assistants unless overridden by assistant-specific configurations.
// Knowledge base search settings
"kb": {
"threshold": 0.7, // Similarity threshold
"graph": false // Enable GraphRAG association
},
# Web search settings
web:
provider: "tavily" # "tavily", "serper" (builtin providers only)
api_key_env: "TAVILY_API_KEY"
max_results: 10
// Database search settings
"db": {
"max_results": 20
},
# Knowledge base search settings
kb:
threshold: 0.7 # Similarity threshold
graph: false # Enable GraphRAG association
// Keyword extraction options (uses.keyword)
"keyword": {
"max_keywords": 10,
"language": "auto" // "auto", "en", "zh", etc.
},
# Database search settings
db:
max_results: 20
// QueryDSL generation options (uses.querydsl)
"querydsl": {
"strict": false // Strict mode: fail if generation fails
},
# Keyword extraction options (uses.keyword)
keyword:
max_keywords: 10
language: "auto" # "auto", "en", "zh", etc.
// Rerank options (uses.rerank)
"rerank": {
"top_n": 10 // Return top N results after reranking
},
# QueryDSL generation options (uses.querydsl)
querydsl:
strict: false # Strict mode: fail if generation fails
// Citation format for LLM references
"citation": {
"format": "#ref:{id}",
"auto_inject_prompt": true // Auto-inject citation instructions to system prompt
},
# Rerank options (uses.rerank)
rerank:
top_n: 10 # Return top N results after reranking
// Source weighting for result merging
"weights": {
"user": 1.0, // User-provided DataContent (highest priority)
"hook": 0.8, // Hook ctx.search.*() results
"auto": 0.6 // Auto search results
},
# Citation format for LLM references
citation:
format: "#ref:{id}"
auto_inject_prompt: true # Auto-inject citation instructions to system prompt
// Search behavior options
"options": {
"skip_threshold": 5 // Skip auto search if user provides >= N results
}
}
# Source weighting for result merging
weights:
user: 1.0 # User-provided DataContent (highest priority)
hook: 0.8 # Hook ctx.search.*() results
auto: 0.6 # Auto search results
# Search behavior options
options:
skip_threshold: 5 # Skip auto search if user provides >= N results
```
### Assistant Configuration
@ -1380,7 +1372,7 @@ func (ast *Assistant) GetMergedSearchConfig() *searchTypes.Config {
"rerank": "mcp:my-server.rerank" // Use MCP tool for reranking
},
// Search configuration (overrides agent/search.yao)
// Search configuration (overrides agent/search.yml)
"search": {
// Overrides global web settings
"web": {
@ -2017,7 +2009,7 @@ if (result.error) {
Configuration is merged with later layers overriding earlier ones:
1. **System Built-in** - Hardcoded defaults (lowest priority)
2. **Global-level** - `agent/agent.yml` (uses) + `agent/search.yao` (search options)
2. **Global-level** - `agent/agent.yml` (uses) + `agent/search.yml` (search options)
3. **Assistant-level** - `assistants/<assistant-id>/package.yao` (uses + search)
4. **Hook-level** - CreateHook return `uses.search` value
5. **Request-level** - `options.uses.search` in Stream() call (highest priority)
@ -2301,19 +2293,15 @@ Stream()
**Configuration:**
Global defaults (`agent/search.yao`):
Global defaults (`agent/search.yml`):
```jsonc
{
"weights": {
"user": 1.0, // User-provided DataContent
"hook": 0.8, // Hook ctx.search.*() results
"auto": 0.6 // Auto search results
},
"options": {
"skip_threshold": 5 // Skip auto search if user provides >= N results
}
}
```yaml
weights:
user: 1.0 # User-provided DataContent
hook: 0.8 # Hook ctx.search.*() results
auto: 0.6 # Auto search results
options:
skip_threshold: 5 # Skip auto search if user provides >= N results
```
Assistant-level override (`assistants/<assistant-id>/package.yao`):

27
agent/search/citation.go Normal file
View 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)
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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)
}

View 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.

View 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)
}

View 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
View 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
View 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
View 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
}

View 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
}
}

View 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"`
}

View 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
View 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
}

View file

@ -9,7 +9,9 @@ import (
"github.com/spf13/cast"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
searchTypes "github.com/yaoapp/yao/agent/search/types"
)
// ToKnowledgeBase converts various types to KnowledgeBase
@ -616,3 +618,59 @@ func ToPromptPresets(v interface{}) (map[string][]Prompt, error) {
return result, nil
}
}
// ToUses converts various types to context.Uses
func ToUses(v interface{}) (*context.Uses, error) {
if v == nil {
return nil, nil
}
switch uses := v.(type) {
case *context.Uses:
return uses, nil
case context.Uses:
return &uses, nil
default:
raw, err := jsoniter.Marshal(uses)
if err != nil {
return nil, fmt.Errorf("uses format error: %s", err.Error())
}
var result context.Uses
err = jsoniter.Unmarshal(raw, &result)
if err != nil {
return nil, fmt.Errorf("uses format error: %s", err.Error())
}
return &result, nil
}
}
// ToSearchConfig converts various types to searchTypes.Config
func ToSearchConfig(v interface{}) (*searchTypes.Config, error) {
if v == nil {
return nil, nil
}
switch cfg := v.(type) {
case *searchTypes.Config:
return cfg, nil
case searchTypes.Config:
return &cfg, nil
default:
raw, err := jsoniter.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("search config format error: %s", err.Error())
}
var result searchTypes.Config
err = jsoniter.Unmarshal(raw, &result)
if err != nil {
return nil, fmt.Errorf("search config format error: %s", err.Error())
}
return &result, nil
}
}

View file

@ -33,6 +33,7 @@ var AssistantAllowedFields = map[string]bool{
"share": true,
"locales": true,
"uses": true,
"search": true,
"automated": true,
"mentionable": true,
"created_at": true,
@ -104,6 +105,7 @@ var AssistantFullFields = []string{
"share",
"locales",
"uses",
"search",
"automated",
"mentionable",
"created_at",

View file

@ -9,6 +9,7 @@ import (
"github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
searchTypes "github.com/yaoapp/yao/agent/search/types"
)
// Setting represents the conversation configuration structure
@ -436,6 +437,7 @@ type AssistantModel struct {
Source string `json:"source,omitempty"` // Hook script source code
Locales i18n.Map `json:"locales,omitempty"` // Assistant Locales
Uses *context.Uses `json:"uses,omitempty"` // Assistant-specific wrapper configurations for vision, audio, etc. If not set, use global settings
Search *searchTypes.Config `json:"search,omitempty"` // Search configuration (web, kb, db, citation, weights, etc.)
CreatedAt int64 `json:"created_at"` // Creation timestamp
UpdatedAt int64 `json:"updated_at"` // Last update timestamp

View file

@ -10,6 +10,7 @@ import (
"github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
searchTypes "github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/agent/store/types"
)
@ -183,6 +184,7 @@ func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error)
"placeholder": assistant.Placeholder,
"locales": assistant.Locales,
"uses": assistant.Uses,
"search": assistant.Search,
}
for field, value := range jsonFields {
@ -241,7 +243,7 @@ func (store *Xun) UpdateAssistant(assistantID string, updates map[string]interfa
data := make(map[string]interface{})
// List of fields that need JSON marshaling
jsonFields := []string{"options", "tags", "modes", "prompts", "prompt_presets", "connector_options", "kb", "db", "mcp", "workflow", "placeholder", "locales", "uses"}
jsonFields := []string{"options", "tags", "modes", "prompts", "prompt_presets", "connector_options", "kb", "db", "mcp", "workflow", "placeholder", "locales", "uses", "search"}
jsonFieldSet := make(map[string]bool)
for _, field := range jsonFields {
jsonFieldSet[field] = true
@ -441,7 +443,7 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
// Convert rows to types.AssistantModel slice
assistants := make([]*types.AssistantModel, 0, len(rows))
jsonFields := []string{"tags", "options", "prompts", "prompt_presets", "connector_options", "workflow", "kb", "mcp", "placeholder", "locales", "uses"}
jsonFields := []string{"tags", "options", "prompts", "prompt_presets", "connector_options", "workflow", "kb", "mcp", "placeholder", "locales", "uses", "search"}
for _, row := range rows {
data := row.ToMap()
@ -514,7 +516,7 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
}
// Parse JSON fields
jsonFields := []string{"tags", "modes", "options", "prompts", "prompt_presets", "connector_options", "workflow", "kb", "db", "mcp", "placeholder", "locales", "uses"}
jsonFields := []string{"tags", "modes", "options", "prompts", "prompt_presets", "connector_options", "workflow", "kb", "db", "mcp", "placeholder", "locales", "uses", "search"}
store.parseJSONFields(data, jsonFields)
// Convert map to types.AssistantModel
@ -659,6 +661,16 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
}
}
if search, has := data["search"]; has && search != nil {
raw, err := jsoniter.Marshal(search)
if err == nil {
var s searchTypes.Config
if err := jsoniter.Unmarshal(raw, &s); err == nil {
model.Search = &s
}
}
}
// Apply i18n translation if locale is provided
if len(locale) > 0 && locale[0] != "" {
store.translate(model, assistantID, locale[0])

View file

@ -10,6 +10,7 @@ import (
"github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
searchTypes "github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/agent/store/xun"
"github.com/yaoapp/yao/config"
@ -452,6 +453,198 @@ func TestSaveAssistant(t *testing.T) {
}
})
t.Run("SearchConfiguration", func(t *testing.T) {
// Test assistant with Search configuration
assistant := &types.AssistantModel{
Name: "Search Config Test Assistant",
Type: "assistant",
Connector: "openai",
Share: "private",
Search: &searchTypes.Config{
Web: &searchTypes.WebConfig{
Provider: "tavily",
MaxResults: 15,
},
KB: &searchTypes.KBConfig{
Collections: []string{"docs", "faq"},
Threshold: 0.8,
Graph: true,
},
DB: &searchTypes.DBConfig{
Models: []string{"user", "product"},
MaxResults: 50,
},
Citation: &searchTypes.CitationConfig{
Format: "#ref:{id}",
AutoInjectPrompt: true,
},
Weights: &searchTypes.WeightsConfig{
User: 1.0,
Hook: 0.9,
Auto: 0.7,
},
},
}
id, err := store.SaveAssistant(assistant)
if err != nil {
t.Fatalf("Failed to save assistant with search config: %v", err)
}
// Retrieve and verify search configuration - search is NOT in default fields
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
if err != nil {
t.Fatalf("Failed to retrieve assistant: %v", err)
}
if retrieved.Search == nil {
t.Fatal("Expected search to be set")
}
// Verify Web config
if retrieved.Search.Web == nil {
t.Fatal("Expected search.web to be set")
}
if retrieved.Search.Web.Provider != "tavily" {
t.Errorf("Expected web provider 'tavily', got '%s'", retrieved.Search.Web.Provider)
}
if retrieved.Search.Web.MaxResults != 15 {
t.Errorf("Expected web max_results 15, got %d", retrieved.Search.Web.MaxResults)
}
// Verify KB config
if retrieved.Search.KB == nil {
t.Fatal("Expected search.kb to be set")
}
if len(retrieved.Search.KB.Collections) != 2 {
t.Errorf("Expected 2 KB collections, got %d", len(retrieved.Search.KB.Collections))
}
if retrieved.Search.KB.Collections[0] != "docs" {
t.Errorf("Expected first collection 'docs', got '%s'", retrieved.Search.KB.Collections[0])
}
if retrieved.Search.KB.Threshold != 0.8 {
t.Errorf("Expected KB threshold 0.8, got %f", retrieved.Search.KB.Threshold)
}
if !retrieved.Search.KB.Graph {
t.Error("Expected KB graph to be true")
}
// Verify DB config
if retrieved.Search.DB == nil {
t.Fatal("Expected search.db to be set")
}
if len(retrieved.Search.DB.Models) != 2 {
t.Errorf("Expected 2 DB models, got %d", len(retrieved.Search.DB.Models))
}
if retrieved.Search.DB.MaxResults != 50 {
t.Errorf("Expected DB max_results 50, got %d", retrieved.Search.DB.MaxResults)
}
// Verify Citation config
if retrieved.Search.Citation == nil {
t.Fatal("Expected search.citation to be set")
}
if retrieved.Search.Citation.Format != "#ref:{id}" {
t.Errorf("Expected citation format '#ref:{id}', got '%s'", retrieved.Search.Citation.Format)
}
if !retrieved.Search.Citation.AutoInjectPrompt {
t.Error("Expected citation auto_inject_prompt to be true")
}
// Verify Weights config
if retrieved.Search.Weights == nil {
t.Fatal("Expected search.weights to be set")
}
if retrieved.Search.Weights.User != 1.0 {
t.Errorf("Expected weights.user 1.0, got %f", retrieved.Search.Weights.User)
}
if retrieved.Search.Weights.Hook != 0.9 {
t.Errorf("Expected weights.hook 0.9, got %f", retrieved.Search.Weights.Hook)
}
if retrieved.Search.Weights.Auto != 0.7 {
t.Errorf("Expected weights.auto 0.7, got %f", retrieved.Search.Weights.Auto)
}
t.Logf("Successfully saved and retrieved assistant with search configuration")
})
t.Run("NilSearchConfiguration", func(t *testing.T) {
// Test assistant without Search configuration
assistant := &types.AssistantModel{
Name: "No Search Config Assistant",
Type: "assistant",
Connector: "openai",
Share: "private",
}
id, err := store.SaveAssistant(assistant)
if err != nil {
t.Fatalf("Failed to save assistant without search: %v", err)
}
// Retrieve and verify search is nil - request all fields to check search
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
if err != nil {
t.Fatalf("Failed to retrieve assistant: %v", err)
}
if retrieved.Search != nil {
t.Errorf("Expected search to be nil, got %+v", retrieved.Search)
}
})
t.Run("PartialSearchConfiguration", func(t *testing.T) {
// Test assistant with partial Search configuration
assistant := &types.AssistantModel{
Name: "Partial Search Config Assistant",
Type: "assistant",
Connector: "openai",
Share: "private",
Search: &searchTypes.Config{
Web: &searchTypes.WebConfig{
Provider: "serper",
},
// KB, DB, Citation, Weights not set
},
}
id, err := store.SaveAssistant(assistant)
if err != nil {
t.Fatalf("Failed to save assistant with partial search: %v", err)
}
// Retrieve and verify - request all fields for search
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
if err != nil {
t.Fatalf("Failed to retrieve assistant: %v", err)
}
if retrieved.Search == nil {
t.Fatal("Expected search to be set")
}
if retrieved.Search.Web == nil {
t.Fatal("Expected search.web to be set")
}
if retrieved.Search.Web.Provider != "serper" {
t.Errorf("Expected web provider 'serper', got '%s'", retrieved.Search.Web.Provider)
}
// Other fields should be nil
if retrieved.Search.KB != nil {
t.Errorf("Expected search.kb to be nil, got %+v", retrieved.Search.KB)
}
if retrieved.Search.DB != nil {
t.Errorf("Expected search.db to be nil, got %+v", retrieved.Search.DB)
}
if retrieved.Search.Citation != nil {
t.Errorf("Expected search.citation to be nil, got %+v", retrieved.Search.Citation)
}
if retrieved.Search.Weights != nil {
t.Errorf("Expected search.weights to be nil, got %+v", retrieved.Search.Weights)
}
})
t.Run("ConnectorOptions", func(t *testing.T) {
// Test assistant with connector options
optionalTrue := true
@ -2725,6 +2918,128 @@ func TestUpdateAssistant(t *testing.T) {
}
})
t.Run("UpdateSearch", func(t *testing.T) {
// Create assistant without search
assistant := &types.AssistantModel{
Name: "Search Update Test",
Type: "assistant",
Connector: "openai",
Share: "private",
}
id, err := store.SaveAssistant(assistant)
if err != nil {
t.Fatalf("Failed to create assistant: %v", err)
}
// Update with search configuration
updates := map[string]interface{}{
"search": &searchTypes.Config{
Web: &searchTypes.WebConfig{
Provider: "tavily",
MaxResults: 20,
},
KB: &searchTypes.KBConfig{
Collections: []string{"knowledge"},
Threshold: 0.75,
},
},
}
err = store.UpdateAssistant(id, updates)
if err != nil {
t.Fatalf("Failed to update search: %v", err)
}
// Verify updates - search is NOT in default fields
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
if err != nil {
t.Fatalf("Failed to retrieve assistant: %v", err)
}
if retrieved.Search == nil {
t.Fatal("Expected search to be set")
}
if retrieved.Search.Web == nil {
t.Fatal("Expected search.web to be set")
}
if retrieved.Search.Web.Provider != "tavily" {
t.Errorf("Expected web provider 'tavily', got '%s'", retrieved.Search.Web.Provider)
}
if retrieved.Search.Web.MaxResults != 20 {
t.Errorf("Expected web max_results 20, got %d", retrieved.Search.Web.MaxResults)
}
if retrieved.Search.KB == nil {
t.Fatal("Expected search.kb to be set")
}
if len(retrieved.Search.KB.Collections) != 1 {
t.Errorf("Expected 1 KB collection, got %d", len(retrieved.Search.KB.Collections))
}
if retrieved.Search.KB.Threshold != 0.75 {
t.Errorf("Expected KB threshold 0.75, got %f", retrieved.Search.KB.Threshold)
}
// Update to change search configuration
updates2 := map[string]interface{}{
"search": &searchTypes.Config{
Web: &searchTypes.WebConfig{
Provider: "serper",
MaxResults: 30,
},
Citation: &searchTypes.CitationConfig{
Format: "#cite:{id}",
AutoInjectPrompt: false,
},
},
}
err = store.UpdateAssistant(id, updates2)
if err != nil {
t.Fatalf("Failed to update search again: %v", err)
}
// Verify second update - search is NOT in default fields
retrieved2, err := store.GetAssistant(id, types.AssistantFullFields)
if err != nil {
t.Fatalf("Failed to retrieve assistant: %v", err)
}
if retrieved2.Search.Web.Provider != "serper" {
t.Errorf("Expected web provider 'serper', got '%s'", retrieved2.Search.Web.Provider)
}
if retrieved2.Search.Web.MaxResults != 30 {
t.Errorf("Expected web max_results 30, got %d", retrieved2.Search.Web.MaxResults)
}
if retrieved2.Search.Citation == nil {
t.Fatal("Expected search.citation to be set")
}
if retrieved2.Search.Citation.Format != "#cite:{id}" {
t.Errorf("Expected citation format '#cite:{id}', got '%s'", retrieved2.Search.Citation.Format)
}
// Update to remove search (set to nil)
updates3 := map[string]interface{}{
"search": nil,
}
err = store.UpdateAssistant(id, updates3)
if err != nil {
t.Fatalf("Failed to set search to nil: %v", err)
}
// Verify search is nil - search is NOT in default fields
retrieved3, err := store.GetAssistant(id, types.AssistantFullFields)
if err != nil {
t.Fatalf("Failed to retrieve assistant: %v", err)
}
if retrieved3.Search != nil {
t.Errorf("Expected search to be nil, got %+v", retrieved3.Search)
}
})
t.Run("UpdatePermissionFields", func(t *testing.T) {
// Create assistant with permission fields
assistant := &types.AssistantModel{

View file

@ -3,6 +3,7 @@ package types
import (
"github.com/yaoapp/gou/connector/openai"
"github.com/yaoapp/yao/agent/assistant"
searchTypes "github.com/yaoapp/yao/agent/search/types"
store "github.com/yaoapp/yao/agent/store/types"
)
@ -19,6 +20,7 @@ type DSL struct {
// ===============================
Models map[string]openai.Capabilities `json:"models,omitempty" yaml:"models,omitempty"` // The model capabilities configuration
KB *store.KBSetting `json:"kb,omitempty" yaml:"kb,omitempty"` // The knowledge base configuration loaded from agent/kb.yml
Search *searchTypes.Config `json:"search,omitempty" yaml:"search,omitempty"` // The search configuration loaded from agent/search.yao
// Internal
// ===============================
@ -38,6 +40,12 @@ type Uses struct {
Audio string `json:"audio,omitempty" yaml:"audio,omitempty"` // The assistant for processing audio (speech-to-text, text-to-speech). If the model doesn't support audio, use this to convert audio to text. Format: "agent" or "mcp:mcp_server_id"
Search string `json:"search,omitempty" yaml:"search,omitempty"` // The assistant for searching the knowledge, global web search. If not set, and the assistant enable the knowledge, it will search the result from the knowledge automatically.
Fetch string `json:"fetch,omitempty" yaml:"fetch,omitempty"` // The assistant for fetching the http/https/ftp/sftp/etc. file, and return the file's content. if not set, use the http process to fetch the file.
// Search-related processing tools (NLP)
Web string `json:"web,omitempty" yaml:"web,omitempty"` // Web search handler: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Keyword extraction: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // QueryDSL generation: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
Rerank string `json:"rerank,omitempty" yaml:"rerank,omitempty"` // Result reranking: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
}
// Mention Structure

File diff suppressed because one or more lines are too long

View file

@ -235,6 +235,13 @@
"comment": "Assistant-specific wrapper configurations for vision, audio, etc. If not set, use global settings",
"nullable": true
},
{
"name": "search",
"type": "json",
"label": "Search",
"comment": "Search configuration (web, kb, db, citation, weights, etc.)",
"nullable": true
},
{
"name": "automated",
"type": "boolean",