From 7768ca73b3978e1989a0ff76b14d69342d7912a0 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 13 Dec 2025 16:11:30 +0800 Subject: [PATCH] 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. --- agent/assistant/assistant.go | 21 +- agent/context/jsapi.go | 3 + agent/context/jsapi_search.go | 323 +++++++++++++++++++++++++++ agent/context/jsapi_search_test.go | 342 +++++++++++++++++++++++++++++ agent/search/DESIGN.md | 48 ++-- agent/search/jsapi.go | 225 ++++++++++++------- agent/search/jsapi_test.go | 328 +++++++++++++++++++++++++++ 7 files changed, 1198 insertions(+), 92 deletions(-) create mode 100644 agent/context/jsapi_search_test.go create mode 100644 agent/search/jsapi_test.go diff --git a/agent/assistant/assistant.go b/agent/assistant/assistant.go index 26a4f146..bd6413e5 100644 --- a/agent/assistant/assistant.go +++ b/agent/assistant/assistant.go @@ -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 diff --git a/agent/context/jsapi.go b/agent/context/jsapi.go index bbe79fa1..2daa0a66 100644 --- a/agent/context/jsapi.go +++ b/agent/context/jsapi.go @@ -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 diff --git a/agent/context/jsapi_search.go b/agent/context/jsapi_search.go index 3590771b..cad17153 100644 --- a/agent/context/jsapi_search.go +++ b/agent/context/jsapi_search.go @@ -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 + }) +} diff --git a/agent/context/jsapi_search_test.go b/agent/context/jsapi_search_test.go new file mode 100644 index 00000000..cbf7c0f6 --- /dev/null +++ b/agent/context/jsapi_search_test.go @@ -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") +} diff --git a/agent/search/DESIGN.md b/agent/search/DESIGN.md index e570a4b6..b538f1d0 100644 --- a/agent/search/DESIGN.md +++ b/agent/search/DESIGN.md @@ -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 diff --git a/agent/search/jsapi.go b/agent/search/jsapi.go index 6055ae63..be0c22ea 100644 --- a/agent/search/jsapi.go +++ b/agent/search/jsapi.go @@ -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) } } diff --git a/agent/search/jsapi_test.go b/agent/search/jsapi_test.go new file mode 100644 index 00000000..92be80d4 --- /dev/null +++ b/agent/search/jsapi_test.go @@ -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) +}