Enhance Search API with New V8 Binding Methods
- Introduced a new search object in the context to expose search methods (Web, KB, DB, All, Any, Race) for JavaScript integration. - Implemented individual search methods with argument validation and error handling, improving the robustness of the API. - Updated the JSAPI implementation to utilize the new search object, ensuring seamless interaction with the search functionalities. - Enhanced documentation in DESIGN.md to reflect the new V8 binding methods and their usage, providing clear guidance for developers.
This commit is contained in:
parent
fc9e00c917
commit
7768ca73b3
7 changed files with 1198 additions and 92 deletions
|
|
@ -25,8 +25,25 @@ func init() {
|
|||
return &agentCallerWrapper{ast: ast}, nil
|
||||
}
|
||||
|
||||
// Initialize Search JSAPI factory
|
||||
search.SetJSAPIFactory()
|
||||
// Initialize Search JSAPI factory with config getter
|
||||
search.SetJSAPIFactory(func(assistantID string) (*searchTypes.Config, *search.Uses) {
|
||||
ast, err := Get(assistantID)
|
||||
if err != nil || ast == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// Convert assistant.Uses to search.Uses
|
||||
var uses *search.Uses
|
||||
if ast.Uses != nil {
|
||||
uses = &search.Uses{
|
||||
Search: ast.Uses.Search,
|
||||
Web: ast.Uses.Web,
|
||||
Keyword: ast.Uses.Keyword,
|
||||
QueryDSL: ast.Uses.QueryDSL,
|
||||
Rerank: ast.Uses.Rerank,
|
||||
}
|
||||
}
|
||||
return ast.Search, uses
|
||||
})
|
||||
}
|
||||
|
||||
// agentCallerWrapper wraps Assistant to implement AgentCaller interface
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
|
|||
// Set mcp object
|
||||
jsObject.Set("mcp", ctx.newMCPObject(v8ctx.Isolate()))
|
||||
|
||||
// Set search object
|
||||
jsObject.Set("search", ctx.newSearchObject(v8ctx.Isolate()))
|
||||
|
||||
// Note: Space object will be set after instance creation (requires v8ctx)
|
||||
|
||||
// Create instance
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
package context
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||
"rogchap.com/v8go"
|
||||
)
|
||||
|
||||
// SearchAPI defines the search JSAPI interface for ctx.search.*
|
||||
// This interface is defined here to avoid circular dependency between context and search packages.
|
||||
// The actual implementation is in agent/search/jsapi.go
|
||||
|
|
@ -37,3 +42,321 @@ func (ctx *Context) Search() SearchAPI {
|
|||
}
|
||||
return SearchAPIFactory(ctx)
|
||||
}
|
||||
|
||||
// newSearchObject creates a new search object with all search methods
|
||||
// This is called from jsapi.go NewObject() to mount ctx.search
|
||||
func (ctx *Context) newSearchObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
|
||||
searchObj := v8go.NewObjectTemplate(iso)
|
||||
|
||||
// Single search methods
|
||||
searchObj.Set("Web", ctx.searchWebMethod(iso))
|
||||
searchObj.Set("KB", ctx.searchKBMethod(iso))
|
||||
searchObj.Set("DB", ctx.searchDBMethod(iso))
|
||||
|
||||
// Parallel search methods - inspired by JavaScript Promise
|
||||
searchObj.Set("All", ctx.searchAllMethod(iso))
|
||||
searchObj.Set("Any", ctx.searchAnyMethod(iso))
|
||||
searchObj.Set("Race", ctx.searchRaceMethod(iso))
|
||||
|
||||
return searchObj
|
||||
}
|
||||
|
||||
// searchWebMethod implements ctx.search.Web(query, options?)
|
||||
// Options:
|
||||
// - limit: number - max results (default: 10)
|
||||
// - sites: string[] - restrict to specific sites
|
||||
// - time_range: string - "day", "week", "month", "year"
|
||||
// - rerank: { top_n: number } - rerank options
|
||||
func (ctx *Context) searchWebMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
args := info.Args()
|
||||
|
||||
// Validate arguments
|
||||
if len(args) < 1 {
|
||||
return bridge.JsException(v8ctx, "Web requires query parameter")
|
||||
}
|
||||
|
||||
// Get query string
|
||||
if !args[0].IsString() {
|
||||
return bridge.JsException(v8ctx, "query must be a string")
|
||||
}
|
||||
query := args[0].String()
|
||||
|
||||
// Parse options (optional)
|
||||
var opts map[string]interface{}
|
||||
if len(args) >= 2 && !args[1].IsUndefined() && !args[1].IsNull() {
|
||||
goVal, err := bridge.GoValue(args[1], v8ctx)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "invalid options: "+err.Error())
|
||||
}
|
||||
if optsMap, ok := goVal.(map[string]interface{}); ok {
|
||||
opts = optsMap
|
||||
}
|
||||
}
|
||||
|
||||
// Get search API
|
||||
searchAPI := ctx.Search()
|
||||
if searchAPI == nil {
|
||||
return bridge.JsException(v8ctx, "search API not available")
|
||||
}
|
||||
|
||||
// Execute search
|
||||
result := searchAPI.Web(query, opts)
|
||||
|
||||
// Convert result to JS value
|
||||
jsVal, err := bridge.JsValue(v8ctx, result)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "failed to convert result: "+err.Error())
|
||||
}
|
||||
|
||||
return jsVal
|
||||
})
|
||||
}
|
||||
|
||||
// searchKBMethod implements ctx.search.KB(query, options?)
|
||||
// Options:
|
||||
// - collections: string[] - collection IDs
|
||||
// - threshold: number - similarity threshold (0-1)
|
||||
// - limit: number - max results
|
||||
// - graph: boolean - enable graph association
|
||||
// - rerank: { top_n: number } - rerank options
|
||||
func (ctx *Context) searchKBMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
args := info.Args()
|
||||
|
||||
// Validate arguments
|
||||
if len(args) < 1 {
|
||||
return bridge.JsException(v8ctx, "KB requires query parameter")
|
||||
}
|
||||
|
||||
// Get query string
|
||||
if !args[0].IsString() {
|
||||
return bridge.JsException(v8ctx, "query must be a string")
|
||||
}
|
||||
query := args[0].String()
|
||||
|
||||
// Parse options (optional)
|
||||
var opts map[string]interface{}
|
||||
if len(args) >= 2 && !args[1].IsUndefined() && !args[1].IsNull() {
|
||||
goVal, err := bridge.GoValue(args[1], v8ctx)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "invalid options: "+err.Error())
|
||||
}
|
||||
if optsMap, ok := goVal.(map[string]interface{}); ok {
|
||||
opts = optsMap
|
||||
}
|
||||
}
|
||||
|
||||
// Get search API
|
||||
searchAPI := ctx.Search()
|
||||
if searchAPI == nil {
|
||||
return bridge.JsException(v8ctx, "search API not available")
|
||||
}
|
||||
|
||||
// Execute search
|
||||
result := searchAPI.KB(query, opts)
|
||||
|
||||
// Convert result to JS value
|
||||
jsVal, err := bridge.JsValue(v8ctx, result)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "failed to convert result: "+err.Error())
|
||||
}
|
||||
|
||||
return jsVal
|
||||
})
|
||||
}
|
||||
|
||||
// searchDBMethod implements ctx.search.DB(query, options?)
|
||||
// Options:
|
||||
// - models: string[] - model IDs
|
||||
// - wheres: Where[] - pre-defined filters (GOU QueryDSL Where format)
|
||||
// - orders: Order[] - sort orders (GOU QueryDSL Order format)
|
||||
// - select: string[] - fields to return
|
||||
// - limit: number - max results
|
||||
// - rerank: { top_n: number } - rerank options
|
||||
func (ctx *Context) searchDBMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
args := info.Args()
|
||||
|
||||
// Validate arguments
|
||||
if len(args) < 1 {
|
||||
return bridge.JsException(v8ctx, "DB requires query parameter")
|
||||
}
|
||||
|
||||
// Get query string
|
||||
if !args[0].IsString() {
|
||||
return bridge.JsException(v8ctx, "query must be a string")
|
||||
}
|
||||
query := args[0].String()
|
||||
|
||||
// Parse options (optional)
|
||||
var opts map[string]interface{}
|
||||
if len(args) >= 2 && !args[1].IsUndefined() && !args[1].IsNull() {
|
||||
goVal, err := bridge.GoValue(args[1], v8ctx)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "invalid options: "+err.Error())
|
||||
}
|
||||
if optsMap, ok := goVal.(map[string]interface{}); ok {
|
||||
opts = optsMap
|
||||
}
|
||||
}
|
||||
|
||||
// Get search API
|
||||
searchAPI := ctx.Search()
|
||||
if searchAPI == nil {
|
||||
return bridge.JsException(v8ctx, "search API not available")
|
||||
}
|
||||
|
||||
// Execute search
|
||||
result := searchAPI.DB(query, opts)
|
||||
|
||||
// Convert result to JS value
|
||||
jsVal, err := bridge.JsValue(v8ctx, result)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "failed to convert result: "+err.Error())
|
||||
}
|
||||
|
||||
return jsVal
|
||||
})
|
||||
}
|
||||
|
||||
// searchAllMethod implements ctx.search.All(requests)
|
||||
// Waits for all searches to complete (like Promise.all)
|
||||
// Each request should have:
|
||||
// - type: string - "web", "kb", or "db"
|
||||
// - query: string - search query
|
||||
// - ... other type-specific options
|
||||
func (ctx *Context) searchAllMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
args := info.Args()
|
||||
|
||||
// Validate arguments
|
||||
if len(args) < 1 {
|
||||
return bridge.JsException(v8ctx, "All requires requests parameter")
|
||||
}
|
||||
|
||||
// Parse requests array
|
||||
goVal, err := bridge.GoValue(args[0], v8ctx)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "invalid requests: "+err.Error())
|
||||
}
|
||||
|
||||
requestsArray, ok := goVal.([]interface{})
|
||||
if !ok {
|
||||
return bridge.JsException(v8ctx, "requests must be an array")
|
||||
}
|
||||
|
||||
// Get search API
|
||||
searchAPI := ctx.Search()
|
||||
if searchAPI == nil {
|
||||
return bridge.JsException(v8ctx, "search API not available")
|
||||
}
|
||||
|
||||
// Execute parallel search
|
||||
results := searchAPI.All(requestsArray)
|
||||
|
||||
// Convert results to JS value
|
||||
jsVal, err := bridge.JsValue(v8ctx, results)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "failed to convert results: "+err.Error())
|
||||
}
|
||||
|
||||
return jsVal
|
||||
})
|
||||
}
|
||||
|
||||
// searchAnyMethod implements ctx.search.Any(requests)
|
||||
// Returns when any search succeeds with results (like Promise.any)
|
||||
// Each request should have:
|
||||
// - type: string - "web", "kb", or "db"
|
||||
// - query: string - search query
|
||||
// - ... other type-specific options
|
||||
func (ctx *Context) searchAnyMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
args := info.Args()
|
||||
|
||||
// Validate arguments
|
||||
if len(args) < 1 {
|
||||
return bridge.JsException(v8ctx, "Any requires requests parameter")
|
||||
}
|
||||
|
||||
// Parse requests array
|
||||
goVal, err := bridge.GoValue(args[0], v8ctx)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "invalid requests: "+err.Error())
|
||||
}
|
||||
|
||||
requestsArray, ok := goVal.([]interface{})
|
||||
if !ok {
|
||||
return bridge.JsException(v8ctx, "requests must be an array")
|
||||
}
|
||||
|
||||
// Get search API
|
||||
searchAPI := ctx.Search()
|
||||
if searchAPI == nil {
|
||||
return bridge.JsException(v8ctx, "search API not available")
|
||||
}
|
||||
|
||||
// Execute parallel search
|
||||
results := searchAPI.Any(requestsArray)
|
||||
|
||||
// Convert results to JS value
|
||||
jsVal, err := bridge.JsValue(v8ctx, results)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "failed to convert results: "+err.Error())
|
||||
}
|
||||
|
||||
return jsVal
|
||||
})
|
||||
}
|
||||
|
||||
// searchRaceMethod implements ctx.search.Race(requests)
|
||||
// Returns when any search completes (like Promise.race)
|
||||
// Each request should have:
|
||||
// - type: string - "web", "kb", or "db"
|
||||
// - query: string - search query
|
||||
// - ... other type-specific options
|
||||
func (ctx *Context) searchRaceMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
v8ctx := info.Context()
|
||||
args := info.Args()
|
||||
|
||||
// Validate arguments
|
||||
if len(args) < 1 {
|
||||
return bridge.JsException(v8ctx, "Race requires requests parameter")
|
||||
}
|
||||
|
||||
// Parse requests array
|
||||
goVal, err := bridge.GoValue(args[0], v8ctx)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "invalid requests: "+err.Error())
|
||||
}
|
||||
|
||||
requestsArray, ok := goVal.([]interface{})
|
||||
if !ok {
|
||||
return bridge.JsException(v8ctx, "requests must be an array")
|
||||
}
|
||||
|
||||
// Get search API
|
||||
searchAPI := ctx.Search()
|
||||
if searchAPI == nil {
|
||||
return bridge.JsException(v8ctx, "search API not available")
|
||||
}
|
||||
|
||||
// Execute parallel search
|
||||
results := searchAPI.Race(requestsArray)
|
||||
|
||||
// Convert results to JS value
|
||||
jsVal, err := bridge.JsValue(v8ctx, results)
|
||||
if err != nil {
|
||||
return bridge.JsException(v8ctx, "failed to convert results: "+err.Error())
|
||||
}
|
||||
|
||||
return jsVal
|
||||
})
|
||||
}
|
||||
|
|
|
|||
342
agent/context/jsapi_search_test.go
Normal file
342
agent/context/jsapi_search_test.go
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
package context_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// Note: SearchAPIFactory is set by assistant.init() with proper config getter
|
||||
// We import assistant package to ensure init() runs before tests
|
||||
|
||||
// newSearchTestContext creates a Context for search JSAPI testing
|
||||
func newSearchTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &oauthTypes.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
ClientID: "test-client-id",
|
||||
Scope: "openid profile email",
|
||||
SessionID: "test-session-id",
|
||||
UserID: "test-user-123",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
// getResponseContent extracts the content from the first assistant message
|
||||
func getResponseContent(res *context.HookCreateResponse) string {
|
||||
if res == nil || len(res.Messages) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, msg := range res.Messages {
|
||||
if msg.Role == "assistant" {
|
||||
if content, ok := msg.Content.(string); ok {
|
||||
return content
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestSearchJSAPI_Web tests ctx.search.Web() via Create Hook
|
||||
func TestSearchJSAPI_Web(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Load the search-jsapi test assistant
|
||||
agent, err := assistant.Get("tests.search-jsapi")
|
||||
require.NoError(t, err, "Failed to get tests.search-jsapi assistant")
|
||||
require.NotNil(t, agent.HookScript, "The tests.search-jsapi assistant has no script")
|
||||
|
||||
ctx := newSearchTestContext("chat-search-web", "tests.search-jsapi")
|
||||
|
||||
// Call Create hook with test:web command
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:web Yao App Engine"}})
|
||||
require.NoError(t, err, "Create hook failed")
|
||||
require.NotNil(t, res, "Expected non-nil response")
|
||||
|
||||
// Get response content from messages
|
||||
content := getResponseContent(res)
|
||||
require.NotEmpty(t, content, "Expected response content")
|
||||
|
||||
// Parse the JSON response
|
||||
var result types.Result
|
||||
err = json.Unmarshal([]byte(content), &result)
|
||||
require.NoError(t, err, "Response should be valid JSON: %s", content)
|
||||
|
||||
// Verify result
|
||||
assert.Equal(t, types.SearchTypeWeb, result.Type, "type should be web")
|
||||
assert.Equal(t, "Yao App Engine", result.Query, "query should match")
|
||||
assert.Empty(t, result.Error, "should not have error: %s", result.Error)
|
||||
assert.Greater(t, len(result.Items), 0, "should have items")
|
||||
|
||||
t.Logf("Web search returned %d items", len(result.Items))
|
||||
for i, item := range result.Items {
|
||||
if i < 3 {
|
||||
t.Logf(" [%s] %s - %s", item.CitationID, item.Title, item.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchJSAPI_WebWithSites tests ctx.search.Web() with site restriction
|
||||
func TestSearchJSAPI_WebWithSites(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.search-jsapi")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent.HookScript)
|
||||
|
||||
ctx := newSearchTestContext("chat-search-web-sites", "tests.search-jsapi")
|
||||
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:web_sites Yao App Engine"}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
content := getResponseContent(res)
|
||||
require.NotEmpty(t, content, "Expected response content")
|
||||
|
||||
var result types.Result
|
||||
err = json.Unmarshal([]byte(content), &result)
|
||||
require.NoError(t, err, "Response should be valid JSON: %s", content)
|
||||
|
||||
assert.Equal(t, types.SearchTypeWeb, result.Type)
|
||||
assert.Empty(t, result.Error, "should not have error: %s", result.Error)
|
||||
assert.Greater(t, len(result.Items), 0, "should have items")
|
||||
|
||||
// Verify all results are from allowed sites
|
||||
allowedSites := []string{"github.com", "yaoapps.com"}
|
||||
for _, item := range result.Items {
|
||||
isAllowed := false
|
||||
for _, site := range allowedSites {
|
||||
if strings.Contains(item.URL, site) {
|
||||
isAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, isAllowed, "URL %s should be from allowed sites", item.URL)
|
||||
}
|
||||
|
||||
t.Logf("Site-restricted search returned %d items", len(result.Items))
|
||||
}
|
||||
|
||||
// TestSearchJSAPI_KB tests ctx.search.KB() via Create Hook (skeleton)
|
||||
func TestSearchJSAPI_KB(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.search-jsapi")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent.HookScript)
|
||||
|
||||
ctx := newSearchTestContext("chat-search-kb", "tests.search-jsapi")
|
||||
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:kb test query"}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
content := getResponseContent(res)
|
||||
require.NotEmpty(t, content, "Expected response content")
|
||||
|
||||
var result types.Result
|
||||
err = json.Unmarshal([]byte(content), &result)
|
||||
require.NoError(t, err, "Response should be valid JSON: %s", content)
|
||||
|
||||
assert.Equal(t, types.SearchTypeKB, result.Type, "type should be kb")
|
||||
assert.Equal(t, "test query", result.Query, "query should match")
|
||||
assert.Equal(t, types.SourceHook, result.Source, "source should be hook")
|
||||
}
|
||||
|
||||
// TestSearchJSAPI_DB tests ctx.search.DB() via Create Hook (skeleton)
|
||||
func TestSearchJSAPI_DB(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.search-jsapi")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent.HookScript)
|
||||
|
||||
ctx := newSearchTestContext("chat-search-db", "tests.search-jsapi")
|
||||
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:db test query"}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
content := getResponseContent(res)
|
||||
require.NotEmpty(t, content, "Expected response content")
|
||||
|
||||
var result types.Result
|
||||
err = json.Unmarshal([]byte(content), &result)
|
||||
require.NoError(t, err, "Response should be valid JSON: %s", content)
|
||||
|
||||
assert.Equal(t, types.SearchTypeDB, result.Type, "type should be db")
|
||||
assert.Equal(t, "test query", result.Query, "query should match")
|
||||
assert.Equal(t, types.SourceHook, result.Source, "source should be hook")
|
||||
}
|
||||
|
||||
// TestSearchJSAPI_All tests ctx.search.All() via Create Hook
|
||||
func TestSearchJSAPI_All(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.search-jsapi")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent.HookScript)
|
||||
|
||||
ctx := newSearchTestContext("chat-search-all", "tests.search-jsapi")
|
||||
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:all"}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
content := getResponseContent(res)
|
||||
require.NotEmpty(t, content, "Expected response content")
|
||||
|
||||
// Parse as array of results
|
||||
var results []*types.Result
|
||||
err = json.Unmarshal([]byte(content), &results)
|
||||
require.NoError(t, err, "Response should be valid JSON array: %s", content)
|
||||
|
||||
assert.Len(t, results, 2, "should have 2 results")
|
||||
|
||||
// Both should succeed
|
||||
successCount := 0
|
||||
totalItems := 0
|
||||
for _, r := range results {
|
||||
if r != nil && r.Error == "" {
|
||||
successCount++
|
||||
totalItems += len(r.Items)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, successCount, "both searches should succeed")
|
||||
assert.Greater(t, totalItems, 0, "should have items")
|
||||
|
||||
t.Logf("All search: %d results, %d total items", len(results), totalItems)
|
||||
}
|
||||
|
||||
// TestSearchJSAPI_Any tests ctx.search.Any() via Create Hook
|
||||
func TestSearchJSAPI_Any(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.search-jsapi")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent.HookScript)
|
||||
|
||||
ctx := newSearchTestContext("chat-search-any", "tests.search-jsapi")
|
||||
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:any"}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
content := getResponseContent(res)
|
||||
require.NotEmpty(t, content, "Expected response content")
|
||||
|
||||
var results []*types.Result
|
||||
err = json.Unmarshal([]byte(content), &results)
|
||||
require.NoError(t, err, "Response should be valid JSON array: %s", content)
|
||||
|
||||
assert.Len(t, results, 2, "should have 2 result slots")
|
||||
|
||||
// At least one should have results
|
||||
hasSuccess := false
|
||||
for _, r := range results {
|
||||
if r != nil && len(r.Items) > 0 && r.Error == "" {
|
||||
hasSuccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasSuccess, "at least one search should succeed")
|
||||
|
||||
t.Logf("Any search completed")
|
||||
}
|
||||
|
||||
// TestSearchJSAPI_Race tests ctx.search.Race() via Create Hook
|
||||
func TestSearchJSAPI_Race(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.search-jsapi")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent.HookScript)
|
||||
|
||||
ctx := newSearchTestContext("chat-search-race", "tests.search-jsapi")
|
||||
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:race"}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
content := getResponseContent(res)
|
||||
require.NotEmpty(t, content, "Expected response content")
|
||||
|
||||
var results []*types.Result
|
||||
err = json.Unmarshal([]byte(content), &results)
|
||||
require.NoError(t, err, "Response should be valid JSON array: %s", content)
|
||||
|
||||
assert.Len(t, results, 2, "should have 2 result slots")
|
||||
|
||||
// At least one should have completed
|
||||
hasResult := false
|
||||
for _, r := range results {
|
||||
if r != nil {
|
||||
hasResult = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasResult, "at least one search should complete")
|
||||
|
||||
t.Logf("Race search completed")
|
||||
}
|
||||
|
||||
// TestSearchJSAPI_InvalidCommand tests invalid test command
|
||||
func TestSearchJSAPI_InvalidCommand(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.search-jsapi")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent.HookScript)
|
||||
|
||||
ctx := newSearchTestContext("chat-search-invalid", "tests.search-jsapi")
|
||||
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "invalid command"}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
content := getResponseContent(res)
|
||||
assert.Contains(t, content, "Invalid test command", "should return error message")
|
||||
}
|
||||
|
||||
// TestSearchJSAPI_UnknownMethod tests unknown test method
|
||||
func TestSearchJSAPI_UnknownMethod(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.search-jsapi")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent.HookScript)
|
||||
|
||||
ctx := newSearchTestContext("chat-search-unknown", "tests.search-jsapi")
|
||||
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "test:unknown"}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
|
||||
content := getResponseContent(res)
|
||||
assert.Contains(t, content, "Unknown test method", "should return error message")
|
||||
}
|
||||
|
|
@ -892,26 +892,44 @@ The Search module is exposed via `ctx.search` object in hook scripts.
|
|||
To avoid circular dependency between `context` and `search` packages:
|
||||
|
||||
```
|
||||
agent/context/jsapi_search.go agent/search/jsapi.go
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ SearchAPI interface │◄──────│ JSAPI struct │
|
||||
│ SearchAPIFactory var │ │ (implements SearchAPI) │
|
||||
│ ctx.Search() method │ │ SetJSAPIFactory() │
|
||||
└─────────────────────────┘ └─────────────────────────┘
|
||||
▲ │
|
||||
│ │
|
||||
└──────────────────────────────────┘
|
||||
agent/context/jsapi_search.go agent/search/jsapi.go
|
||||
┌─────────────────────────────┐ ┌─────────────────────────┐
|
||||
│ SearchAPI interface │◄───────│ JSAPI struct │
|
||||
│ SearchAPIFactory var │ │ (implements SearchAPI) │
|
||||
│ V8 binding methods: │ │ NewJSAPI() │
|
||||
│ newSearchObject() │ │ Web/KB/DB() │
|
||||
│ searchWebMethod() │ │ All/Any/Race() │
|
||||
│ searchKBMethod() │ │ buildRequest() │
|
||||
│ searchDBMethod() │ │ parseRequests() │
|
||||
│ searchAllMethod() │ │ ConfigGetter type │
|
||||
│ searchAnyMethod() │ │ SetJSAPIFactory() │
|
||||
│ searchRaceMethod() │ └─────────────────────────┘
|
||||
└─────────────────────────────┘ │
|
||||
▲ │
|
||||
│ │
|
||||
└───────────────────────────────────────┘
|
||||
Factory registration
|
||||
(in assistant/init)
|
||||
(with ConfigGetter in assistant/init)
|
||||
|
||||
agent/context/jsapi.go
|
||||
┌─────────────────────────────┐
|
||||
│ NewObject() │
|
||||
│ jsObject.Set("search", │
|
||||
│ ctx.newSearchObject()) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key Files:**
|
||||
|
||||
| File | Description |
|
||||
| ----------------------------- | ------------------------------ |
|
||||
| `context/jsapi_search.go` | SearchAPI interface definition |
|
||||
| `search/jsapi.go` | JSAPI implementation |
|
||||
| `assistant/assistant.go:init` | Factory registration |
|
||||
| File | Description |
|
||||
| -------------------------------- | ---------------------------------------------------------------- |
|
||||
| `context/jsapi_search.go` | SearchAPI interface + V8 binding methods |
|
||||
| `context/jsapi_search_test.go` | Integration tests (real V8 calls via test assistant) |
|
||||
| `context/jsapi.go` | Mount search object to ctx |
|
||||
| `search/jsapi.go` | JSAPI implementation (calls Searcher) + ConfigGetter |
|
||||
| `search/jsapi_test.go` | Black-box unit tests |
|
||||
| `assistant/assistant.go:init` | Factory registration via SetJSAPIFactory(ConfigGetter) |
|
||||
| `assistants/tests/search-jsapi/` | Test assistant for JSAPI integration tests (Create hook, no LLM) |
|
||||
|
||||
### API Methods
|
||||
|
||||
|
|
|
|||
|
|
@ -8,17 +8,15 @@ import (
|
|||
// JSAPI implements context.SearchAPI interface
|
||||
// Provides ctx.search.Web(), ctx.search.KB(), ctx.search.DB(), ctx.search.All(), ctx.search.Any(), ctx.search.Race()
|
||||
type JSAPI struct {
|
||||
ctx *context.Context
|
||||
config *types.Config
|
||||
uses *Uses
|
||||
ctx *context.Context
|
||||
searcher *Searcher
|
||||
}
|
||||
|
||||
// NewJSAPI creates a new search JSAPI instance
|
||||
func NewJSAPI(ctx *context.Context, config *types.Config, uses *Uses) *JSAPI {
|
||||
return &JSAPI{
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
uses: uses,
|
||||
ctx: ctx,
|
||||
searcher: New(config, uses),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -29,15 +27,9 @@ func NewJSAPI(ctx *context.Context, config *types.Config, uses *Uses) *JSAPI {
|
|||
// - time_range: string - "day", "week", "month", "year"
|
||||
// - rerank: map[string]interface{} - rerank options
|
||||
func (api *JSAPI) Web(query string, opts map[string]interface{}) interface{} {
|
||||
// TODO: Implement web search
|
||||
// 1. Build Request from query and opts
|
||||
// 2. Call web handler
|
||||
// 3. Return Result or error
|
||||
return &types.Result{
|
||||
Type: types.SearchTypeWeb,
|
||||
Query: query,
|
||||
Error: "not implemented",
|
||||
}
|
||||
req := api.buildRequest(types.SearchTypeWeb, query, opts)
|
||||
result, _ := api.searcher.Search(api.ctx, req)
|
||||
return result
|
||||
}
|
||||
|
||||
// KB executes knowledge base search
|
||||
|
|
@ -48,15 +40,9 @@ func (api *JSAPI) Web(query string, opts map[string]interface{}) interface{} {
|
|||
// - graph: bool - enable graph association
|
||||
// - rerank: map[string]interface{} - rerank options
|
||||
func (api *JSAPI) KB(query string, opts map[string]interface{}) interface{} {
|
||||
// TODO: Implement KB search
|
||||
// 1. Build Request from query and opts
|
||||
// 2. Call KB handler
|
||||
// 3. Return Result or error
|
||||
return &types.Result{
|
||||
Type: types.SearchTypeKB,
|
||||
Query: query,
|
||||
Error: "not implemented",
|
||||
}
|
||||
req := api.buildRequest(types.SearchTypeKB, query, opts)
|
||||
result, _ := api.searcher.Search(api.ctx, req)
|
||||
return result
|
||||
}
|
||||
|
||||
// DB executes database search
|
||||
|
|
@ -68,15 +54,9 @@ func (api *JSAPI) KB(query string, opts map[string]interface{}) interface{} {
|
|||
// - limit: int - max results
|
||||
// - rerank: map[string]interface{} - rerank options
|
||||
func (api *JSAPI) DB(query string, opts map[string]interface{}) interface{} {
|
||||
// TODO: Implement DB search
|
||||
// 1. Build Request from query and opts
|
||||
// 2. Call DB handler
|
||||
// 3. Return Result or error
|
||||
return &types.Result{
|
||||
Type: types.SearchTypeDB,
|
||||
Query: query,
|
||||
Error: "not implemented",
|
||||
}
|
||||
req := api.buildRequest(types.SearchTypeDB, query, opts)
|
||||
result, _ := api.searcher.Search(api.ctx, req)
|
||||
return result
|
||||
}
|
||||
|
||||
// All executes all searches and waits for all to complete (like Promise.all)
|
||||
|
|
@ -85,17 +65,9 @@ func (api *JSAPI) DB(query string, opts map[string]interface{}) interface{} {
|
|||
// - query: string - search query
|
||||
// - ... other type-specific options
|
||||
func (api *JSAPI) All(requests []interface{}) []interface{} {
|
||||
// TODO: Implement All search
|
||||
// 1. Parse requests into []Request
|
||||
// 2. Call Searcher.All()
|
||||
// 3. Return []Result
|
||||
results := make([]interface{}, len(requests))
|
||||
for i := range requests {
|
||||
results[i] = &types.Result{
|
||||
Error: "not implemented",
|
||||
}
|
||||
}
|
||||
return results
|
||||
reqs := api.parseRequests(requests)
|
||||
results, _ := api.searcher.All(api.ctx, reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// Any returns as soon as any search succeeds with results (like Promise.any)
|
||||
|
|
@ -104,17 +76,9 @@ func (api *JSAPI) All(requests []interface{}) []interface{} {
|
|||
// - query: string - search query
|
||||
// - ... other type-specific options
|
||||
func (api *JSAPI) Any(requests []interface{}) []interface{} {
|
||||
// TODO: Implement Any search
|
||||
// 1. Parse requests into []Request
|
||||
// 2. Call Searcher.Any()
|
||||
// 3. Return []Result
|
||||
results := make([]interface{}, len(requests))
|
||||
for i := range requests {
|
||||
results[i] = &types.Result{
|
||||
Error: "not implemented",
|
||||
}
|
||||
}
|
||||
return results
|
||||
reqs := api.parseRequests(requests)
|
||||
results, _ := api.searcher.Any(api.ctx, reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// Race returns as soon as any search completes (like Promise.race)
|
||||
|
|
@ -123,32 +87,143 @@ func (api *JSAPI) Any(requests []interface{}) []interface{} {
|
|||
// - query: string - search query
|
||||
// - ... other type-specific options
|
||||
func (api *JSAPI) Race(requests []interface{}) []interface{} {
|
||||
// TODO: Implement Race search
|
||||
// 1. Parse requests into []Request
|
||||
// 2. Call Searcher.Race()
|
||||
// 3. Return []Result
|
||||
results := make([]interface{}, len(requests))
|
||||
for i := range requests {
|
||||
results[i] = &types.Result{
|
||||
Error: "not implemented",
|
||||
}
|
||||
}
|
||||
return results
|
||||
reqs := api.parseRequests(requests)
|
||||
results, _ := api.searcher.Race(api.ctx, reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// init registers the JSAPI factory with context package
|
||||
func init() {
|
||||
// Note: The actual factory is set by assistant package during initialization
|
||||
// This avoids circular dependency: context -> search -> context
|
||||
// See: assistant/assistant.go init()
|
||||
// buildRequest builds a Request from query and options
|
||||
func (api *JSAPI) buildRequest(searchType types.SearchType, query string, opts map[string]interface{}) *types.Request {
|
||||
req := &types.Request{
|
||||
Type: searchType,
|
||||
Query: query,
|
||||
Source: types.SourceHook, // JSAPI calls are from hooks
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
return req
|
||||
}
|
||||
|
||||
// Common options
|
||||
if limit, ok := opts["limit"].(float64); ok {
|
||||
req.Limit = int(limit)
|
||||
} else if limit, ok := opts["limit"].(int); ok {
|
||||
req.Limit = limit
|
||||
}
|
||||
|
||||
// Web-specific options
|
||||
if searchType == types.SearchTypeWeb {
|
||||
if sites, ok := opts["sites"].([]interface{}); ok {
|
||||
req.Sites = toStringSlice(sites)
|
||||
}
|
||||
if timeRange, ok := opts["time_range"].(string); ok {
|
||||
req.TimeRange = timeRange
|
||||
}
|
||||
}
|
||||
|
||||
// KB-specific options
|
||||
if searchType == types.SearchTypeKB {
|
||||
if collections, ok := opts["collections"].([]interface{}); ok {
|
||||
req.Collections = toStringSlice(collections)
|
||||
}
|
||||
if threshold, ok := opts["threshold"].(float64); ok {
|
||||
req.Threshold = threshold
|
||||
}
|
||||
if graph, ok := opts["graph"].(bool); ok {
|
||||
req.Graph = graph
|
||||
}
|
||||
}
|
||||
|
||||
// DB-specific options
|
||||
if searchType == types.SearchTypeDB {
|
||||
if models, ok := opts["models"].([]interface{}); ok {
|
||||
req.Models = toStringSlice(models)
|
||||
}
|
||||
if selectFields, ok := opts["select"].([]interface{}); ok {
|
||||
req.Select = toStringSlice(selectFields)
|
||||
}
|
||||
// Note: wheres and orders are more complex, handled by QueryDSL generator
|
||||
}
|
||||
|
||||
// Rerank options
|
||||
if rerankOpts, ok := opts["rerank"].(map[string]interface{}); ok {
|
||||
req.Rerank = &types.RerankOptions{}
|
||||
if topN, ok := rerankOpts["top_n"].(float64); ok {
|
||||
req.Rerank.TopN = int(topN)
|
||||
} else if topN, ok := rerankOpts["top_n"].(int); ok {
|
||||
req.Rerank.TopN = topN
|
||||
}
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// parseRequests parses an array of request objects into typed Requests
|
||||
func (api *JSAPI) parseRequests(requests []interface{}) []*types.Request {
|
||||
reqs := make([]*types.Request, 0, len(requests))
|
||||
for _, r := range requests {
|
||||
reqMap, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get type
|
||||
typeStr, ok := reqMap["type"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
searchType := types.SearchType(typeStr)
|
||||
|
||||
// Get query
|
||||
query, ok := reqMap["query"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build request with remaining options
|
||||
req := api.buildRequest(searchType, query, reqMap)
|
||||
reqs = append(reqs, req)
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
|
||||
// convertResults converts typed Results to interface slice for JS
|
||||
func (api *JSAPI) convertResults(results []*types.Result) []interface{} {
|
||||
out := make([]interface{}, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toStringSlice converts []interface{} to []string
|
||||
func toStringSlice(arr []interface{}) []string {
|
||||
result := make([]string, 0, len(arr))
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ConfigGetter is a function type that retrieves search config and uses for an assistant
|
||||
type ConfigGetter func(assistantID string) (*types.Config, *Uses)
|
||||
|
||||
// configGetter is set by assistant package during initialization
|
||||
var configGetter ConfigGetter
|
||||
|
||||
// SetJSAPIFactory sets the factory function for creating SearchAPI instances
|
||||
// Called by assistant package during initialization
|
||||
func SetJSAPIFactory() {
|
||||
// getter: function to get search config and uses from assistant ID
|
||||
func SetJSAPIFactory(getter ConfigGetter) {
|
||||
configGetter = getter
|
||||
context.SearchAPIFactory = func(ctx *context.Context) context.SearchAPI {
|
||||
// Get config and uses from context or use defaults
|
||||
// TODO: Get actual config from assistant
|
||||
return NewJSAPI(ctx, nil, nil)
|
||||
var config *types.Config
|
||||
var uses *Uses
|
||||
if configGetter != nil && ctx.AssistantID != "" {
|
||||
config, uses = configGetter(ctx.AssistantID)
|
||||
}
|
||||
return NewJSAPI(ctx, config, uses)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
328
agent/search/jsapi_test.go
Normal file
328
agent/search/jsapi_test.go
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
package search_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/search"
|
||||
"github.com/yaoapp/yao/agent/search/types"
|
||||
)
|
||||
|
||||
func TestNewJSAPI(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, nil, nil)
|
||||
require.NotNil(t, api)
|
||||
}
|
||||
|
||||
func TestJSAPI_Web(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, &types.Config{
|
||||
Web: &types.WebConfig{Provider: "tavily"},
|
||||
}, &search.Uses{Web: "builtin"})
|
||||
|
||||
result := api.Web("test query", nil)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*types.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, types.SearchTypeWeb, r.Type)
|
||||
assert.Equal(t, "test query", r.Query)
|
||||
assert.Equal(t, types.SourceHook, r.Source)
|
||||
}
|
||||
|
||||
func TestJSAPI_Web_WithOptions(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, &types.Config{
|
||||
Web: &types.WebConfig{Provider: "tavily"},
|
||||
}, &search.Uses{Web: "builtin"})
|
||||
|
||||
opts := map[string]interface{}{
|
||||
"limit": float64(5),
|
||||
"sites": []interface{}{"github.com", "stackoverflow.com"},
|
||||
"time_range": "week",
|
||||
}
|
||||
|
||||
result := api.Web("golang concurrency", opts)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*types.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, types.SearchTypeWeb, r.Type)
|
||||
assert.Equal(t, "golang concurrency", r.Query)
|
||||
}
|
||||
|
||||
func TestJSAPI_KB(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, &types.Config{
|
||||
KB: &types.KBConfig{Collections: []string{"docs"}},
|
||||
}, nil)
|
||||
|
||||
result := api.KB("test query", nil)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*types.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, types.SearchTypeKB, r.Type)
|
||||
assert.Equal(t, "test query", r.Query)
|
||||
assert.Equal(t, types.SourceHook, r.Source)
|
||||
}
|
||||
|
||||
func TestJSAPI_KB_WithOptions(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, &types.Config{
|
||||
KB: &types.KBConfig{Collections: []string{"docs"}},
|
||||
}, nil)
|
||||
|
||||
opts := map[string]interface{}{
|
||||
"collections": []interface{}{"docs", "faq"},
|
||||
"threshold": 0.8,
|
||||
"limit": float64(10),
|
||||
"graph": true,
|
||||
}
|
||||
|
||||
result := api.KB("knowledge base query", opts)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*types.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, types.SearchTypeKB, r.Type)
|
||||
assert.Equal(t, "knowledge base query", r.Query)
|
||||
}
|
||||
|
||||
func TestJSAPI_DB(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, &types.Config{
|
||||
DB: &types.DBConfig{Models: []string{"product"}},
|
||||
}, &search.Uses{QueryDSL: "builtin"})
|
||||
|
||||
result := api.DB("test query", nil)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*types.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, types.SearchTypeDB, r.Type)
|
||||
assert.Equal(t, "test query", r.Query)
|
||||
assert.Equal(t, types.SourceHook, r.Source)
|
||||
}
|
||||
|
||||
func TestJSAPI_DB_WithOptions(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, &types.Config{
|
||||
DB: &types.DBConfig{Models: []string{"product"}},
|
||||
}, &search.Uses{QueryDSL: "builtin"})
|
||||
|
||||
opts := map[string]interface{}{
|
||||
"models": []interface{}{"product", "order"},
|
||||
"select": []interface{}{"id", "name", "price"},
|
||||
"limit": float64(20),
|
||||
}
|
||||
|
||||
result := api.DB("database query", opts)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*types.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, types.SearchTypeDB, r.Type)
|
||||
assert.Equal(t, "database query", r.Query)
|
||||
}
|
||||
|
||||
func TestJSAPI_All(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, &types.Config{
|
||||
KB: &types.KBConfig{Collections: []string{"docs"}},
|
||||
DB: &types.DBConfig{Models: []string{"product"}},
|
||||
}, nil)
|
||||
|
||||
requests := []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "kb",
|
||||
"query": "KB query",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "db",
|
||||
"query": "DB query",
|
||||
},
|
||||
}
|
||||
|
||||
results := api.All(requests)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// First result (KB)
|
||||
r0, ok := results[0].(*types.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, types.SearchTypeKB, r0.Type)
|
||||
assert.Equal(t, "KB query", r0.Query)
|
||||
|
||||
// Second result (DB)
|
||||
r1, ok := results[1].(*types.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, types.SearchTypeDB, r1.Type)
|
||||
assert.Equal(t, "DB query", r1.Query)
|
||||
}
|
||||
|
||||
func TestJSAPI_Any(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, &types.Config{
|
||||
KB: &types.KBConfig{Collections: []string{"docs"}},
|
||||
DB: &types.DBConfig{Models: []string{"product"}},
|
||||
}, nil)
|
||||
|
||||
requests := []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "kb",
|
||||
"query": "KB query",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "db",
|
||||
"query": "DB query",
|
||||
},
|
||||
}
|
||||
|
||||
results := api.Any(requests)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// At least one result should be present
|
||||
hasResult := false
|
||||
for _, r := range results {
|
||||
if r != nil {
|
||||
hasResult = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasResult)
|
||||
}
|
||||
|
||||
func TestJSAPI_Race(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, &types.Config{
|
||||
KB: &types.KBConfig{Collections: []string{"docs"}},
|
||||
DB: &types.DBConfig{Models: []string{"product"}},
|
||||
}, nil)
|
||||
|
||||
requests := []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "kb",
|
||||
"query": "KB query",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "db",
|
||||
"query": "DB query",
|
||||
},
|
||||
}
|
||||
|
||||
results := api.Race(requests)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// At least one result should be present
|
||||
hasResult := false
|
||||
for _, r := range results {
|
||||
if r != nil {
|
||||
hasResult = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasResult)
|
||||
}
|
||||
|
||||
func TestJSAPI_All_Empty(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, nil, nil)
|
||||
results := api.All([]interface{}{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestJSAPI_Any_Empty(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, nil, nil)
|
||||
results := api.Any([]interface{}{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestJSAPI_Race_Empty(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, nil, nil)
|
||||
results := api.Race([]interface{}{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestJSAPI_Web_WithRerank(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, &types.Config{
|
||||
Web: &types.WebConfig{Provider: "tavily"},
|
||||
}, &search.Uses{Web: "builtin"})
|
||||
|
||||
opts := map[string]interface{}{
|
||||
"limit": float64(10),
|
||||
"rerank": map[string]interface{}{
|
||||
"top_n": float64(5),
|
||||
},
|
||||
}
|
||||
|
||||
result := api.Web("test query", opts)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*types.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, types.SearchTypeWeb, r.Type)
|
||||
}
|
||||
|
||||
func TestJSAPI_All_InvalidRequests(t *testing.T) {
|
||||
api := search.NewJSAPI(nil, &types.Config{
|
||||
Web: &types.WebConfig{Provider: "tavily"},
|
||||
}, &search.Uses{Web: "builtin"})
|
||||
|
||||
// Mix of invalid and valid requests
|
||||
requests := []interface{}{
|
||||
"invalid", // Not a map
|
||||
map[string]interface{}{
|
||||
"query": "no type", // Missing type
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "web", // Missing query
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "web",
|
||||
"query": "valid query",
|
||||
},
|
||||
}
|
||||
|
||||
results := api.All(requests)
|
||||
// Only the valid request should produce a result
|
||||
assert.Len(t, results, 1)
|
||||
}
|
||||
|
||||
func TestSetJSAPIFactory(t *testing.T) {
|
||||
// Reset factory
|
||||
context.SearchAPIFactory = nil
|
||||
|
||||
// Set factory with nil getter (uses defaults)
|
||||
search.SetJSAPIFactory(nil)
|
||||
|
||||
// Verify factory is set
|
||||
require.NotNil(t, context.SearchAPIFactory)
|
||||
|
||||
// Create a mock context
|
||||
ctx := &context.Context{}
|
||||
|
||||
// Get search API
|
||||
searchAPI := context.SearchAPIFactory(ctx)
|
||||
require.NotNil(t, searchAPI)
|
||||
}
|
||||
|
||||
func TestSetJSAPIFactory_WithGetter(t *testing.T) {
|
||||
// Reset factory
|
||||
context.SearchAPIFactory = nil
|
||||
|
||||
// Set factory with custom getter
|
||||
search.SetJSAPIFactory(func(assistantID string) (*types.Config, *search.Uses) {
|
||||
if assistantID == "test-assistant" {
|
||||
return &types.Config{
|
||||
Web: &types.WebConfig{Provider: "tavily"},
|
||||
}, &search.Uses{Web: "builtin"}
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
// Verify factory is set
|
||||
require.NotNil(t, context.SearchAPIFactory)
|
||||
|
||||
// Create a context with assistant ID
|
||||
ctx := &context.Context{AssistantID: "test-assistant"}
|
||||
|
||||
// Get search API
|
||||
searchAPI := context.SearchAPIFactory(ctx)
|
||||
require.NotNil(t, searchAPI)
|
||||
}
|
||||
|
||||
func TestJSAPI_ImplementsSearchAPI(t *testing.T) {
|
||||
// Verify JSAPI implements context.SearchAPI interface
|
||||
var _ context.SearchAPI = search.NewJSAPI(nil, nil, nil)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue