Enhance Search Result Storage and Intermediate Data Handling

- Introduced a new `Search` type to store intermediate processing results, including extracted keywords, entities, relations, and generated QueryDSL for improved debugging and citation support.
- Updated the `executeAutoSearch` method to populate the new `Search` structure, ensuring all relevant data is captured during search execution.
- Implemented methods for saving and retrieving search records in MongoDB and Redis, enhancing data persistence across sessions.
- Revised localization files to include new keys for search-related messages, improving user experience.
- Updated DESIGN.md to reflect changes in the search result structure and data flow, ensuring comprehensive documentation of the new features.
This commit is contained in:
Max 2025-12-15 16:06:11 +08:00
parent 42bb27a8dd
commit 00f9d86788
13 changed files with 2041 additions and 159 deletions

View file

@ -119,6 +119,7 @@ func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context
// 1. uses.keyword is configured (not empty)
// 2. Skip.Keyword is not true
// 3. Web search is enabled
var extractedKeywords []string
webSearchEnabled := searchConfig != nil && searchConfig.Web != nil
if webSearchEnabled && !skipKeyword && searchUses.Keyword != "" {
extractor := keyword.NewExtractor(searchUses.Keyword, searchConfig.Keyword)
@ -126,12 +127,15 @@ func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context
if err != nil {
ctx.Logger.Warn("Keyword extraction failed, using original query: %v", err)
} else if len(keywords) > 0 {
extractedKeywords = keywords
// Use extracted keywords as the search query for web search
optimizedQuery := strings.Join(keywords, " ")
ctx.Logger.Info("Extracted keywords for web search: %s -> %s", truncateString(query, 30), optimizedQuery)
query = optimizedQuery
}
}
// extractedKeywords will be used for storage in saveSearch() - TODO: Phase 1.9.5
_ = extractedKeywords
// Build search requests based on configuration
requests := ast.buildSearchRequests(query, searchConfig)

View file

@ -108,14 +108,14 @@ func init() {
"search.no_results": "No references found",
// Search: assistant/search.go - Trace labels
"search.trace.label": "Search",
"search.trace.description": "Search the web and knowledge base for relevant information",
"search.trace.web.label": "Web Search",
"search.trace.web.description": "Searching the web",
"search.trace.kb.label": "KB Search",
"search.trace.kb.description": "Searching knowledge base",
"search.trace.db.label": "DB Search",
"search.trace.db.description": "Searching database",
"search.trace.label": "Search",
"search.trace.description": "Search the web and knowledge base for relevant information",
"search.trace.web.label": "Web Search",
"search.trace.web.description": "Searching the web",
"search.trace.kb.label": "KB Search",
"search.trace.kb.description": "Searching knowledge base",
"search.trace.db.label": "DB Search",
"search.trace.db.description": "Searching database",
},
}
@ -192,14 +192,14 @@ func init() {
"search.no_results": "未找到相关资料",
// Search: assistant/search.go - Trace labels
"search.trace.label": "搜索",
"search.trace.description": "搜索网络和知识库获取相关信息",
"search.trace.web.label": "网页搜索",
"search.trace.web.description": "搜索网页获取相关信息",
"search.trace.kb.label": "知识库搜索",
"search.trace.kb.description": "搜索知识库获取相关信息",
"search.trace.db.label": "数据库搜索",
"search.trace.db.description": "搜索数据库获取相关信息",
"search.trace.label": "搜索",
"search.trace.description": "搜索网络和知识库获取相关信息",
"search.trace.web.label": "网页搜索",
"search.trace.web.description": "搜索网页获取相关信息",
"search.trace.kb.label": "知识库搜索",
"search.trace.kb.description": "搜索知识库获取相关信息",
"search.trace.db.label": "数据库搜索",
"search.trace.db.description": "搜索数据库获取相关信息",
},
}

View file

@ -565,7 +565,7 @@ type RerankOptions struct {
TopN int `json:"top_n,omitempty"` // Return top N after reranking
}
// Result represents the search result
// Result represents the search result with all intermediate processing data
type Result struct {
Type SearchType `json:"type"` // Search type
Query string `json:"query"` // Original query
@ -575,10 +575,31 @@ type Result struct {
Duration int64 `json:"duration_ms"` // Search duration in ms
Error string `json:"error,omitempty"` // Error message if failed
// Intermediate processing results (for storage and debugging)
Keywords []string `json:"keywords,omitempty"` // Extracted keywords (Web/NLP)
DSL map[string]any `json:"dsl,omitempty"` // Generated QueryDSL (DB)
Entities []Entity `json:"entities,omitempty"` // Extracted entities (Graph RAG)
Relations []Relation `json:"relations,omitempty"` // Extracted relations (Graph RAG)
// Graph associations (KB only, if enabled)
GraphNodes []*GraphNode `json:"graph_nodes,omitempty"`
}
// Entity represents an extracted entity (for Graph RAG)
type Entity struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Source string `json:"source,omitempty"`
}
// Relation represents an extracted relation (for Graph RAG)
type Relation struct {
Subject string `json:"subject"`
Predicate string `json:"predicate"`
Object string `json:"object"`
Source string `json:"source,omitempty"`
}
// ResultItem represents a single search result item
type ResultItem struct {
// Citation
@ -615,6 +636,43 @@ type ProcessedQuery struct {
Vector []float32 `json:"vector,omitempty"` // For KB search
DSL *gou.QueryDSL `json:"dsl,omitempty"` // For DB search, uses GOU QueryDSL
}
```
> **Design Note: Result with Intermediate Data**
>
> The `Result` type now includes intermediate processing results (`Keywords`, `DSL`, `Entities`, `Relations`)
> that were previously only available during query processing. This design enables:
>
> 1. **Storage for Debugging**: All processing steps are captured for later analysis
> 2. **System Tuning**: Analyze extracted keywords, generated DSL, and entity extraction quality
> 3. **Unified Data Flow**: Handlers populate these fields during execution, eliminating the need
> for separate data collection in `executeAutoSearch`
>
> **Handler Responsibilities**:
>
> - **Web Handler**: Populates `Keywords` from NLP extraction
> - **DB Handler**: Populates `DSL` from QueryDSL generation
> - **KB Handler**: Populates `Entities`, `Relations`, and `GraphNodes` from Graph RAG
>
> **Data Flow**:
>
> ```
> Request → Handler → Result (with Keywords/DSL/Entities/Relations)
> ↓
> BuildReferenceContext
> ↓
> saveSearch (stores all intermediate data)
> ```
```go
// ProcessedQuery is DEPRECATED for external use
// Handlers should populate Result.Keywords/DSL/Entities/Relations directly
type ProcessedQuery struct {
Type SearchType `json:"type"`
Keywords []string `json:"keywords,omitempty"` // For web search
Vector []float32 `json:"vector,omitempty"` // For KB search
DSL *gou.QueryDSL `json:"dsl,omitempty"` // For DB search
}
// Note: For QueryDSL and Model types, use GOU types directly:
// - github.com/yaoapp/gou/query/gou.QueryDSL
@ -1008,6 +1066,495 @@ Frame 3 - Removed:
(loading indicator removed when done: true)
```
## Search Result Storage
Search results are stored per request to support citation click-through and history replay.
### Data Model
```
Relationships:
Chat
└── Request (request_id)
├── Message[] (user, assistant, tool...)
└── SearchResult[] (one request may have multiple searches)
└── Reference[] (indexed references from each search)
```
### Citation Locating
LLM output uses `<a>` tags with index:
```xml
AI is artificial intelligence<a index="1" />, it has developed rapidly<a index="2" />...
```
Location path: `request_id` + `index` → precisely locate reference
### Database Schema
**Table: `agent_search`**
| Column | Type | Description |
| ---------- | ----------- | -------------------------------------- |
| id | BIGINT | Auto-increment primary key |
| request_id | VARCHAR(64) | Associated request ID (indexed) |
| chat_id | VARCHAR(64) | Associated chat ID (indexed) |
| query | TEXT | Original search query |
| config | JSON | Search config used (for tuning) |
| keywords | JSON | Extracted keywords (from NLP) |
| entities | JSON | Extracted entities (for Graph search) |
| relations | JSON | Extracted relations (for Graph search) |
| dsl | JSON | Generated QueryDSL (for DB search) |
| source | VARCHAR(32) | Search source: web/kb/db/auto |
| references | JSON | Reference[] with global index |
| graph | JSON | GraphNode[] from knowledge graph |
| xml | TEXT | Formatted XML for LLM context |
| prompt | TEXT | Citation instruction prompt |
| duration | INT | Search duration in milliseconds |
| error | TEXT | Error message if failed (nullable) |
| created_at | TIMESTAMP | Creation time |
| deleted_at | TIMESTAMP | Soft delete time (nullable) |
**Config Field Structure:**
```json
{
"uses": {
"search": "builtin",
"web": "builtin",
"keyword": "builtin",
"querydsl": "builtin",
"rerank": "builtin"
},
"web": {
"provider": "tavily",
"max_results": 5
},
"kb": {
"collections": ["docs", "faq"],
"threshold": 0.7,
"graph": true
},
"db": {
"models": ["product", "order"],
"max_results": 20
},
"rerank": {
"provider": "builtin",
"top_n": 10
}
}
```
### Type Definitions
```go
// store/types/types.go
// Search represents stored search results for a request
// Stores all intermediate processing results for debugging and replay
type Search struct {
ID int64 `json:"id"`
RequestID string `json:"request_id"`
ChatID string `json:"chat_id"`
Query string `json:"query"` // Original query
Config map[string]any `json:"config,omitempty"` // Search config used (for tuning)
Keywords []string `json:"keywords,omitempty"` // Extracted keywords (Web/NLP)
Entities []Entity `json:"entities,omitempty"` // Extracted entities (Graph)
Relations []Relation `json:"relations,omitempty"` // Extracted relations (Graph)
DSL map[string]any `json:"dsl,omitempty"` // Generated QueryDSL (DB)
Source string `json:"source"` // web/kb/db/auto
References []Reference `json:"references"`
Graph []GraphNode `json:"graph,omitempty"` // Graph nodes from KB
XML string `json:"xml,omitempty"` // Formatted XML for LLM
Prompt string `json:"prompt,omitempty"` // Citation prompt
Duration int64 `json:"duration_ms"` // Search duration
Error string `json:"error,omitempty"` // Error if failed
CreatedAt time.Time `json:"created_at"`
}
// Reference represents a single reference with global index (for storage)
type Reference struct {
Index int `json:"index"` // Global index: 1, 2, 3...
Type string `json:"type"` // web/kb/db
Title string `json:"title"`
URL string `json:"url,omitempty"`
Snippet string `json:"snippet"`
Content string `json:"content,omitempty"` // Full content (optional)
Metadata map[string]any `json:"metadata,omitempty"`
}
// Entity represents an extracted entity from query (for Graph search)
type Entity struct {
Name string `json:"name"` // Entity name
Type string `json:"type"` // Entity type: person, org, location, etc.
Metadata map[string]any `json:"metadata,omitempty"`
}
// Relation represents an extracted relation from query (for Graph search)
type Relation struct {
Subject string `json:"subject"` // Source entity
Predicate string `json:"predicate"` // Relation type
Object string `json:"object"` // Target entity
Metadata map[string]any `json:"metadata,omitempty"`
}
// GraphNode represents a node from knowledge graph (search result)
type GraphNode struct {
ID string `json:"id"`
Type string `json:"type"` // Entity type
Name string `json:"name"` // Entity name
Description string `json:"description,omitempty"`
Relation string `json:"relation,omitempty"` // Relationship to query
Score float64 `json:"score,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// SearchFilter for querying search records
type SearchFilter struct {
RequestID string `json:"request_id,omitempty"`
ChatID string `json:"chat_id,omitempty"`
Source string `json:"source,omitempty"`
}
```
### Store Interface Extension
```go
// store/types/store.go
// ChatStore interface extension
type ChatStore interface {
// ... existing methods ...
// ==========================================================================
// Search Management
// ==========================================================================
// SaveSearch saves search record for a request
// search: Search record to save
// Returns: Potential error
SaveSearch(search *Search) error
// GetSearches retrieves search records for a request
// requestID: Request ID
// Returns: Search records and potential error
GetSearches(requestID string) ([]*Search, error)
// GetReference retrieves a single reference by request ID and index
// requestID: Request ID
// index: Reference index (1-based)
// Returns: Reference and potential error
GetReference(requestID string, index int) (*Reference, error)
// DeleteSearches deletes all search records for a chat
// chatID: Chat ID
// Returns: Potential error
DeleteSearches(chatID string) error
}
```
### Xun Implementation
```go
// store/xun/search.go
// SaveSearch saves a search record
func (store *Xun) SaveSearch(search *Search) error {
if search.RequestID == "" {
return fmt.Errorf("request_id is required")
}
refsJSON, err := jsoniter.MarshalToString(search.References)
if err != nil {
return fmt.Errorf("failed to marshal references: %w", err)
}
row := map[string]interface{}{
"request_id": search.RequestID,
"chat_id": search.ChatID,
"query": search.Query,
"config": search.Config, // Search config for tuning
"keywords": search.Keywords,
"entities": search.Entities, // Graph entities
"relations": search.Relations, // Graph relations
"dsl": search.DSL,
"source": search.Source,
"references": refsJSON,
"graph": search.Graph, // Graph nodes
"xml": search.XML,
"prompt": search.Prompt,
"duration": search.Duration,
"error": search.Error,
"created_at": time.Now(),
}
return store.newQuerySearch().Insert(row)
}
// GetSearches retrieves all search records for a request
func (store *Xun) GetSearches(requestID string) ([]*Search, error) {
rows, err := store.newQuerySearch().
Where("request_id", requestID).
WhereNull("deleted_at").
OrderBy("created_at", "asc").
Get()
// ... convert rows to Search
}
// GetReference retrieves a single reference
func (store *Xun) GetReference(requestID string, index int) (*Reference, error) {
searches, err := store.GetSearches(requestID)
if err != nil {
return nil, err
}
// Find reference by index across all search records
for _, search := range searches {
for _, ref := range search.References {
if ref.Index == index {
return &ref, nil
}
}
}
return nil, fmt.Errorf("reference %d not found in request %s", index, requestID)
}
```
### Model Definition
```json
// yao/models/agent/search.mod.yao
{
"name": "Search",
"label": "Search",
"description": "Search records for citation support and debugging",
"tags": ["agent", "system"],
"builtin": true,
"readonly": true,
"table": {
"name": "agent_search",
"comment": "Agent search table"
},
"columns": [
{ "name": "id", "type": "ID", "label": "ID" },
{
"name": "request_id",
"type": "string",
"length": 64,
"nullable": false,
"index": true
},
{
"name": "chat_id",
"type": "string",
"length": 64,
"nullable": false,
"index": true
},
{ "name": "query", "type": "text", "nullable": true },
{
"name": "config",
"type": "json",
"nullable": true,
"comment": "Search config used (for tuning)"
},
{ "name": "keywords", "type": "json", "nullable": true },
{ "name": "entities", "type": "json", "nullable": true },
{ "name": "relations", "type": "json", "nullable": true },
{ "name": "dsl", "type": "json", "nullable": true },
{ "name": "source", "type": "string", "length": 32, "nullable": false },
{ "name": "references", "type": "json", "nullable": true },
{ "name": "graph", "type": "json", "nullable": true },
{ "name": "xml", "type": "text", "nullable": true },
{ "name": "prompt", "type": "text", "nullable": true },
{ "name": "duration", "type": "integer", "nullable": true },
{ "name": "error", "type": "text", "nullable": true }
],
"option": { "timestamps": true, "soft_deletes": true }
}
```
### Stream Integration
Storage logic is encapsulated in `assistant/search.go` with a dedicated method:
```go
// assistant/search.go
// SearchExecutionResult contains all intermediate results from search execution
type SearchExecutionResult struct {
Query string // Original query
Config map[string]any // Search config used
Keywords []string // Extracted keywords (Web/NLP)
Entities []storeTypes.Entity // Extracted entities (Graph)
Relations []storeTypes.Relation // Extracted relations (Graph)
DSL map[string]any // Generated QueryDSL (DB)
Source string // web/kb/db/auto
RefCtx *searchTypes.ReferenceContext // Reference context for LLM
Graph []storeTypes.GraphNode // Graph nodes from KB
Duration int64 // Duration in ms
Error string // Error message if failed
}
// saveSearch saves search record to store for citation support and debugging
func (ast *Assistant) saveSearch(ctx *context.Context, result *SearchExecutionResult) {
if ctx.Store == nil || result == nil {
return
}
// Skip if no references and no error
if result.RefCtx == nil && result.Error == "" {
return
}
var refs []storeTypes.Reference
var xml, prompt string
if result.RefCtx != nil {
refs = convertReferences(result.RefCtx.References)
xml = result.RefCtx.XML
prompt = result.RefCtx.Prompt
}
search := &storeTypes.Search{
RequestID: ctx.RequestID,
ChatID: ctx.ID,
Query: result.Query,
Config: result.Config, // Search config for tuning analysis
Keywords: result.Keywords,
Entities: result.Entities, // Graph entities
Relations: result.Relations, // Graph relations
DSL: result.DSL,
Source: result.Source,
References: refs,
Graph: result.Graph, // Graph nodes
XML: xml,
Prompt: prompt,
Duration: result.Duration,
Error: result.Error,
}
if err := ctx.Store.SaveSearch(search); err != nil {
ctx.Logger.Warn("Failed to save search: %v", err)
}
}
// convertReferences converts search references to store format
func convertReferences(refs []*searchTypes.Reference) []storeTypes.Reference {
result := make([]storeTypes.Reference, len(refs))
for i, ref := range refs {
result[i] = storeTypes.Reference{
Index: i + 1, // 1-based index
Type: string(ref.Type),
Title: ref.Title,
URL: ref.URL,
Snippet: ref.Content,
Content: ref.Content,
Metadata: ref.Meta,
}
}
return result
}
// In executeAutoSearch:
func (ast *Assistant) executeAutoSearch(ctx *context.Context, ...) *searchTypes.ReferenceContext {
start := time.Now()
// 1. Execute search (Result now contains all intermediate data)
results, err := searcher.All(ctx, requests)
duration := time.Since(start).Milliseconds()
// 2. Prepare execution result for storage
execResult := &SearchExecutionResult{
Query: query,
Config: buildSearchConfig(searchConfig, searchUses),
Source: "auto",
Duration: duration,
}
if err != nil {
execResult.Error = err.Error()
ast.saveSearch(ctx, execResult)
return nil
}
// 3. Extract intermediate data from results
// Result.Keywords, Result.DSL, Result.Entities, Result.Relations are populated by handlers
for _, result := range results {
if len(result.Keywords) > 0 {
execResult.Keywords = result.Keywords
}
if result.DSL != nil {
execResult.DSL = result.DSL
}
if len(result.Entities) > 0 {
execResult.Entities = convertEntities(result.Entities)
}
if len(result.Relations) > 0 {
execResult.Relations = convertRelations(result.Relations)
}
if len(result.GraphNodes) > 0 {
execResult.Graph = convertGraphNodes(result.GraphNodes)
}
}
// 4. Build reference context
refCtx := search.BuildReferenceContext(results, citationConfig)
execResult.RefCtx = refCtx
// 5. Save search record
ast.saveSearch(ctx, execResult)
return refCtx
}
```
### Usage Scenarios
**Scenario 1: Single Search**
```
Request: req_001
└── Search: { source: "auto", references: [{index:1,...}, {index:2,...}, {index:3,...}] }
```
**Scenario 2: Multiple Searches (e.g., Tool Call triggers another search)**
```
Request: req_001
├── Search[0]: { source: "web", references: [{index:1,...}, {index:2,...}] }
└── Search[1]: { source: "kb", references: [{index:3,...}, {index:4,...}] }
```
Index is globally incremented, so `request_id + index` is always unique.
### API Endpoints
```
GET /api/chat/{chat_id}/request/{request_id}/references # Get all references for request
GET /api/chat/{chat_id}/request/{request_id}/reference/{index} # Get single reference by index
```
### Frontend Integration
```typescript
// When user clicks citation [1]
async function onCitationClick(requestId: string, index: number) {
const ref = await api.get(
`/chat/${chatId}/request/${requestId}/reference/${index}`
);
showReferenceCard({
title: ref.title,
url: ref.url,
snippet: ref.snippet,
content: ref.content,
});
}
```
## JSAPI Integration
The Search module is exposed via `ctx.search` object in hook scripts.

View file

@ -157,3 +157,31 @@ func (m *Mongo) DeleteAssistants(filter types.AssistantFilter) (int64, error) {
// TODO: implement
return 0, nil
}
// =============================================================================
// Search Management
// =============================================================================
// SaveSearch saves a search record for a request
func (m *Mongo) SaveSearch(search *types.Search) error {
// TODO: implement
return nil
}
// GetSearches retrieves all search records for a request
func (m *Mongo) GetSearches(requestID string) ([]*types.Search, error) {
// TODO: implement
return nil, nil
}
// GetReference retrieves a single reference by request ID and index
func (m *Mongo) GetReference(requestID string, index int) (*types.Reference, error) {
// TODO: implement
return nil, nil
}
// DeleteSearches deletes all search records for a chat
func (m *Mongo) DeleteSearches(chatID string) error {
// TODO: implement
return nil
}

View file

@ -157,3 +157,31 @@ func (r *Redis) DeleteAssistants(filter types.AssistantFilter) (int64, error) {
// TODO: implement
return 0, nil
}
// =============================================================================
// Search Management
// =============================================================================
// SaveSearch saves a search record for a request
func (r *Redis) SaveSearch(search *types.Search) error {
// TODO: implement
return nil
}
// GetSearches retrieves all search records for a request
func (r *Redis) GetSearches(requestID string) ([]*types.Search, error) {
// TODO: implement
return nil, nil
}
// GetReference retrieves a single reference by request ID and index
func (r *Redis) GetReference(requestID string, index int) (*types.Reference, error) {
// TODO: implement
return nil, nil
}
// DeleteSearches deletes all search records for a chat
func (r *Redis) DeleteSearches(chatID string) error {
// TODO: implement
return nil
}

View file

@ -98,6 +98,34 @@ type ChatStore interface {
// chatID: Chat ID
// Returns: Potential error
DeleteResume(chatID string) error
// ==========================================================================
// Search Management
// ==========================================================================
// SaveSearch saves a search record for a request
// Used for citation support, debugging, and replay
// search: Search record to save
// Returns: Potential error
SaveSearch(search *Search) error
// GetSearches retrieves all search records for a request
// requestID: Request ID
// Returns: Search records and potential error
GetSearches(requestID string) ([]*Search, error)
// GetReference retrieves a single reference by request ID and index
// Used for citation click handling
// requestID: Request ID
// index: Reference index (1-based)
// Returns: Reference and potential error
GetReference(requestID string, index int) (*Reference, error)
// DeleteSearches deletes all search records for a chat
// Called when deleting a chat
// chatID: Chat ID
// Returns: Potential error
DeleteSearches(chatID string) error
}
// AssistantStore defines the assistant storage interface

View file

@ -447,3 +447,71 @@ type AssistantModel struct {
YaoTeamID string `json:"-"` // Team ID for team-based access control (not exposed in JSON)
YaoTenantID string `json:"-"` // Tenant ID for multi-tenancy support (not exposed in JSON)
}
// =============================================================================
// Search Types (for search result storage)
// =============================================================================
// Search represents stored search results for a request
// Stores all intermediate processing results for debugging, replay, and citation
type Search struct {
ID int64 `json:"id"`
RequestID string `json:"request_id"`
ChatID string `json:"chat_id"`
Query string `json:"query"` // Original query
Config map[string]any `json:"config,omitempty"` // Search config used (for tuning)
Keywords []string `json:"keywords,omitempty"` // Extracted keywords (Web/NLP)
Entities []Entity `json:"entities,omitempty"` // Extracted entities (Graph)
Relations []Relation `json:"relations,omitempty"` // Extracted relations (Graph)
DSL map[string]any `json:"dsl,omitempty"` // Generated QueryDSL (DB)
Source string `json:"source"` // web/kb/db/auto
References []Reference `json:"references"` // References with global index
Graph []GraphNode `json:"graph,omitempty"` // Graph nodes from KB
XML string `json:"xml,omitempty"` // Formatted XML for LLM
Prompt string `json:"prompt,omitempty"` // Citation prompt
Duration int64 `json:"duration_ms"` // Search duration in ms
Error string `json:"error,omitempty"` // Error if failed
CreatedAt time.Time `json:"created_at"`
}
// Reference represents a single reference with global index (for storage)
type Reference struct {
Index int `json:"index"` // Global index (1-based, unique within request)
Type string `json:"type"` // web/kb/db
Title string `json:"title"` // Reference title
URL string `json:"url,omitempty"` // URL (for web)
Snippet string `json:"snippet,omitempty"` // Short snippet
Content string `json:"content,omitempty"` // Full content
Metadata map[string]any `json:"metadata,omitempty"`
}
// SearchFilter for listing searches
type SearchFilter struct {
RequestID string `json:"request_id,omitempty"`
ChatID string `json:"chat_id,omitempty"`
Source string `json:"source,omitempty"`
}
// Entity represents an extracted entity (for Graph RAG)
type Entity struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Source string `json:"source,omitempty"`
}
// Relation represents an extracted relation (for Graph RAG)
type Relation struct {
Subject string `json:"subject"`
Predicate string `json:"predicate"`
Object string `json:"object"`
Source string `json:"source,omitempty"`
}
// GraphNode represents a node from knowledge graph
type GraphNode struct {
ID string `json:"id"`
Type string `json:"type"`
Label string `json:"label,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
Score float64 `json:"score,omitempty"`
}

301
agent/store/xun/search.go Normal file
View file

@ -0,0 +1,301 @@
package xun
import (
"fmt"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/yao/agent/store/types"
)
// =============================================================================
// Search Management
// =============================================================================
// SaveSearch saves a search record for a request
func (store *Xun) SaveSearch(search *types.Search) error {
if search == nil {
return fmt.Errorf("search is nil")
}
if search.RequestID == "" {
return fmt.Errorf("request_id is required")
}
if search.ChatID == "" {
return fmt.Errorf("chat_id is required")
}
if search.Source == "" {
return fmt.Errorf("source is required")
}
now := time.Now()
// Build row data
row := map[string]interface{}{
"request_id": search.RequestID,
"chat_id": search.ChatID,
"query": search.Query,
"source": search.Source,
"duration": search.Duration,
"created_at": now,
"updated_at": now,
}
// Handle JSON fields
if search.Config != nil {
configJSON, err := jsoniter.MarshalToString(search.Config)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
row["config"] = configJSON
}
if len(search.Keywords) > 0 {
keywordsJSON, err := jsoniter.MarshalToString(search.Keywords)
if err != nil {
return fmt.Errorf("failed to marshal keywords: %w", err)
}
row["keywords"] = keywordsJSON
}
if len(search.Entities) > 0 {
entitiesJSON, err := jsoniter.MarshalToString(search.Entities)
if err != nil {
return fmt.Errorf("failed to marshal entities: %w", err)
}
row["entities"] = entitiesJSON
}
if len(search.Relations) > 0 {
relationsJSON, err := jsoniter.MarshalToString(search.Relations)
if err != nil {
return fmt.Errorf("failed to marshal relations: %w", err)
}
row["relations"] = relationsJSON
}
if search.DSL != nil {
dslJSON, err := jsoniter.MarshalToString(search.DSL)
if err != nil {
return fmt.Errorf("failed to marshal dsl: %w", err)
}
row["dsl"] = dslJSON
}
if len(search.References) > 0 {
refsJSON, err := jsoniter.MarshalToString(search.References)
if err != nil {
return fmt.Errorf("failed to marshal references: %w", err)
}
row["references"] = refsJSON
}
if len(search.Graph) > 0 {
graphJSON, err := jsoniter.MarshalToString(search.Graph)
if err != nil {
return fmt.Errorf("failed to marshal graph: %w", err)
}
row["graph"] = graphJSON
}
if search.XML != "" {
row["xml"] = search.XML
}
if search.Prompt != "" {
row["prompt"] = search.Prompt
}
if search.Error != "" {
row["error"] = search.Error
}
return store.newQuerySearch().Insert(row)
}
// GetSearches retrieves all search records for a request
func (store *Xun) GetSearches(requestID string) ([]*types.Search, error) {
if requestID == "" {
return nil, fmt.Errorf("request_id is required")
}
rows, err := store.newQuerySearch().
Where("request_id", requestID).
WhereNull("deleted_at").
OrderBy("created_at", "asc").
Get()
if err != nil {
return nil, err
}
searches := make([]*types.Search, 0, len(rows))
for _, row := range rows {
data := row.ToMap()
if data == nil {
continue
}
search, err := store.rowToSearch(data)
if err != nil {
continue
}
searches = append(searches, search)
}
return searches, nil
}
// GetReference retrieves a single reference by request ID and index
func (store *Xun) GetReference(requestID string, index int) (*types.Reference, error) {
if requestID == "" {
return nil, fmt.Errorf("request_id is required")
}
if index < 1 {
return nil, fmt.Errorf("index must be >= 1")
}
// Get all searches for this request
searches, err := store.GetSearches(requestID)
if err != nil {
return nil, err
}
// Find the reference with matching index
for _, search := range searches {
for _, ref := range search.References {
if ref.Index == index {
return &ref, nil
}
}
}
return nil, fmt.Errorf("reference not found: request_id=%s, index=%d", requestID, index)
}
// DeleteSearches deletes all search records for a chat (soft delete)
func (store *Xun) DeleteSearches(chatID string) error {
if chatID == "" {
return fmt.Errorf("chat_id is required")
}
_, err := store.newQuerySearch().
Where("chat_id", chatID).
WhereNull("deleted_at").
Update(map[string]interface{}{
"deleted_at": time.Now(),
"updated_at": time.Now(),
})
return err
}
// =============================================================================
// Query Builder
// =============================================================================
// newQuerySearch creates a new query builder for the search table
func (store *Xun) newQuerySearch() query.Query {
qb := store.query.New()
qb.Table(store.getSearchTable())
return qb
}
// getSearchTable returns the search table name
func (store *Xun) getSearchTable() string {
m := model.Select("__yao.agent.search")
if m != nil && m.MetaData.Table.Name != "" {
return m.MetaData.Table.Name
}
return "agent_search"
}
// =============================================================================
// Helper Functions
// =============================================================================
// rowToSearch converts a database row to a Search struct
func (store *Xun) rowToSearch(data map[string]interface{}) (*types.Search, error) {
search := &types.Search{
ID: getInt64(data, "id"),
RequestID: getString(data, "request_id"),
ChatID: getString(data, "chat_id"),
Query: getString(data, "query"),
Source: getString(data, "source"),
XML: getString(data, "xml"),
Prompt: getString(data, "prompt"),
Duration: getInt64(data, "duration"),
Error: getString(data, "error"),
}
// Handle timestamps
if createdAt := getTime(data, "created_at"); createdAt != nil {
search.CreatedAt = *createdAt
}
// Parse JSON fields
if config := data["config"]; config != nil {
if configStr, ok := config.(string); ok && configStr != "" {
var configMap map[string]any
if err := jsoniter.UnmarshalFromString(configStr, &configMap); err == nil {
search.Config = configMap
}
}
}
if keywords := data["keywords"]; keywords != nil {
if keywordsStr, ok := keywords.(string); ok && keywordsStr != "" {
var keywordsList []string
if err := jsoniter.UnmarshalFromString(keywordsStr, &keywordsList); err == nil {
search.Keywords = keywordsList
}
}
}
if entities := data["entities"]; entities != nil {
if entitiesStr, ok := entities.(string); ok && entitiesStr != "" {
var entitiesList []types.Entity
if err := jsoniter.UnmarshalFromString(entitiesStr, &entitiesList); err == nil {
search.Entities = entitiesList
}
}
}
if relations := data["relations"]; relations != nil {
if relationsStr, ok := relations.(string); ok && relationsStr != "" {
var relationsList []types.Relation
if err := jsoniter.UnmarshalFromString(relationsStr, &relationsList); err == nil {
search.Relations = relationsList
}
}
}
if dsl := data["dsl"]; dsl != nil {
if dslStr, ok := dsl.(string); ok && dslStr != "" {
var dslMap map[string]any
if err := jsoniter.UnmarshalFromString(dslStr, &dslMap); err == nil {
search.DSL = dslMap
}
}
}
if refs := data["references"]; refs != nil {
if refsStr, ok := refs.(string); ok && refsStr != "" {
var refsList []types.Reference
if err := jsoniter.UnmarshalFromString(refsStr, &refsList); err == nil {
search.References = refsList
}
}
}
if graph := data["graph"]; graph != nil {
if graphStr, ok := graph.(string); ok && graphStr != "" {
var graphList []types.GraphNode
if err := jsoniter.UnmarshalFromString(graphStr, &graphList); err == nil {
search.Graph = graphList
}
}
}
return search, nil
}

View file

@ -0,0 +1,715 @@
package xun_test
import (
"fmt"
"testing"
"time"
"github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/agent/store/xun"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
// TestSaveSearch tests saving search records
func TestSaveSearch(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
store, err := xun.NewXun(types.Setting{
Connector: "default",
})
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
// Create a chat first
chat := &types.Chat{
AssistantID: "test_assistant",
Title: "Search Test Chat",
}
err = store.CreateChat(chat)
if err != nil {
t.Fatalf("Failed to create chat: %v", err)
}
defer store.DeleteChat(chat.ChatID)
t.Run("SaveBasicSearch", func(t *testing.T) {
requestID := fmt.Sprintf("req_%d", time.Now().UnixNano())
search := &types.Search{
RequestID: requestID,
ChatID: chat.ChatID,
Query: "What is the weather today?",
Source: "web",
Duration: 150,
}
err := store.SaveSearch(search)
if err != nil {
t.Fatalf("Failed to save search: %v", err)
}
// Verify
searches, err := store.GetSearches(requestID)
if err != nil {
t.Fatalf("Failed to get searches: %v", err)
}
if len(searches) != 1 {
t.Fatalf("Expected 1 search, got %d", len(searches))
}
if searches[0].Query != "What is the weather today?" {
t.Errorf("Expected query 'What is the weather today?', got '%s'", searches[0].Query)
}
if searches[0].Source != "web" {
t.Errorf("Expected source 'web', got '%s'", searches[0].Source)
}
if searches[0].Duration != 150 {
t.Errorf("Expected duration 150, got %d", searches[0].Duration)
}
})
t.Run("SaveSearchWithKeywords", func(t *testing.T) {
requestID := fmt.Sprintf("req_%d", time.Now().UnixNano())
search := &types.Search{
RequestID: requestID,
ChatID: chat.ChatID,
Query: "Latest news about AI",
Keywords: []string{"AI", "news", "latest"},
Source: "web",
Duration: 200,
}
err := store.SaveSearch(search)
if err != nil {
t.Fatalf("Failed to save search: %v", err)
}
searches, err := store.GetSearches(requestID)
if err != nil {
t.Fatalf("Failed to get searches: %v", err)
}
if len(searches) != 1 {
t.Fatalf("Expected 1 search, got %d", len(searches))
}
if len(searches[0].Keywords) != 3 {
t.Errorf("Expected 3 keywords, got %d", len(searches[0].Keywords))
}
if searches[0].Keywords[0] != "AI" {
t.Errorf("Expected first keyword 'AI', got '%s'", searches[0].Keywords[0])
}
})
t.Run("SaveSearchWithReferences", func(t *testing.T) {
requestID := fmt.Sprintf("req_%d", time.Now().UnixNano())
search := &types.Search{
RequestID: requestID,
ChatID: chat.ChatID,
Query: "How to learn Go programming?",
Source: "web",
References: []types.Reference{
{
Index: 1,
Type: "web",
Title: "Go Programming Tutorial",
URL: "https://go.dev/tour/",
Snippet: "An interactive introduction to Go",
},
{
Index: 2,
Type: "web",
Title: "Effective Go",
URL: "https://go.dev/doc/effective_go",
Snippet: "Tips for writing clear, idiomatic Go code",
},
},
XML: "<references>...</references>",
Prompt: "Please cite sources using [1], [2]...",
Duration: 300,
}
err := store.SaveSearch(search)
if err != nil {
t.Fatalf("Failed to save search: %v", err)
}
searches, err := store.GetSearches(requestID)
if err != nil {
t.Fatalf("Failed to get searches: %v", err)
}
if len(searches) != 1 {
t.Fatalf("Expected 1 search, got %d", len(searches))
}
if len(searches[0].References) != 2 {
t.Errorf("Expected 2 references, got %d", len(searches[0].References))
}
if searches[0].References[0].Title != "Go Programming Tutorial" {
t.Errorf("Expected first reference title 'Go Programming Tutorial', got '%s'", searches[0].References[0].Title)
}
if searches[0].XML != "<references>...</references>" {
t.Errorf("Expected XML '<references>...</references>', got '%s'", searches[0].XML)
}
if searches[0].Prompt != "Please cite sources using [1], [2]..." {
t.Errorf("Expected prompt to be set")
}
})
t.Run("SaveSearchWithConfig", func(t *testing.T) {
requestID := fmt.Sprintf("req_%d", time.Now().UnixNano())
search := &types.Search{
RequestID: requestID,
ChatID: chat.ChatID,
Query: "Config test",
Source: "auto",
Config: map[string]any{
"uses": map[string]any{
"search": "builtin",
"web": "builtin",
"keyword": "builtin",
},
"web": map[string]any{
"provider": "tavily",
"max_results": 5,
},
},
Duration: 100,
}
err := store.SaveSearch(search)
if err != nil {
t.Fatalf("Failed to save search: %v", err)
}
searches, err := store.GetSearches(requestID)
if err != nil {
t.Fatalf("Failed to get searches: %v", err)
}
if len(searches) != 1 {
t.Fatalf("Expected 1 search, got %d", len(searches))
}
if searches[0].Config == nil {
t.Fatal("Expected config to be set")
}
uses, ok := searches[0].Config["uses"].(map[string]any)
if !ok {
t.Fatal("Expected uses in config")
}
if uses["search"] != "builtin" {
t.Errorf("Expected uses.search='builtin', got '%v'", uses["search"])
}
})
t.Run("SaveSearchWithEntitiesAndRelations", func(t *testing.T) {
requestID := fmt.Sprintf("req_%d", time.Now().UnixNano())
search := &types.Search{
RequestID: requestID,
ChatID: chat.ChatID,
Query: "Who is the CEO of Apple?",
Source: "kb",
Entities: []types.Entity{
{Name: "Apple", Type: "Organization"},
{Name: "Tim Cook", Type: "Person"},
},
Relations: []types.Relation{
{Subject: "Tim Cook", Predicate: "CEO_of", Object: "Apple"},
},
Graph: []types.GraphNode{
{ID: "node1", Type: "Organization", Label: "Apple", Score: 0.95},
{ID: "node2", Type: "Person", Label: "Tim Cook", Score: 0.92},
},
Duration: 250,
}
err := store.SaveSearch(search)
if err != nil {
t.Fatalf("Failed to save search: %v", err)
}
searches, err := store.GetSearches(requestID)
if err != nil {
t.Fatalf("Failed to get searches: %v", err)
}
if len(searches) != 1 {
t.Fatalf("Expected 1 search, got %d", len(searches))
}
if len(searches[0].Entities) != 2 {
t.Errorf("Expected 2 entities, got %d", len(searches[0].Entities))
}
if searches[0].Entities[0].Name != "Apple" {
t.Errorf("Expected first entity 'Apple', got '%s'", searches[0].Entities[0].Name)
}
if len(searches[0].Relations) != 1 {
t.Errorf("Expected 1 relation, got %d", len(searches[0].Relations))
}
if searches[0].Relations[0].Predicate != "CEO_of" {
t.Errorf("Expected predicate 'CEO_of', got '%s'", searches[0].Relations[0].Predicate)
}
if len(searches[0].Graph) != 2 {
t.Errorf("Expected 2 graph nodes, got %d", len(searches[0].Graph))
}
})
t.Run("SaveSearchWithDSL", func(t *testing.T) {
requestID := fmt.Sprintf("req_%d", time.Now().UnixNano())
search := &types.Search{
RequestID: requestID,
ChatID: chat.ChatID,
Query: "Find orders over $1000",
Source: "db",
DSL: map[string]any{
"wheres": []map[string]any{
{"column": "amount", "op": ">", "value": 1000},
},
"orders": []map[string]any{
{"column": "created_at", "option": "desc"},
},
},
Duration: 50,
}
err := store.SaveSearch(search)
if err != nil {
t.Fatalf("Failed to save search: %v", err)
}
searches, err := store.GetSearches(requestID)
if err != nil {
t.Fatalf("Failed to get searches: %v", err)
}
if len(searches) != 1 {
t.Fatalf("Expected 1 search, got %d", len(searches))
}
if searches[0].DSL == nil {
t.Fatal("Expected DSL to be set")
}
wheres, ok := searches[0].DSL["wheres"].([]any)
if !ok || len(wheres) == 0 {
t.Error("Expected wheres in DSL")
}
})
t.Run("SaveSearchWithError", func(t *testing.T) {
requestID := fmt.Sprintf("req_%d", time.Now().UnixNano())
search := &types.Search{
RequestID: requestID,
ChatID: chat.ChatID,
Query: "Failed search",
Source: "web",
Error: "API rate limit exceeded",
Duration: 10,
}
err := store.SaveSearch(search)
if err != nil {
t.Fatalf("Failed to save search: %v", err)
}
searches, err := store.GetSearches(requestID)
if err != nil {
t.Fatalf("Failed to get searches: %v", err)
}
if len(searches) != 1 {
t.Fatalf("Expected 1 search, got %d", len(searches))
}
if searches[0].Error != "API rate limit exceeded" {
t.Errorf("Expected error 'API rate limit exceeded', got '%s'", searches[0].Error)
}
})
t.Run("SaveSearchWithoutRequestID", func(t *testing.T) {
search := &types.Search{
ChatID: chat.ChatID,
Query: "Test",
Source: "web",
}
err := store.SaveSearch(search)
if err == nil {
t.Error("Expected error when saving without request_id")
}
})
t.Run("SaveSearchWithoutChatID", func(t *testing.T) {
search := &types.Search{
RequestID: "req_test",
Query: "Test",
Source: "web",
}
err := store.SaveSearch(search)
if err == nil {
t.Error("Expected error when saving without chat_id")
}
})
t.Run("SaveSearchWithoutSource", func(t *testing.T) {
search := &types.Search{
RequestID: "req_test",
ChatID: chat.ChatID,
Query: "Test",
}
err := store.SaveSearch(search)
if err == nil {
t.Error("Expected error when saving without source")
}
})
t.Run("SaveNilSearch", func(t *testing.T) {
err := store.SaveSearch(nil)
if err == nil {
t.Error("Expected error when saving nil search")
}
})
}
// TestGetSearches tests retrieving search records
func TestGetSearches(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
store, err := xun.NewXun(types.Setting{
Connector: "default",
})
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
// Create a chat
chat := &types.Chat{
AssistantID: "test_assistant",
}
err = store.CreateChat(chat)
if err != nil {
t.Fatalf("Failed to create chat: %v", err)
}
defer store.DeleteChat(chat.ChatID)
t.Run("GetMultipleSearches", func(t *testing.T) {
requestID := fmt.Sprintf("req_%d", time.Now().UnixNano())
// Save multiple searches for the same request
for i := 1; i <= 3; i++ {
search := &types.Search{
RequestID: requestID,
ChatID: chat.ChatID,
Query: fmt.Sprintf("Query %d", i),
Source: "web",
Duration: int64(i * 100),
}
err := store.SaveSearch(search)
if err != nil {
t.Fatalf("Failed to save search %d: %v", i, err)
}
time.Sleep(10 * time.Millisecond) // Ensure different created_at
}
searches, err := store.GetSearches(requestID)
if err != nil {
t.Fatalf("Failed to get searches: %v", err)
}
if len(searches) != 3 {
t.Errorf("Expected 3 searches, got %d", len(searches))
}
// Verify order (by created_at asc)
for i := 0; i < len(searches)-1; i++ {
if searches[i].CreatedAt.After(searches[i+1].CreatedAt) {
t.Error("Searches not ordered by created_at asc")
}
}
})
t.Run("GetSearchesForNonExistentRequest", func(t *testing.T) {
searches, err := store.GetSearches("nonexistent_request")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(searches) != 0 {
t.Errorf("Expected 0 searches, got %d", len(searches))
}
})
t.Run("GetSearchesWithEmptyRequestID", func(t *testing.T) {
_, err := store.GetSearches("")
if err == nil {
t.Error("Expected error when getting searches without request_id")
}
})
}
// TestGetReference tests retrieving a single reference
func TestGetReference(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
store, err := xun.NewXun(types.Setting{
Connector: "default",
})
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
// Create a chat
chat := &types.Chat{
AssistantID: "test_assistant",
}
err = store.CreateChat(chat)
if err != nil {
t.Fatalf("Failed to create chat: %v", err)
}
defer store.DeleteChat(chat.ChatID)
requestID := fmt.Sprintf("req_%d", time.Now().UnixNano())
// Save search with references
search := &types.Search{
RequestID: requestID,
ChatID: chat.ChatID,
Query: "Test query",
Source: "web",
References: []types.Reference{
{Index: 1, Type: "web", Title: "Reference 1", URL: "https://example.com/1"},
{Index: 2, Type: "web", Title: "Reference 2", URL: "https://example.com/2"},
{Index: 3, Type: "kb", Title: "Reference 3", Content: "KB content"},
},
Duration: 100,
}
err = store.SaveSearch(search)
if err != nil {
t.Fatalf("Failed to save search: %v", err)
}
t.Run("GetExistingReference", func(t *testing.T) {
ref, err := store.GetReference(requestID, 1)
if err != nil {
t.Fatalf("Failed to get reference: %v", err)
}
if ref.Title != "Reference 1" {
t.Errorf("Expected title 'Reference 1', got '%s'", ref.Title)
}
if ref.URL != "https://example.com/1" {
t.Errorf("Expected URL 'https://example.com/1', got '%s'", ref.URL)
}
})
t.Run("GetReferenceByIndex", func(t *testing.T) {
ref, err := store.GetReference(requestID, 3)
if err != nil {
t.Fatalf("Failed to get reference: %v", err)
}
if ref.Type != "kb" {
t.Errorf("Expected type 'kb', got '%s'", ref.Type)
}
if ref.Content != "KB content" {
t.Errorf("Expected content 'KB content', got '%s'", ref.Content)
}
})
t.Run("GetNonExistentReference", func(t *testing.T) {
_, err := store.GetReference(requestID, 999)
if err == nil {
t.Error("Expected error when getting non-existent reference")
}
})
t.Run("GetReferenceWithInvalidIndex", func(t *testing.T) {
_, err := store.GetReference(requestID, 0)
if err == nil {
t.Error("Expected error when getting reference with index 0")
}
_, err = store.GetReference(requestID, -1)
if err == nil {
t.Error("Expected error when getting reference with negative index")
}
})
t.Run("GetReferenceWithEmptyRequestID", func(t *testing.T) {
_, err := store.GetReference("", 1)
if err == nil {
t.Error("Expected error when getting reference without request_id")
}
})
}
// TestDeleteSearches tests deleting search records
func TestDeleteSearches(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
store, err := xun.NewXun(types.Setting{
Connector: "default",
})
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
t.Run("DeleteSearchesForChat", func(t *testing.T) {
// Create a chat
chat := &types.Chat{
AssistantID: "test_assistant",
}
err := store.CreateChat(chat)
if err != nil {
t.Fatalf("Failed to create chat: %v", err)
}
defer store.DeleteChat(chat.ChatID)
// Save multiple searches
for i := 1; i <= 3; i++ {
requestID := fmt.Sprintf("req_%d_%d", time.Now().UnixNano(), i)
search := &types.Search{
RequestID: requestID,
ChatID: chat.ChatID,
Query: fmt.Sprintf("Query %d", i),
Source: "web",
Duration: 100,
}
err := store.SaveSearch(search)
if err != nil {
t.Fatalf("Failed to save search: %v", err)
}
}
// Delete all searches for the chat
err = store.DeleteSearches(chat.ChatID)
if err != nil {
t.Fatalf("Failed to delete searches: %v", err)
}
// Note: GetSearches filters by request_id, not chat_id
// We can't easily verify deletion without a GetSearchesByChatID method
// But the soft delete should have been applied
})
t.Run("DeleteSearchesWithEmptyChatID", func(t *testing.T) {
err := store.DeleteSearches("")
if err == nil {
t.Error("Expected error when deleting searches without chat_id")
}
})
}
// TestSearchCompleteWorkflow tests a complete search workflow
func TestSearchCompleteWorkflow(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
store, err := xun.NewXun(types.Setting{
Connector: "default",
})
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
t.Run("CompleteWorkflow", func(t *testing.T) {
// 1. Create chat
chat := &types.Chat{
AssistantID: "workflow_assistant",
Title: "Search Workflow Test",
}
err := store.CreateChat(chat)
if err != nil {
t.Fatalf("Failed to create chat: %v", err)
}
defer store.DeleteChat(chat.ChatID)
requestID := fmt.Sprintf("req_%d", time.Now().UnixNano())
// 2. Save search with full data
search := &types.Search{
RequestID: requestID,
ChatID: chat.ChatID,
Query: "What are the best practices for Go programming?",
Config: map[string]any{
"uses": map[string]any{"search": "builtin", "web": "builtin"},
"web": map[string]any{"provider": "tavily", "max_results": 5},
},
Keywords: []string{"Go", "programming", "best practices"},
Source: "auto",
References: []types.Reference{
{Index: 1, Type: "web", Title: "Effective Go", URL: "https://go.dev/doc/effective_go"},
{Index: 2, Type: "web", Title: "Go Proverbs", URL: "https://go-proverbs.github.io/"},
{Index: 3, Type: "kb", Title: "Internal Go Guide", Content: "Our team's Go coding standards..."},
},
XML: "<references><ref index=\"1\">...</ref></references>",
Prompt: "When citing, use [1], [2], [3] format.",
Duration: 350,
}
err = store.SaveSearch(search)
if err != nil {
t.Fatalf("Failed to save search: %v", err)
}
// 3. Retrieve searches
searches, err := store.GetSearches(requestID)
if err != nil {
t.Fatalf("Failed to get searches: %v", err)
}
if len(searches) != 1 {
t.Fatalf("Expected 1 search, got %d", len(searches))
}
// 4. Verify all fields
s := searches[0]
if s.Query != "What are the best practices for Go programming?" {
t.Errorf("Query mismatch")
}
if len(s.Keywords) != 3 {
t.Errorf("Expected 3 keywords, got %d", len(s.Keywords))
}
if len(s.References) != 3 {
t.Errorf("Expected 3 references, got %d", len(s.References))
}
if s.Config == nil {
t.Error("Config should not be nil")
}
// 5. Get specific reference
ref, err := store.GetReference(requestID, 2)
if err != nil {
t.Fatalf("Failed to get reference: %v", err)
}
if ref.Title != "Go Proverbs" {
t.Errorf("Expected 'Go Proverbs', got '%s'", ref.Title)
}
// 6. Delete searches
err = store.DeleteSearches(chat.ChatID)
if err != nil {
t.Fatalf("Failed to delete searches: %v", err)
}
// 7. Verify deletion (soft delete, so GetSearches should return empty)
deletedSearches, err := store.GetSearches(requestID)
if err != nil {
t.Fatalf("Failed to get searches after delete: %v", err)
}
if len(deletedSearches) != 0 {
t.Errorf("Expected 0 searches after delete, got %d", len(deletedSearches))
}
t.Log("Complete search workflow passed!")
})
}

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@ var systemModels = map[string]string{
"__yao.agent.chat": "yao/models/agent/chat.mod.yao",
"__yao.agent.message": "yao/models/agent/message.mod.yao",
"__yao.agent.resume": "yao/models/agent/resume.mod.yao",
"__yao.agent.search": "yao/models/agent/search.mod.yao",
"__yao.attachment": "yao/models/attachment.mod.yao",
"__yao.audit": "yao/models/audit.mod.yao",
"__yao.config": "yao/models/config.mod.yao",

View file

@ -199,6 +199,7 @@ var testSystemModels = map[string]string{
"__yao.agent.chat": "yao/models/agent/chat.mod.yao",
"__yao.agent.message": "yao/models/agent/message.mod.yao",
"__yao.agent.resume": "yao/models/agent/resume.mod.yao",
"__yao.agent.search": "yao/models/agent/search.mod.yao",
"__yao.attachment": "yao/models/attachment.mod.yao",
"__yao.audit": "yao/models/audit.mod.yao",
"__yao.config": "yao/models/config.mod.yao",

View file

@ -0,0 +1,138 @@
{
"name": "Search",
"label": "Search",
"description": "Search records for citation support and debugging",
"tags": ["agent", "system"],
"builtin": true,
"readonly": true,
"sort": 9999,
"table": { "name": "agent_search", "comment": "Agent search table" },
"columns": [
{
"name": "id",
"type": "ID",
"label": "ID",
"comment": "Auto-increment primary key"
},
{
"name": "request_id",
"type": "string",
"label": "Request ID",
"comment": "Associated request ID",
"length": 64,
"nullable": false,
"index": true
},
{
"name": "chat_id",
"type": "string",
"label": "Chat ID",
"comment": "Associated chat ID",
"length": 64,
"nullable": false,
"index": true
},
{
"name": "query",
"type": "text",
"label": "Query",
"comment": "Original search query",
"nullable": true
},
{
"name": "config",
"type": "json",
"label": "Config",
"comment": "Search config used (for tuning)",
"nullable": true
},
{
"name": "keywords",
"type": "json",
"label": "Keywords",
"comment": "Extracted keywords (from NLP)",
"nullable": true
},
{
"name": "entities",
"type": "json",
"label": "Entities",
"comment": "Extracted entities (for Graph search)",
"nullable": true
},
{
"name": "relations",
"type": "json",
"label": "Relations",
"comment": "Extracted relations (for Graph search)",
"nullable": true
},
{
"name": "dsl",
"type": "json",
"label": "DSL",
"comment": "Generated QueryDSL (for DB search)",
"nullable": true
},
{
"name": "source",
"type": "string",
"label": "Source",
"comment": "Search source: web/kb/db/auto",
"length": 32,
"nullable": false
},
{
"name": "references",
"type": "json",
"label": "References",
"comment": "Reference[] with global index",
"nullable": true
},
{
"name": "graph",
"type": "json",
"label": "Graph",
"comment": "GraphNode[] from knowledge graph",
"nullable": true
},
{
"name": "xml",
"type": "text",
"label": "XML",
"comment": "Formatted XML for LLM context",
"nullable": true
},
{
"name": "prompt",
"type": "text",
"label": "Prompt",
"comment": "Citation instruction prompt",
"nullable": true
},
{
"name": "duration",
"type": "integer",
"label": "Duration",
"comment": "Search duration in milliseconds",
"nullable": true
},
{
"name": "error",
"type": "text",
"label": "Error",
"comment": "Error message if failed",
"nullable": true
}
],
"relations": {
"chat": {
"type": "hasOne",
"model": "__yao.agent.chat",
"key": "chat_id",
"foreign": "chat_id"
}
},
"option": { "timestamps": true, "soft_deletes": true }
}