yao/agent/search/rerank/agent.go
Max 534f4d6ed5 Refactor Search Module and Update Documentation
- Introduced a new TODO.md file to outline the implementation plan and progress for the search module.
- Updated DESIGN.md to reflect changes in the directory structure and clarify the roles of various components, including the new Handler + Registry pattern for reranking and keyword extraction.
- Refactored the Searcher struct to utilize a direct reference to the rerank package, enhancing modularity and clarity in the search process.
- Modified the Search and SearchMultiple methods to include context parameters, improving flexibility for agent mode operations.
- Revised the Reranker interface to require context for Agent and MCP modes, ensuring compatibility with different reranking strategies.
- Enhanced documentation to provide comprehensive guidance on the updated search architecture and its components.
2025-12-13 15:01:53 +08:00

232 lines
6.3 KiB
Go

package rerank
import (
"encoding/json"
"fmt"
"strings"
"github.com/yaoapp/yao/agent/caller"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/search/types"
)
// AgentProvider implements reranking by delegating to another agent
// The agent should have a Next Hook that accepts rerank request and returns reordered items
type AgentProvider struct {
agentID string // Assistant ID to delegate to
}
// NewAgentProvider creates a new agent reranker
func NewAgentProvider(agentID string) *AgentProvider {
return &AgentProvider{agentID: agentID}
}
// Rerank delegates reranking to an LLM-powered assistant
// The assistant receives items and query, returns reordered item IDs or items
func (p *AgentProvider) Rerank(ctx *context.Context, query string, items []*types.ResultItem, opts *types.RerankOptions) ([]*types.ResultItem, error) {
if ctx == nil {
return nil, fmt.Errorf("context is required for agent rerank")
}
// Get agent via caller interface (avoids circular dependency)
agent, err := caller.AgentGetterFunc(p.agentID)
if err != nil {
return nil, fmt.Errorf("failed to get agent %s: %w", p.agentID, err)
}
// Build request message with items to rerank
requestData := map[string]interface{}{
"query": query,
"items": items,
"top_n": opts.TopN,
"action": "rerank",
}
requestJSON, _ := json.Marshal(requestData)
// Create messages for agent
messages := []context.Message{
{
Role: "user",
Content: string(requestJSON),
},
}
// Call agent's Stream method with skip options (no history, no output)
options := &context.Options{
Skip: &context.Skip{
History: true,
Output: true,
},
}
result, err := agent.Stream(ctx, messages, options)
if err != nil {
return nil, fmt.Errorf("agent stream failed: %w", err)
}
// Parse response
return p.parseResponse(result, items, opts)
}
// parseResponse extracts reranked items from agent response
// The response format from agent.Stream is typically:
// { "next": { "data": { "order": [...] } } }
func (p *AgentProvider) parseResponse(result interface{}, originalItems []*types.ResultItem, opts *types.RerankOptions) ([]*types.ResultItem, error) {
if result == nil {
return originalItems, nil
}
// Build index map for quick lookup
itemMap := make(map[string]*types.ResultItem)
for _, item := range originalItems {
if item.CitationID != "" {
itemMap[item.CitationID] = item
}
}
// Extract response data
response := extractResponseData(result)
if response == nil {
return originalItems, nil
}
// Try to get reranked order from response
// Expected format: { "order": ["ref_001", "ref_003", "ref_002"] }
// Or: { "items": [{ "citation_id": "ref_001", ... }, ...] }
var reranked []*types.ResultItem
// Try "order" field (list of citation IDs)
if order, ok := response["order"]; ok {
if orderList := toStringSlice(order); len(orderList) > 0 {
for _, id := range orderList {
if item, exists := itemMap[id]; exists {
reranked = append(reranked, item)
delete(itemMap, id) // Avoid duplicates
}
}
// Append remaining items not in order
for _, item := range originalItems {
if _, exists := itemMap[item.CitationID]; exists {
reranked = append(reranked, item)
}
}
}
}
// Try "items" field (full items or items with citation_id)
if len(reranked) == 0 {
if items, ok := response["items"]; ok {
if itemsList := toItemsList(items); len(itemsList) > 0 {
for _, respItem := range itemsList {
// Check if it's just a reference or full item
if citationID, ok := respItem["citation_id"].(string); ok {
if item, exists := itemMap[citationID]; exists {
reranked = append(reranked, item)
delete(itemMap, citationID)
}
}
}
// Append remaining items
for _, item := range originalItems {
if _, exists := itemMap[item.CitationID]; exists {
reranked = append(reranked, item)
}
}
}
}
}
// If no valid response, return original items
if len(reranked) == 0 {
reranked = originalItems
}
// Apply top N
if opts.TopN > 0 && opts.TopN < len(reranked) {
reranked = reranked[:opts.TopN]
}
return reranked, nil
}
// extractResponseData extracts the actual response data from agent.Stream result
// Handles nested structures like { "next": { "data": { ... } } }
func extractResponseData(result interface{}) map[string]interface{} {
switch v := result.(type) {
case map[string]interface{}:
// Check for "next" wrapper (from NextHookResponse)
if next, ok := v["next"].(map[string]interface{}); ok {
// Check for "data" inside next
if data, ok := next["data"].(map[string]interface{}); ok {
return data
}
return next
}
// Check for direct "data" wrapper
if data, ok := v["data"].(map[string]interface{}); ok {
return data
}
return v
case string:
// Try to parse as JSON
var data map[string]interface{}
if err := json.Unmarshal([]byte(v), &data); err == nil {
return extractResponseData(data)
}
}
// Try to handle other types by converting to JSON and back
if result != nil {
if bytes, err := json.Marshal(result); err == nil {
var data map[string]interface{}
if err := json.Unmarshal(bytes, &data); err == nil {
return extractResponseData(data)
}
}
}
return nil
}
// toStringSlice converts interface to string slice
func toStringSlice(v interface{}) []string {
switch val := v.(type) {
case []string:
return val
case []interface{}:
result := make([]string, 0, len(val))
for _, item := range val {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
}
return nil
}
// toItemsList converts interface to list of maps
func toItemsList(v interface{}) []map[string]interface{} {
switch val := v.(type) {
case []map[string]interface{}:
return val
case []interface{}:
result := make([]map[string]interface{}, 0, len(val))
for _, item := range val {
if m, ok := item.(map[string]interface{}); ok {
result = append(result, m)
}
}
return result
}
return nil
}
// extractAgentID extracts assistant ID from uses.rerank value
// For backward compatibility, strips any prefix if present
func extractAgentID(usesRerank string) string {
// Remove any prefix like "agent:" if present
if strings.HasPrefix(usesRerank, "agent:") {
return strings.TrimPrefix(usesRerank, "agent:")
}
return usesRerank
}