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
|
return &agentCallerWrapper{ast: ast}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Search JSAPI factory
|
// Initialize Search JSAPI factory with config getter
|
||||||
search.SetJSAPIFactory()
|
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
|
// agentCallerWrapper wraps Assistant to implement AgentCaller interface
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
|
||||||
// Set mcp object
|
// Set mcp object
|
||||||
jsObject.Set("mcp", ctx.newMCPObject(v8ctx.Isolate()))
|
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)
|
// Note: Space object will be set after instance creation (requires v8ctx)
|
||||||
|
|
||||||
// Create instance
|
// Create instance
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
package context
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||||
|
"rogchap.com/v8go"
|
||||||
|
)
|
||||||
|
|
||||||
// SearchAPI defines the search JSAPI interface for ctx.search.*
|
// SearchAPI defines the search JSAPI interface for ctx.search.*
|
||||||
// This interface is defined here to avoid circular dependency between context and search packages.
|
// This interface is defined here to avoid circular dependency between context and search packages.
|
||||||
// The actual implementation is in agent/search/jsapi.go
|
// The actual implementation is in agent/search/jsapi.go
|
||||||
|
|
@ -37,3 +42,321 @@ func (ctx *Context) Search() SearchAPI {
|
||||||
}
|
}
|
||||||
return SearchAPIFactory(ctx)
|
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:
|
To avoid circular dependency between `context` and `search` packages:
|
||||||
|
|
||||||
```
|
```
|
||||||
agent/context/jsapi_search.go agent/search/jsapi.go
|
agent/context/jsapi_search.go agent/search/jsapi.go
|
||||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
┌─────────────────────────────┐ ┌─────────────────────────┐
|
||||||
│ SearchAPI interface │◄──────│ JSAPI struct │
|
│ SearchAPI interface │◄───────│ JSAPI struct │
|
||||||
│ SearchAPIFactory var │ │ (implements SearchAPI) │
|
│ SearchAPIFactory var │ │ (implements SearchAPI) │
|
||||||
│ ctx.Search() method │ │ SetJSAPIFactory() │
|
│ V8 binding methods: │ │ NewJSAPI() │
|
||||||
└─────────────────────────┘ └─────────────────────────┘
|
│ newSearchObject() │ │ Web/KB/DB() │
|
||||||
▲ │
|
│ searchWebMethod() │ │ All/Any/Race() │
|
||||||
│ │
|
│ searchKBMethod() │ │ buildRequest() │
|
||||||
└──────────────────────────────────┘
|
│ searchDBMethod() │ │ parseRequests() │
|
||||||
|
│ searchAllMethod() │ │ ConfigGetter type │
|
||||||
|
│ searchAnyMethod() │ │ SetJSAPIFactory() │
|
||||||
|
│ searchRaceMethod() │ └─────────────────────────┘
|
||||||
|
└─────────────────────────────┘ │
|
||||||
|
▲ │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
Factory registration
|
Factory registration
|
||||||
(in assistant/init)
|
(with ConfigGetter in assistant/init)
|
||||||
|
|
||||||
|
agent/context/jsapi.go
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ NewObject() │
|
||||||
|
│ jsObject.Set("search", │
|
||||||
|
│ ctx.newSearchObject()) │
|
||||||
|
└─────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Files:**
|
**Key Files:**
|
||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
| ----------------------------- | ------------------------------ |
|
| -------------------------------- | ---------------------------------------------------------------- |
|
||||||
| `context/jsapi_search.go` | SearchAPI interface definition |
|
| `context/jsapi_search.go` | SearchAPI interface + V8 binding methods |
|
||||||
| `search/jsapi.go` | JSAPI implementation |
|
| `context/jsapi_search_test.go` | Integration tests (real V8 calls via test assistant) |
|
||||||
| `assistant/assistant.go:init` | Factory registration |
|
| `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
|
### API Methods
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,15 @@ import (
|
||||||
// JSAPI implements context.SearchAPI interface
|
// JSAPI implements context.SearchAPI interface
|
||||||
// Provides ctx.search.Web(), ctx.search.KB(), ctx.search.DB(), ctx.search.All(), ctx.search.Any(), ctx.search.Race()
|
// Provides ctx.search.Web(), ctx.search.KB(), ctx.search.DB(), ctx.search.All(), ctx.search.Any(), ctx.search.Race()
|
||||||
type JSAPI struct {
|
type JSAPI struct {
|
||||||
ctx *context.Context
|
ctx *context.Context
|
||||||
config *types.Config
|
searcher *Searcher
|
||||||
uses *Uses
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewJSAPI creates a new search JSAPI instance
|
// NewJSAPI creates a new search JSAPI instance
|
||||||
func NewJSAPI(ctx *context.Context, config *types.Config, uses *Uses) *JSAPI {
|
func NewJSAPI(ctx *context.Context, config *types.Config, uses *Uses) *JSAPI {
|
||||||
return &JSAPI{
|
return &JSAPI{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
config: config,
|
searcher: New(config, uses),
|
||||||
uses: uses,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,15 +27,9 @@ func NewJSAPI(ctx *context.Context, config *types.Config, uses *Uses) *JSAPI {
|
||||||
// - time_range: string - "day", "week", "month", "year"
|
// - time_range: string - "day", "week", "month", "year"
|
||||||
// - rerank: map[string]interface{} - rerank options
|
// - rerank: map[string]interface{} - rerank options
|
||||||
func (api *JSAPI) Web(query string, opts map[string]interface{}) interface{} {
|
func (api *JSAPI) Web(query string, opts map[string]interface{}) interface{} {
|
||||||
// TODO: Implement web search
|
req := api.buildRequest(types.SearchTypeWeb, query, opts)
|
||||||
// 1. Build Request from query and opts
|
result, _ := api.searcher.Search(api.ctx, req)
|
||||||
// 2. Call web handler
|
return result
|
||||||
// 3. Return Result or error
|
|
||||||
return &types.Result{
|
|
||||||
Type: types.SearchTypeWeb,
|
|
||||||
Query: query,
|
|
||||||
Error: "not implemented",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// KB executes knowledge base search
|
// 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
|
// - graph: bool - enable graph association
|
||||||
// - rerank: map[string]interface{} - rerank options
|
// - rerank: map[string]interface{} - rerank options
|
||||||
func (api *JSAPI) KB(query string, opts map[string]interface{}) interface{} {
|
func (api *JSAPI) KB(query string, opts map[string]interface{}) interface{} {
|
||||||
// TODO: Implement KB search
|
req := api.buildRequest(types.SearchTypeKB, query, opts)
|
||||||
// 1. Build Request from query and opts
|
result, _ := api.searcher.Search(api.ctx, req)
|
||||||
// 2. Call KB handler
|
return result
|
||||||
// 3. Return Result or error
|
|
||||||
return &types.Result{
|
|
||||||
Type: types.SearchTypeKB,
|
|
||||||
Query: query,
|
|
||||||
Error: "not implemented",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB executes database search
|
// DB executes database search
|
||||||
|
|
@ -68,15 +54,9 @@ func (api *JSAPI) KB(query string, opts map[string]interface{}) interface{} {
|
||||||
// - limit: int - max results
|
// - limit: int - max results
|
||||||
// - rerank: map[string]interface{} - rerank options
|
// - rerank: map[string]interface{} - rerank options
|
||||||
func (api *JSAPI) DB(query string, opts map[string]interface{}) interface{} {
|
func (api *JSAPI) DB(query string, opts map[string]interface{}) interface{} {
|
||||||
// TODO: Implement DB search
|
req := api.buildRequest(types.SearchTypeDB, query, opts)
|
||||||
// 1. Build Request from query and opts
|
result, _ := api.searcher.Search(api.ctx, req)
|
||||||
// 2. Call DB handler
|
return result
|
||||||
// 3. Return Result or error
|
|
||||||
return &types.Result{
|
|
||||||
Type: types.SearchTypeDB,
|
|
||||||
Query: query,
|
|
||||||
Error: "not implemented",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All executes all searches and waits for all to complete (like Promise.all)
|
// 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
|
// - query: string - search query
|
||||||
// - ... other type-specific options
|
// - ... other type-specific options
|
||||||
func (api *JSAPI) All(requests []interface{}) []interface{} {
|
func (api *JSAPI) All(requests []interface{}) []interface{} {
|
||||||
// TODO: Implement All search
|
reqs := api.parseRequests(requests)
|
||||||
// 1. Parse requests into []Request
|
results, _ := api.searcher.All(api.ctx, reqs)
|
||||||
// 2. Call Searcher.All()
|
return api.convertResults(results)
|
||||||
// 3. Return []Result
|
|
||||||
results := make([]interface{}, len(requests))
|
|
||||||
for i := range requests {
|
|
||||||
results[i] = &types.Result{
|
|
||||||
Error: "not implemented",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any returns as soon as any search succeeds with results (like Promise.any)
|
// 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
|
// - query: string - search query
|
||||||
// - ... other type-specific options
|
// - ... other type-specific options
|
||||||
func (api *JSAPI) Any(requests []interface{}) []interface{} {
|
func (api *JSAPI) Any(requests []interface{}) []interface{} {
|
||||||
// TODO: Implement Any search
|
reqs := api.parseRequests(requests)
|
||||||
// 1. Parse requests into []Request
|
results, _ := api.searcher.Any(api.ctx, reqs)
|
||||||
// 2. Call Searcher.Any()
|
return api.convertResults(results)
|
||||||
// 3. Return []Result
|
|
||||||
results := make([]interface{}, len(requests))
|
|
||||||
for i := range requests {
|
|
||||||
results[i] = &types.Result{
|
|
||||||
Error: "not implemented",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Race returns as soon as any search completes (like Promise.race)
|
// 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
|
// - query: string - search query
|
||||||
// - ... other type-specific options
|
// - ... other type-specific options
|
||||||
func (api *JSAPI) Race(requests []interface{}) []interface{} {
|
func (api *JSAPI) Race(requests []interface{}) []interface{} {
|
||||||
// TODO: Implement Race search
|
reqs := api.parseRequests(requests)
|
||||||
// 1. Parse requests into []Request
|
results, _ := api.searcher.Race(api.ctx, reqs)
|
||||||
// 2. Call Searcher.Race()
|
return api.convertResults(results)
|
||||||
// 3. Return []Result
|
|
||||||
results := make([]interface{}, len(requests))
|
|
||||||
for i := range requests {
|
|
||||||
results[i] = &types.Result{
|
|
||||||
Error: "not implemented",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// init registers the JSAPI factory with context package
|
// buildRequest builds a Request from query and options
|
||||||
func init() {
|
func (api *JSAPI) buildRequest(searchType types.SearchType, query string, opts map[string]interface{}) *types.Request {
|
||||||
// Note: The actual factory is set by assistant package during initialization
|
req := &types.Request{
|
||||||
// This avoids circular dependency: context -> search -> context
|
Type: searchType,
|
||||||
// See: assistant/assistant.go init()
|
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
|
// SetJSAPIFactory sets the factory function for creating SearchAPI instances
|
||||||
// Called by assistant package during initialization
|
// 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 {
|
context.SearchAPIFactory = func(ctx *context.Context) context.SearchAPI {
|
||||||
// Get config and uses from context or use defaults
|
var config *types.Config
|
||||||
// TODO: Get actual config from assistant
|
var uses *Uses
|
||||||
return NewJSAPI(ctx, nil, nil)
|
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