Enhance Search Functionality with Keyword Extraction and Intent Detection
- Updated the search handling to incorporate keyword extraction with weights, improving the relevance of search results. - Refactored the `shouldAutoSearch` method to return a `SearchIntent` struct, allowing for more nuanced control over search execution based on context. - Enhanced the `buildSearchRequests` function to utilize extracted keywords, optimizing search queries based on user input. - Improved the handling of search types and conditions, ensuring that the system can dynamically adjust search behavior based on intent and configuration. - Updated documentation and prompts to reflect changes in keyword extraction and search intent classification, providing clearer guidelines for usage.
This commit is contained in:
parent
99ac7abdf1
commit
3cf3060e0c
16 changed files with 820 additions and 394 deletions
|
|
@ -205,8 +205,8 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
// ================================================
|
// ================================================
|
||||||
// Execute Auto Search (if enabled)
|
// Execute Auto Search (if enabled)
|
||||||
// ================================================
|
// ================================================
|
||||||
if ast.shouldAutoSearch(ctx, completionMessages, createResponse, opts) {
|
if intent := ast.shouldAutoSearch(ctx, completionMessages, createResponse, opts); intent != nil {
|
||||||
refCtx := ast.executeAutoSearch(ctx, completionMessages, createResponse, opts)
|
refCtx := ast.executeAutoSearch(ctx, completionMessages, createResponse, intent, opts)
|
||||||
if refCtx != nil && len(refCtx.References) > 0 {
|
if refCtx != nil && len(refCtx.References) > 0 {
|
||||||
completionMessages = ast.injectSearchContext(completionMessages, refCtx)
|
completionMessages = ast.injectSearchContext(completionMessages, refCtx)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,32 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// shouldAutoSearch determines if auto search should be executed
|
// shouldAutoSearch determines if auto search should be executed
|
||||||
// Returns false if:
|
// Returns nil if search should be skipped, otherwise returns SearchIntent with types to search
|
||||||
|
// Search is skipped if:
|
||||||
// - opts.Skip.Search is true
|
// - opts.Skip.Search is true
|
||||||
|
// - createResponse.Search is false
|
||||||
// - uses.search is "disabled"
|
// - uses.search is "disabled"
|
||||||
// - assistant has no search configuration
|
// - assistant has no search configuration
|
||||||
// - needsearch intent detection returns false
|
// - needsearch intent detection returns false
|
||||||
func (ast *Assistant) shouldAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, opts *context.Options) bool {
|
func (ast *Assistant) shouldAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, opts *context.Options) *SearchIntent {
|
||||||
// Check if search is skipped via options
|
// Check if search is skipped via options
|
||||||
if opts != nil && opts.Skip != nil && opts.Skip.Search {
|
if opts != nil && opts.Skip != nil && opts.Skip.Search {
|
||||||
ctx.Logger.Debug("Auto search skipped by opts.Skip.Search")
|
ctx.Logger.Debug("Auto search skipped by opts.Skip.Search")
|
||||||
return false
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check createResponse.Search field (highest priority from Create hook)
|
||||||
|
// Supports: bool | SearchIntent | nil
|
||||||
|
if createResponse != nil && createResponse.Search != nil {
|
||||||
|
intent := parseSearchField(createResponse.Search)
|
||||||
|
if intent != nil {
|
||||||
|
if !intent.NeedSearch {
|
||||||
|
ctx.Logger.Info("Auto search disabled by createResponse.Search")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx.Logger.Info("Auto search controlled by createResponse.Search: types=%v", intent.SearchTypes)
|
||||||
|
return intent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get merged uses configuration
|
// Get merged uses configuration
|
||||||
|
|
@ -35,27 +51,101 @@ func (ast *Assistant) shouldAutoSearch(ctx *context.Context, messages []context.
|
||||||
// Check if search is explicitly disabled
|
// Check if search is explicitly disabled
|
||||||
if uses != nil && uses.Search == "disabled" {
|
if uses != nil && uses.Search == "disabled" {
|
||||||
ctx.Logger.Info("Auto search disabled by uses.search=disabled")
|
ctx.Logger.Info("Auto search disabled by uses.search=disabled")
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if assistant has search configuration
|
// Check if assistant has search configuration
|
||||||
if ast.Search == nil && (uses == nil || uses.Search == "") {
|
if ast.Search == nil && (uses == nil || uses.Search == "") {
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check search intent using __yao.needsearch agent
|
// Check search intent using __yao.needsearch agent
|
||||||
if !ast.checkSearchIntent(ctx, messages) {
|
intent := ast.checkSearchIntent(ctx, messages)
|
||||||
|
if intent == nil || !intent.NeedSearch {
|
||||||
ctx.Logger.Info("Auto search skipped: intent detection returned false")
|
ctx.Logger.Info("Auto search skipped: intent detection returned false")
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if search is enabled (builtin, agent, mcp, or empty means builtin)
|
return intent
|
||||||
return true
|
}
|
||||||
|
|
||||||
|
// parseSearchField parses the Search field from HookCreateResponse
|
||||||
|
// Supports: bool | SearchIntent | map[string]any | nil
|
||||||
|
func parseSearchField(search any) *SearchIntent {
|
||||||
|
if search == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := search.(type) {
|
||||||
|
case bool:
|
||||||
|
// bool: true = enable all, false = disable all
|
||||||
|
if v {
|
||||||
|
return &SearchIntent{
|
||||||
|
NeedSearch: true,
|
||||||
|
SearchTypes: []string{"web", "kb", "db"},
|
||||||
|
Confidence: 1.0,
|
||||||
|
Reason: "enabled by hook",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &SearchIntent{
|
||||||
|
NeedSearch: false,
|
||||||
|
SearchTypes: []string{},
|
||||||
|
Confidence: 1.0,
|
||||||
|
Reason: "disabled by hook",
|
||||||
|
}
|
||||||
|
|
||||||
|
case *SearchIntent:
|
||||||
|
// SearchIntent is alias for context.SearchIntent, so this covers both
|
||||||
|
return v
|
||||||
|
|
||||||
|
case SearchIntent:
|
||||||
|
return &v
|
||||||
|
|
||||||
|
case map[string]any:
|
||||||
|
// Parse from map (e.g., from JSON)
|
||||||
|
intent := &SearchIntent{
|
||||||
|
NeedSearch: false,
|
||||||
|
SearchTypes: []string{},
|
||||||
|
Confidence: 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
if needSearch, ok := v["need_search"].(bool); ok {
|
||||||
|
intent.NeedSearch = needSearch
|
||||||
|
}
|
||||||
|
|
||||||
|
if types, ok := v["search_types"].([]any); ok {
|
||||||
|
for _, t := range types {
|
||||||
|
if typeStr, ok := t.(string); ok {
|
||||||
|
intent.SearchTypes = append(intent.SearchTypes, typeStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if confidence, ok := v["confidence"].(float64); ok {
|
||||||
|
intent.Confidence = confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
if reason, ok := v["reason"].(string); ok {
|
||||||
|
intent.Reason = reason
|
||||||
|
}
|
||||||
|
|
||||||
|
return intent
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkSearchIntent uses __yao.needsearch agent to determine if search is needed
|
// checkSearchIntent uses __yao.needsearch agent to determine if search is needed
|
||||||
// Returns true if search is needed, false otherwise
|
// Returns SearchIntent with search types and confidence
|
||||||
func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context.Message) bool {
|
func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context.Message) *SearchIntent {
|
||||||
|
// Default intent: no search needed (fallback when agent unavailable or fails)
|
||||||
|
defaultIntent := &SearchIntent{
|
||||||
|
NeedSearch: false,
|
||||||
|
SearchTypes: []string{},
|
||||||
|
Confidence: 0,
|
||||||
|
}
|
||||||
|
|
||||||
// Filter out system messages and pass full conversation context
|
// Filter out system messages and pass full conversation context
|
||||||
var intentMessages []context.Message
|
var intentMessages []context.Message
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
|
|
@ -65,14 +155,14 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(intentMessages) == 0 {
|
if len(intentMessages) == 0 {
|
||||||
return true // No messages, proceed with search
|
return defaultIntent // No messages, skip search
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get __yao.needsearch agent
|
// Try to get __yao.needsearch agent
|
||||||
needsearchAst, err := Get("__yao.needsearch")
|
needsearchAst, err := Get("__yao.needsearch")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Debug("__yao.needsearch agent not available: %v, proceeding with search", err)
|
ctx.Logger.Debug("__yao.needsearch agent not available: %v, skipping search", err)
|
||||||
return true // Agent not available, proceed with search
|
return defaultIntent // Agent not available, skip search
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Output: Send loading message ===
|
// === Output: Send loading message ===
|
||||||
|
|
@ -90,10 +180,10 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
|
||||||
|
|
||||||
result, err := needsearchAst.Stream(ctx, intentMessages, opts)
|
result, err := needsearchAst.Stream(ctx, intentMessages, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Debug("__yao.needsearch failed: %v, proceeding with search", err)
|
ctx.Logger.Debug("__yao.needsearch failed: %v, skipping search", err)
|
||||||
// === Output: Send done (error case, proceed with search) ===
|
// === Output: Send done (error case, skip search) ===
|
||||||
ast.sendIntentDone(ctx, loadingID, true, "")
|
ast.sendIntentDone(ctx, loadingID, false, "")
|
||||||
return true // On error, proceed with search
|
return defaultIntent // On error, skip search
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the result
|
// Parse the result
|
||||||
|
|
@ -109,11 +199,12 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
|
||||||
intentData = nextData
|
intentData = nextData
|
||||||
}
|
}
|
||||||
|
|
||||||
if needSearch, ok := intentData["need_search"].(bool); ok {
|
intent := parseSearchIntent(intentData)
|
||||||
reason, _ := intentData["reason"].(string)
|
if intent != nil {
|
||||||
ctx.Logger.Debug("Search intent (from Next): need_search=%v, reason=%s", needSearch, reason)
|
ctx.Logger.Debug("Search intent (from Next): need_search=%v, types=%v, confidence=%.2f, reason=%s",
|
||||||
ast.sendIntentDone(ctx, loadingID, needSearch, reason)
|
intent.NeedSearch, intent.SearchTypes, intent.Confidence, intent.Reason)
|
||||||
return needSearch
|
ast.sendIntentDone(ctx, loadingID, intent.NeedSearch, intent.Reason)
|
||||||
|
return intent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -122,24 +213,75 @@ func (ast *Assistant) checkSearchIntent(ctx *context.Context, messages []context
|
||||||
if result.Completion != nil {
|
if result.Completion != nil {
|
||||||
content, ok := result.Completion.Content.(string)
|
content, ok := result.Completion.Content.(string)
|
||||||
if !ok || content == "" {
|
if !ok || content == "" {
|
||||||
ast.sendIntentDone(ctx, loadingID, true, "")
|
ast.sendIntentDone(ctx, loadingID, false, "")
|
||||||
return true
|
return defaultIntent
|
||||||
}
|
}
|
||||||
needSearch, reason := parseNeedSearchFromContent(content)
|
intent := parseSearchIntentFromContent(content)
|
||||||
ctx.Logger.Debug("Search intent (from Content): need_search=%v, reason=%s", needSearch, reason)
|
ctx.Logger.Debug("Search intent (from Content): need_search=%v, types=%v, confidence=%.2f, reason=%s",
|
||||||
ast.sendIntentDone(ctx, loadingID, needSearch, reason)
|
intent.NeedSearch, intent.SearchTypes, intent.Confidence, intent.Reason)
|
||||||
return needSearch
|
ast.sendIntentDone(ctx, loadingID, intent.NeedSearch, intent.Reason)
|
||||||
|
return intent
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: proceed with search if we can't parse the result
|
// Default: skip search if we can't parse the result
|
||||||
// === Output: Send done (default case) ===
|
// === Output: Send done (default case) ===
|
||||||
ast.sendIntentDone(ctx, loadingID, true, "")
|
ast.sendIntentDone(ctx, loadingID, false, "")
|
||||||
return true
|
return defaultIntent
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseNeedSearchFromContent parses need_search result from LLM completion content
|
// parseSearchIntent parses SearchIntent from intent data map
|
||||||
|
func parseSearchIntent(intentData map[string]interface{}) *SearchIntent {
|
||||||
|
if intentData == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
needSearch, ok := intentData["need_search"].(bool)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
intent := &SearchIntent{
|
||||||
|
NeedSearch: needSearch,
|
||||||
|
SearchTypes: []string{},
|
||||||
|
Confidence: 0.5, // Default confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse search_types
|
||||||
|
if types, ok := intentData["search_types"].([]interface{}); ok {
|
||||||
|
for _, t := range types {
|
||||||
|
if typeStr, ok := t.(string); ok {
|
||||||
|
// Validate type
|
||||||
|
typeStr = strings.ToLower(typeStr)
|
||||||
|
if typeStr == "web" || typeStr == "kb" || typeStr == "db" {
|
||||||
|
intent.SearchTypes = append(intent.SearchTypes, typeStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse confidence
|
||||||
|
if confidence, ok := intentData["confidence"].(float64); ok {
|
||||||
|
intent.Confidence = confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reason
|
||||||
|
if reason, ok := intentData["reason"].(string); ok {
|
||||||
|
intent.Reason = reason
|
||||||
|
}
|
||||||
|
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSearchIntentFromContent parses SearchIntent from LLM completion content
|
||||||
// Handles JSON wrapped in markdown code blocks
|
// Handles JSON wrapped in markdown code blocks
|
||||||
func parseNeedSearchFromContent(content string) (bool, string) {
|
func parseSearchIntentFromContent(content string) *SearchIntent {
|
||||||
|
// Default intent: no search needed
|
||||||
|
defaultIntent := &SearchIntent{
|
||||||
|
NeedSearch: false,
|
||||||
|
SearchTypes: []string{},
|
||||||
|
Confidence: 0,
|
||||||
|
}
|
||||||
|
|
||||||
// Remove markdown code block if present
|
// Remove markdown code block if present
|
||||||
content = strings.TrimSpace(content)
|
content = strings.TrimSpace(content)
|
||||||
if strings.HasPrefix(content, "```json") {
|
if strings.HasPrefix(content, "```json") {
|
||||||
|
|
@ -155,17 +297,16 @@ func parseNeedSearchFromContent(content string) (bool, string) {
|
||||||
// Try to parse JSON
|
// Try to parse JSON
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(content), &result); err != nil {
|
if err := json.Unmarshal([]byte(content), &result); err != nil {
|
||||||
// Failed to parse, default to search
|
// Failed to parse, default to no search
|
||||||
return true, ""
|
return defaultIntent
|
||||||
}
|
}
|
||||||
|
|
||||||
needSearch, ok := result["need_search"].(bool)
|
intent := parseSearchIntent(result)
|
||||||
if !ok {
|
if intent == nil {
|
||||||
return true, ""
|
return defaultIntent
|
||||||
}
|
}
|
||||||
|
|
||||||
reason, _ := result["reason"].(string)
|
return intent
|
||||||
return needSearch, reason
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendIntentLoading sends the initial intent detection loading message
|
// sendIntentLoading sends the initial intent detection loading message
|
||||||
|
|
@ -261,10 +402,11 @@ func (ast *Assistant) getMergedSearchUses(createResponse *context.HookCreateResp
|
||||||
return uses
|
return uses
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeAutoSearch executes auto search based on configuration
|
// executeAutoSearch executes auto search based on configuration and intent
|
||||||
// Returns ReferenceContext with results and formatted context
|
// Returns ReferenceContext with results and formatted context
|
||||||
|
// intent specifies which search types to execute (from needsearch agent)
|
||||||
// opts is optional, used to check Skip.Keyword
|
// opts is optional, used to check Skip.Keyword
|
||||||
func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, opts ...*context.Options) *searchTypes.ReferenceContext {
|
func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, intent *SearchIntent, opts ...*context.Options) *searchTypes.ReferenceContext {
|
||||||
ctx.Logger.Phase("Search")
|
ctx.Logger.Phase("Search")
|
||||||
defer ctx.Logger.PhaseComplete("Search")
|
defer ctx.Logger.PhaseComplete("Search")
|
||||||
|
|
||||||
|
|
@ -301,33 +443,23 @@ func (ast *Assistant) executeAutoSearch(ctx *context.Context, messages []context
|
||||||
skipKeyword = opts[0].Skip.Keyword
|
skipKeyword = opts[0].Skip.Keyword
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract keywords for web search if:
|
// Build search requests based on configuration and intent
|
||||||
// 1. uses.keyword is configured (not empty)
|
// Keyword extraction is done inside buildSearchRequests for web search
|
||||||
// 2. Skip.Keyword is not true
|
buildOpts := &buildSearchRequestsOptions{
|
||||||
// 3. Web search is enabled
|
skipKeyword: skipKeyword,
|
||||||
var extractedKeywords []string
|
usesKeyword: searchUses.Keyword,
|
||||||
webSearchEnabled := searchConfig != nil && searchConfig.Web != nil
|
|
||||||
if webSearchEnabled && !skipKeyword && searchUses.Keyword != "" {
|
|
||||||
extractor := keyword.NewExtractor(searchUses.Keyword, searchConfig.Keyword)
|
|
||||||
keywords, err := extractor.Extract(ctx, query, nil)
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
requests, extractedKeywords := ast.buildSearchRequests(ctx, query, searchConfig, intent, buildOpts)
|
||||||
|
|
||||||
// Build search requests based on configuration
|
|
||||||
requests := ast.buildSearchRequests(query, searchConfig)
|
|
||||||
if len(requests) == 0 {
|
if len(requests) == 0 {
|
||||||
ctx.Logger.Info("No search requests to execute")
|
ctx.Logger.Info("No search requests to execute")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update query if keywords were extracted (for web search)
|
||||||
|
if len(extractedKeywords) > 0 {
|
||||||
|
query = keywordsToQuery(extractedKeywords)
|
||||||
|
}
|
||||||
|
|
||||||
// === Output: Send loading message ===
|
// === Output: Send loading message ===
|
||||||
loadingID := ast.sendSearchLoading(ctx)
|
loadingID := ast.sendSearchLoading(ctx)
|
||||||
|
|
||||||
|
|
@ -425,6 +557,52 @@ func (ast *Assistant) sendSearchLoading(ctx *context.Context) string {
|
||||||
return msgID
|
return msgID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendKeywordLoading sends the keyword extraction loading message
|
||||||
|
// Returns the message ID for later replacement
|
||||||
|
func (ast *Assistant) sendKeywordLoading(ctx *context.Context) string {
|
||||||
|
loadingMsg := i18n.T(ctx.Locale, "search.keyword.loading")
|
||||||
|
|
||||||
|
msg := &message.Message{
|
||||||
|
Type: "loading",
|
||||||
|
Props: map[string]any{
|
||||||
|
"message": loadingMsg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send and get message ID
|
||||||
|
msgID, err := ctx.SendStream(msg)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Logger.Warn("Failed to send keyword loading message: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgID
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendKeywordDone replaces keyword loading with done message
|
||||||
|
func (ast *Assistant) sendKeywordDone(ctx *context.Context, loadingID string, success bool) {
|
||||||
|
if loadingID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMsg := i18n.T(ctx.Locale, "search.keyword.done")
|
||||||
|
|
||||||
|
msg := &message.Message{
|
||||||
|
MessageID: loadingID,
|
||||||
|
Delta: true,
|
||||||
|
DeltaAction: message.DeltaReplace,
|
||||||
|
Type: "loading",
|
||||||
|
Props: map[string]any{
|
||||||
|
"message": resultMsg,
|
||||||
|
"done": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.Send(msg); err != nil {
|
||||||
|
ctx.Logger.Warn("Failed to send keyword done message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// sendSearchResult replaces loading with result message (without done flag)
|
// sendSearchResult replaces loading with result message (without done flag)
|
||||||
func (ast *Assistant) sendSearchResult(ctx *context.Context, loadingID string, count int) {
|
func (ast *Assistant) sendSearchResult(ctx *context.Context, loadingID string, count int) {
|
||||||
if loadingID == "" {
|
if loadingID == "" {
|
||||||
|
|
@ -554,22 +732,67 @@ func (ast *Assistant) completeSearchTrace(node traceTypes.Node, resultCount int,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSearchRequests builds search requests based on assistant configuration
|
// buildSearchRequestsOptions contains options for building search requests
|
||||||
func (ast *Assistant) buildSearchRequests(query string, config *searchTypes.Config) []*searchTypes.Request {
|
type buildSearchRequestsOptions struct {
|
||||||
var requests []*searchTypes.Request
|
skipKeyword bool // Skip keyword extraction
|
||||||
|
usesKeyword string // Keyword extractor config: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSearchRequests builds search requests based on assistant configuration and intent
|
||||||
|
// intent specifies which search types to execute (from needsearch agent)
|
||||||
|
// Returns requests and extracted keywords (if any)
|
||||||
|
func (ast *Assistant) buildSearchRequests(ctx *context.Context, query string, config *searchTypes.Config, intent *SearchIntent, opts *buildSearchRequestsOptions) ([]*searchTypes.Request, []searchTypes.Keyword) {
|
||||||
|
var requests []*searchTypes.Request
|
||||||
|
var extractedKeywords []searchTypes.Keyword
|
||||||
|
|
||||||
|
// Helper to check if a search type is allowed by intent
|
||||||
|
isTypeAllowed := func(searchType string) bool {
|
||||||
|
if intent == nil || len(intent.SearchTypes) == 0 {
|
||||||
|
return true // No intent or empty types means all types allowed
|
||||||
|
}
|
||||||
|
for _, t := range intent.SearchTypes {
|
||||||
|
if t == searchType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web search - check if web search is configured and allowed by intent
|
||||||
|
if config != nil && config.Web != nil && isTypeAllowed("web") {
|
||||||
|
webQuery := query
|
||||||
|
|
||||||
|
// Extract keywords for web search if configured
|
||||||
|
if opts != nil && !opts.skipKeyword && opts.usesKeyword != "" {
|
||||||
|
// === Output: Send keyword extraction loading ===
|
||||||
|
keywordLoadingID := ast.sendKeywordLoading(ctx)
|
||||||
|
|
||||||
|
extractor := keyword.NewExtractor(opts.usesKeyword, config.Keyword)
|
||||||
|
keywords, err := extractor.Extract(ctx, query, nil)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Logger.Warn("Keyword extraction failed, using original query: %v", err)
|
||||||
|
ast.sendKeywordDone(ctx, keywordLoadingID, false)
|
||||||
|
} else if len(keywords) > 0 {
|
||||||
|
extractedKeywords = keywords
|
||||||
|
// Use extracted keywords as the search query for web search
|
||||||
|
webQuery = keywordsToQuery(keywords)
|
||||||
|
ctx.Logger.Info("Extracted keywords for web search: %s -> %s", truncateString(query, 30), webQuery)
|
||||||
|
ast.sendKeywordDone(ctx, keywordLoadingID, true)
|
||||||
|
} else {
|
||||||
|
ast.sendKeywordDone(ctx, keywordLoadingID, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Web search - check if web search is configured
|
|
||||||
if config != nil && config.Web != nil {
|
|
||||||
requests = append(requests, &searchTypes.Request{
|
requests = append(requests, &searchTypes.Request{
|
||||||
Type: searchTypes.SearchTypeWeb,
|
Type: searchTypes.SearchTypeWeb,
|
||||||
Query: query,
|
Query: webQuery,
|
||||||
Source: searchTypes.SourceAuto,
|
Source: searchTypes.SourceAuto,
|
||||||
Limit: config.Web.MaxResults,
|
Limit: config.Web.MaxResults,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// KB search - check if KB is configured
|
// KB search - check if KB is configured and allowed by intent
|
||||||
if ast.KB != nil && len(ast.KB.Collections) > 0 {
|
if ast.KB != nil && len(ast.KB.Collections) > 0 && isTypeAllowed("kb") {
|
||||||
limit := 10
|
limit := 10
|
||||||
threshold := 0.7
|
threshold := 0.7
|
||||||
if config != nil && config.KB != nil {
|
if config != nil && config.KB != nil {
|
||||||
|
|
@ -579,7 +802,7 @@ func (ast *Assistant) buildSearchRequests(query string, config *searchTypes.Conf
|
||||||
}
|
}
|
||||||
requests = append(requests, &searchTypes.Request{
|
requests = append(requests, &searchTypes.Request{
|
||||||
Type: searchTypes.SearchTypeKB,
|
Type: searchTypes.SearchTypeKB,
|
||||||
Query: query,
|
Query: query, // KB uses original query for semantic search
|
||||||
Source: searchTypes.SourceAuto,
|
Source: searchTypes.SourceAuto,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Collections: ast.KB.Collections,
|
Collections: ast.KB.Collections,
|
||||||
|
|
@ -588,22 +811,22 @@ func (ast *Assistant) buildSearchRequests(query string, config *searchTypes.Conf
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB search - check if DB is configured
|
// DB search - check if DB is configured and allowed by intent
|
||||||
if ast.DB != nil && len(ast.DB.Models) > 0 {
|
if ast.DB != nil && len(ast.DB.Models) > 0 && isTypeAllowed("db") {
|
||||||
limit := 20
|
limit := 20
|
||||||
if config != nil && config.DB != nil && config.DB.MaxResults > 0 {
|
if config != nil && config.DB != nil && config.DB.MaxResults > 0 {
|
||||||
limit = config.DB.MaxResults
|
limit = config.DB.MaxResults
|
||||||
}
|
}
|
||||||
requests = append(requests, &searchTypes.Request{
|
requests = append(requests, &searchTypes.Request{
|
||||||
Type: searchTypes.SearchTypeDB,
|
Type: searchTypes.SearchTypeDB,
|
||||||
Query: query,
|
Query: query, // DB uses original query for QueryDSL generation
|
||||||
Source: searchTypes.SourceAuto,
|
Source: searchTypes.SourceAuto,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Models: ast.DB.Models,
|
Models: ast.DB.Models,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return requests
|
return requests, extractedKeywords
|
||||||
}
|
}
|
||||||
|
|
||||||
// injectSearchContext injects search results into messages
|
// injectSearchContext injects search results into messages
|
||||||
|
|
@ -699,7 +922,7 @@ func truncateString(s string, maxLen int) string {
|
||||||
// SearchExecutionResult holds all data from search execution for storage
|
// SearchExecutionResult holds all data from search execution for storage
|
||||||
type SearchExecutionResult struct {
|
type SearchExecutionResult struct {
|
||||||
Query string // Original query (before keyword optimization)
|
Query string // Original query (before keyword optimization)
|
||||||
Keywords []string // Extracted keywords
|
Keywords []searchTypes.Keyword // Extracted keywords with weights
|
||||||
Config map[string]any // Search config used
|
Config map[string]any // Search config used
|
||||||
RefCtx *searchTypes.ReferenceContext // Reference context with results
|
RefCtx *searchTypes.ReferenceContext // Reference context with results
|
||||||
Duration int64 // Search duration in ms
|
Duration int64 // Search duration in ms
|
||||||
|
|
@ -707,6 +930,54 @@ type SearchExecutionResult struct {
|
||||||
SearchType string // "auto", "web", "kb", "db"
|
SearchType string // "auto", "web", "kb", "db"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keywordsToQuery converts keywords with weights to a search query string
|
||||||
|
// Keywords are sorted by weight (descending) and joined with spaces
|
||||||
|
func keywordsToQuery(keywords []searchTypes.Keyword) string {
|
||||||
|
if len(keywords) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by weight descending (higher weight first)
|
||||||
|
sorted := make([]searchTypes.Keyword, len(keywords))
|
||||||
|
copy(sorted, keywords)
|
||||||
|
for i := 0; i < len(sorted)-1; i++ {
|
||||||
|
for j := i + 1; j < len(sorted); j++ {
|
||||||
|
if sorted[j].W > sorted[i].W {
|
||||||
|
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join keywords
|
||||||
|
parts := make([]string, len(sorted))
|
||||||
|
for i, kw := range sorted {
|
||||||
|
parts[i] = kw.K
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// keywordsToStrings converts keywords to string slice for storage
|
||||||
|
func keywordsToStrings(keywords []searchTypes.Keyword) []string {
|
||||||
|
if len(keywords) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]string, len(keywords))
|
||||||
|
for i, kw := range keywords {
|
||||||
|
result[i] = kw.K
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsSearchType checks if a search type is in the list
|
||||||
|
func containsSearchType(types []string, searchType string) bool {
|
||||||
|
for _, t := range types {
|
||||||
|
if t == searchType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// saveSearch saves search results to storage
|
// saveSearch saves search results to storage
|
||||||
// Called after search execution completes (success or failure)
|
// Called after search execution completes (success or failure)
|
||||||
func (ast *Assistant) saveSearch(ctx *context.Context, execResult *SearchExecutionResult) {
|
func (ast *Assistant) saveSearch(ctx *context.Context, execResult *SearchExecutionResult) {
|
||||||
|
|
@ -722,7 +993,7 @@ func (ast *Assistant) saveSearch(ctx *context.Context, execResult *SearchExecuti
|
||||||
RequestID: ctx.RequestID(),
|
RequestID: ctx.RequestID(),
|
||||||
ChatID: ctx.ChatID,
|
ChatID: ctx.ChatID,
|
||||||
Query: execResult.Query,
|
Query: execResult.Query,
|
||||||
Keywords: execResult.Keywords,
|
Keywords: keywordsToStrings(execResult.Keywords),
|
||||||
Config: execResult.Config,
|
Config: execResult.Config,
|
||||||
Source: execResult.SearchType,
|
Source: execResult.SearchType,
|
||||||
Duration: execResult.Duration,
|
Duration: execResult.Duration,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ type NextProcessContext struct {
|
||||||
CreateResponse *chatctx.HookCreateResponse // Create hook response
|
CreateResponse *chatctx.HookCreateResponse // Create hook response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchIntent is an alias for context.SearchIntent
|
||||||
|
// Used for search intent detection from __yao.needsearch agent
|
||||||
|
type SearchIntent = chatctx.SearchIntent
|
||||||
|
|
||||||
// ParsedContent extracts the actual tool return value from MCP ToolContent array
|
// ParsedContent extracts the actual tool return value from MCP ToolContent array
|
||||||
// According to MCP protocol:
|
// According to MCP protocol:
|
||||||
// - Content is []ToolContent array
|
// - Content is []ToolContent array
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ func (opts *Options) ToMap() map[string]interface{} {
|
||||||
result["mode"] = opts.Mode
|
result["mode"] = opts.Mode
|
||||||
}
|
}
|
||||||
if opts.Search != nil {
|
if opts.Search != nil {
|
||||||
result["search"] = *opts.Search
|
result["search"] = opts.Search
|
||||||
}
|
}
|
||||||
if opts.Skip != nil {
|
if opts.Skip != nil {
|
||||||
result["skip"] = opts.Skip
|
result["skip"] = opts.Skip
|
||||||
|
|
@ -50,8 +50,9 @@ func OptionsFromMap(m map[string]interface{}) *Options {
|
||||||
if mode, ok := m["mode"].(string); ok {
|
if mode, ok := m["mode"].(string); ok {
|
||||||
opts.Mode = mode
|
opts.Mode = mode
|
||||||
}
|
}
|
||||||
if search, ok := m["search"].(bool); ok {
|
// Search supports: bool | SearchIntent | map[string]any | nil
|
||||||
opts.Search = &search
|
if search := m["search"]; search != nil {
|
||||||
|
opts.Search = search
|
||||||
}
|
}
|
||||||
if skipMap, ok := m["skip"].(map[string]interface{}); ok {
|
if skipMap, ok := m["skip"].(map[string]interface{}); ok {
|
||||||
skip := &Skip{}
|
skip := &Skip{}
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,15 @@ type Context struct {
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"` // The metadata of the request, it will be used to pass data to the page
|
Metadata map[string]interface{} `json:"metadata,omitempty"` // The metadata of the request, it will be used to pass data to the page
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchIntent represents the result of search intent detection
|
||||||
|
// Used by Create hook to specify fine-grained search behavior
|
||||||
|
type SearchIntent struct {
|
||||||
|
NeedSearch bool `json:"need_search"` // Whether search is needed
|
||||||
|
SearchTypes []string `json:"search_types,omitempty"` // Types of search to perform: "web", "kb", "db"
|
||||||
|
Confidence float64 `json:"confidence,omitempty"` // Confidence level (0-1)
|
||||||
|
Reason string `json:"reason,omitempty"` // Reason for the decision
|
||||||
|
}
|
||||||
|
|
||||||
// Options represents the options for the context
|
// Options represents the options for the context
|
||||||
type Options struct {
|
type Options struct {
|
||||||
|
|
||||||
|
|
@ -284,8 +293,11 @@ type Options struct {
|
||||||
// Disable global prompts, default is false
|
// Disable global prompts, default is false
|
||||||
DisableGlobalPrompts bool `json:"disable_global_prompts,omitempty"` // Temporarily disable global prompts for this request
|
DisableGlobalPrompts bool `json:"disable_global_prompts,omitempty"` // Temporarily disable global prompts for this request
|
||||||
|
|
||||||
// Search mode, default is true
|
// Search controls search behavior, supports multiple types:
|
||||||
Search *bool `json:"search,omitempty"` // Search mode, default is true
|
// - bool: true = enable all search types, false = disable all search
|
||||||
|
// - SearchIntent: fine-grained control with specific types, confidence, etc.
|
||||||
|
// - nil: use default behavior (determined by __yao.needsearch agent)
|
||||||
|
Search any `json:"search,omitempty"` // Search mode: bool | SearchIntent | nil
|
||||||
|
|
||||||
// Agent mode, use to select the mode of the request, default is "chat"
|
// Agent mode, use to select the mode of the request, default is "chat"
|
||||||
Mode string `json:"mode,omitempty"` // Agent mode, use to select the mode of the request, default is "chat"
|
Mode string `json:"mode,omitempty"` // Agent mode, use to select the mode of the request, default is "chat"
|
||||||
|
|
@ -372,6 +384,12 @@ type HookCreateResponse struct {
|
||||||
|
|
||||||
// ForceUses controls whether to force using Uses tools even when model has native capabilities
|
// ForceUses controls whether to force using Uses tools even when model has native capabilities
|
||||||
ForceUses *bool `json:"force_uses,omitempty"` // Force using Uses tools regardless of model capabilities
|
ForceUses *bool `json:"force_uses,omitempty"` // Force using Uses tools regardless of model capabilities
|
||||||
|
|
||||||
|
// Search controls search behavior, supports multiple types:
|
||||||
|
// - bool: true = enable all search types, false = disable all search
|
||||||
|
// - SearchIntent: fine-grained control with specific types, confidence, etc.
|
||||||
|
// - nil: use default behavior (determined by __yao.needsearch agent)
|
||||||
|
Search any `json:"search,omitempty"` // Search mode: bool | SearchIntent | nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NextHookPayload payload for the next hook
|
// NextHookPayload payload for the next hook
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,10 @@ func init() {
|
||||||
"search.intent.need_search": "Searching for references...",
|
"search.intent.need_search": "Searching for references...",
|
||||||
"search.intent.no_search": "No references needed",
|
"search.intent.no_search": "No references needed",
|
||||||
|
|
||||||
|
// Keyword Extraction: assistant/search.go - Keyword extraction messages
|
||||||
|
"search.keyword.loading": "Analyzing conversation...",
|
||||||
|
"search.keyword.done": "Analysis complete",
|
||||||
|
|
||||||
// Search: assistant/search.go - Trace labels
|
// Search: assistant/search.go - Trace labels
|
||||||
"search.trace.label": "Search",
|
"search.trace.label": "Search",
|
||||||
"search.trace.description": "Search the web and knowledge base for relevant information",
|
"search.trace.description": "Search the web and knowledge base for relevant information",
|
||||||
|
|
@ -201,6 +205,10 @@ func init() {
|
||||||
"search.intent.need_search": "正在查询相关资料...",
|
"search.intent.need_search": "正在查询相关资料...",
|
||||||
"search.intent.no_search": "无需查询资料",
|
"search.intent.no_search": "无需查询资料",
|
||||||
|
|
||||||
|
// Keyword Extraction: assistant/search.go - Keyword extraction messages
|
||||||
|
"search.keyword.loading": "正在分析对话内容...",
|
||||||
|
"search.keyword.done": "分析完成",
|
||||||
|
|
||||||
// Search: assistant/search.go - Trace labels
|
// Search: assistant/search.go - Trace labels
|
||||||
"search.trace.label": "搜索",
|
"search.trace.label": "搜索",
|
||||||
"search.trace.description": "搜索网络和知识库获取相关信息",
|
"search.trace.description": "搜索网络和知识库获取相关信息",
|
||||||
|
|
@ -318,6 +326,10 @@ func init() {
|
||||||
"search.intent.need_search": "正在查询相关资料...",
|
"search.intent.need_search": "正在查询相关资料...",
|
||||||
"search.intent.no_search": "无需查询资料",
|
"search.intent.no_search": "无需查询资料",
|
||||||
|
|
||||||
|
// Keyword Extraction: assistant/search.go - Keyword extraction messages
|
||||||
|
"search.keyword.loading": "正在分析对话内容...",
|
||||||
|
"search.keyword.done": "分析完成",
|
||||||
|
|
||||||
// Search: assistant/search.go - Trace labels
|
// Search: assistant/search.go - Trace labels
|
||||||
"search.trace.label": "搜索",
|
"search.trace.label": "搜索",
|
||||||
"search.trace.description": "搜索网络和知识库获取相关信息",
|
"search.trace.description": "搜索网络和知识库获取相关信息",
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,10 @@ func initAssistant() error {
|
||||||
Audio: agentDSL.Uses.Audio,
|
Audio: agentDSL.Uses.Audio,
|
||||||
Search: agentDSL.Uses.Search,
|
Search: agentDSL.Uses.Search,
|
||||||
Fetch: agentDSL.Uses.Fetch,
|
Fetch: agentDSL.Uses.Fetch,
|
||||||
|
Web: agentDSL.Uses.Web,
|
||||||
|
Keyword: agentDSL.Uses.Keyword,
|
||||||
|
QueryDSL: agentDSL.Uses.QueryDSL,
|
||||||
|
Rerank: agentDSL.Uses.Rerank,
|
||||||
}
|
}
|
||||||
assistant.SetGlobalUses(globalUses)
|
assistant.SetGlobalUses(globalUses)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ func NewAgentProvider(agentID string) *AgentProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract extracts keywords by calling the target agent
|
// Extract extracts keywords by calling the target agent
|
||||||
// The agent receives the content and returns extracted keywords
|
// The agent receives the content and returns extracted keywords with weights
|
||||||
func (p *AgentProvider) Extract(ctx *agentContext.Context, content string, opts *types.KeywordOptions) ([]string, error) {
|
func (p *AgentProvider) Extract(ctx *agentContext.Context, content string, opts *types.KeywordOptions) ([]types.Keyword, error) {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return nil, fmt.Errorf("context is required for agent keyword extraction")
|
return nil, fmt.Errorf("context is required for agent keyword extraction")
|
||||||
}
|
}
|
||||||
|
|
@ -77,19 +77,20 @@ func (p *AgentProvider) Extract(ctx *agentContext.Context, content string, opts
|
||||||
// Now that agent.Stream() returns *context.Response directly,
|
// Now that agent.Stream() returns *context.Response directly,
|
||||||
// we can access fields without type assertions.
|
// we can access fields without type assertions.
|
||||||
//
|
//
|
||||||
// The agent returns keywords in response.Next field
|
// The agent returns keywords in response.Next field as {data: {keywords: [{k, w}, ...]}}
|
||||||
func (p *AgentProvider) parseResponse(response *agentContext.Response) ([]string, error) {
|
func (p *AgentProvider) parseResponse(response *agentContext.Response) ([]types.Keyword, error) {
|
||||||
if response == nil || response.Next == nil {
|
if response == nil || response.Next == nil {
|
||||||
return []string{}, nil
|
return []types.Keyword{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.parseNextData(response.Next)
|
return p.parseNextData(response.Next)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseNextData extracts keywords from Next hook data
|
// parseNextData extracts keywords from Next hook data
|
||||||
func (p *AgentProvider) parseNextData(next interface{}) ([]string, error) {
|
// Expected format: {data: {keywords: [{k: "keyword", w: 0.9}, ...]}}
|
||||||
|
func (p *AgentProvider) parseNextData(next interface{}) ([]types.Keyword, error) {
|
||||||
if next == nil {
|
if next == nil {
|
||||||
return []string{}, nil
|
return []types.Keyword{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to convert to map first (most common case)
|
// Try to convert to map first (most common case)
|
||||||
|
|
@ -101,32 +102,26 @@ func (p *AgentProvider) parseNextData(next interface{}) ([]string, error) {
|
||||||
case string:
|
case string:
|
||||||
// Try to parse as JSON
|
// Try to parse as JSON
|
||||||
if err := json.Unmarshal([]byte(v), &data); err != nil {
|
if err := json.Unmarshal([]byte(v), &data); err != nil {
|
||||||
// Not a JSON object, try as array
|
// Not a JSON object, try as array of keywords
|
||||||
var keywords []string
|
var keywords []types.Keyword
|
||||||
if err := json.Unmarshal([]byte(v), &keywords); err == nil {
|
if err := json.Unmarshal([]byte(v), &keywords); err == nil {
|
||||||
return keywords, nil
|
return keywords, nil
|
||||||
}
|
}
|
||||||
// Return as single keyword
|
// Return as single keyword with default weight
|
||||||
return []string{v}, nil
|
return []types.Keyword{{K: v, W: 0.5}}, nil
|
||||||
}
|
}
|
||||||
case []string:
|
case []types.Keyword:
|
||||||
return v, nil
|
return v, nil
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
keywords := make([]string, 0, len(v))
|
return p.extractKeywordsFromArray(v)
|
||||||
for _, item := range v {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
keywords = append(keywords, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keywords, nil
|
|
||||||
default:
|
default:
|
||||||
// Try to marshal and unmarshal
|
// Try to marshal and unmarshal
|
||||||
jsonBytes, err := json.Marshal(next)
|
jsonBytes, err := json.Marshal(next)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []string{}, nil
|
return []types.Keyword{}, nil
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(jsonBytes, &data); err != nil {
|
if err := json.Unmarshal(jsonBytes, &data); err != nil {
|
||||||
return []string{}, nil
|
return []types.Keyword{}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,28 +139,48 @@ func (p *AgentProvider) parseNextData(next interface{}) ([]string, error) {
|
||||||
return p.extractKeywordsFromValue(d)
|
return p.extractKeywordsFromValue(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
return []string{}, nil
|
return []types.Keyword{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractKeywordsFromValue extracts string array from various types
|
// extractKeywordsFromValue extracts Keyword array from various types
|
||||||
func (p *AgentProvider) extractKeywordsFromValue(v interface{}) ([]string, error) {
|
func (p *AgentProvider) extractKeywordsFromValue(v interface{}) ([]types.Keyword, error) {
|
||||||
switch kw := v.(type) {
|
switch kw := v.(type) {
|
||||||
case []string:
|
case []types.Keyword:
|
||||||
return kw, nil
|
return kw, nil
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
keywords := make([]string, 0, len(kw))
|
return p.extractKeywordsFromArray(kw)
|
||||||
for _, item := range kw {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
keywords = append(keywords, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keywords, nil
|
|
||||||
case string:
|
case string:
|
||||||
var keywords []string
|
var keywords []types.Keyword
|
||||||
if err := json.Unmarshal([]byte(kw), &keywords); err == nil {
|
if err := json.Unmarshal([]byte(kw), &keywords); err == nil {
|
||||||
return keywords, nil
|
return keywords, nil
|
||||||
}
|
}
|
||||||
return []string{kw}, nil
|
return []types.Keyword{{K: kw, W: 0.5}}, nil
|
||||||
}
|
}
|
||||||
return []string{}, nil
|
return []types.Keyword{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractKeywordsFromArray extracts keywords from []interface{}
|
||||||
|
// Handles both {k, w} objects and plain strings
|
||||||
|
func (p *AgentProvider) extractKeywordsFromArray(items []interface{}) ([]types.Keyword, error) {
|
||||||
|
keywords := make([]types.Keyword, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
switch v := item.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
// Handle {k: "keyword", w: 0.9} format
|
||||||
|
k, _ := v["k"].(string)
|
||||||
|
w, _ := v["w"].(float64)
|
||||||
|
if k != "" {
|
||||||
|
if w == 0 {
|
||||||
|
w = 0.5 // Default weight
|
||||||
|
}
|
||||||
|
keywords = append(keywords, types.Keyword{K: k, W: w})
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
// Plain string, use default weight
|
||||||
|
if v != "" {
|
||||||
|
keywords = append(keywords, types.Keyword{K: v, W: 0.5})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keywords, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ func NewExtractor(usesKeyword string, cfg *types.KeywordConfig) *Extractor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract extracts keywords from content based on configured mode
|
// Extract extracts keywords from content based on configured mode
|
||||||
// Returns a list of keywords optimized for search queries
|
// Returns a list of keywords with weights optimized for search queries
|
||||||
func (e *Extractor) Extract(ctx *context.Context, content string, opts *types.KeywordOptions) ([]string, error) {
|
func (e *Extractor) Extract(ctx *context.Context, content string, opts *types.KeywordOptions) ([]types.Keyword, error) {
|
||||||
// Merge options with config defaults
|
// Merge options with config defaults
|
||||||
mergedOpts := e.mergeOptions(opts)
|
mergedOpts := e.mergeOptions(opts)
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ func (e *Extractor) mergeOptions(opts *types.KeywordOptions) *types.KeywordOptio
|
||||||
|
|
||||||
// agentExtract delegates to an LLM-powered assistant
|
// agentExtract delegates to an LLM-powered assistant
|
||||||
// The assistant can understand context and extract semantically relevant keywords
|
// The assistant can understand context and extract semantically relevant keywords
|
||||||
func (e *Extractor) agentExtract(ctx *context.Context, content string, agentID string, opts *types.KeywordOptions) ([]string, error) {
|
func (e *Extractor) agentExtract(ctx *context.Context, content string, agentID string, opts *types.KeywordOptions) ([]types.Keyword, error) {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return nil, fmt.Errorf("context is required for keyword extraction")
|
return nil, fmt.Errorf("context is required for keyword extraction")
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +93,7 @@ func (e *Extractor) agentExtract(ctx *context.Context, content string, agentID s
|
||||||
|
|
||||||
// mcpExtract calls an external MCP tool
|
// mcpExtract calls an external MCP tool
|
||||||
// Format: "mcp:<server>.<tool>"
|
// Format: "mcp:<server>.<tool>"
|
||||||
func (e *Extractor) mcpExtract(ctx *context.Context, content string, opts *types.KeywordOptions) ([]string, error) {
|
func (e *Extractor) mcpExtract(ctx *context.Context, content string, opts *types.KeywordOptions) ([]types.Keyword, error) {
|
||||||
mcpRef := strings.TrimPrefix(e.usesKeyword, "mcp:")
|
mcpRef := strings.TrimPrefix(e.usesKeyword, "mcp:")
|
||||||
provider, err := NewMCPProvider(mcpRef)
|
provider, err := NewMCPProvider(mcpRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func NewMCPProvider(mcpRef string) (*MCPProvider, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract extracts keywords by calling the MCP tool
|
// Extract extracts keywords by calling the MCP tool
|
||||||
func (p *MCPProvider) Extract(ctx *agentContext.Context, content string, opts *types.KeywordOptions) ([]string, error) {
|
func (p *MCPProvider) Extract(ctx *agentContext.Context, content string, opts *types.KeywordOptions) ([]types.Keyword, error) {
|
||||||
// Get MCP client
|
// Get MCP client
|
||||||
client, err := mcp.Select(p.serverID)
|
client, err := mcp.Select(p.serverID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -56,9 +56,9 @@ func (p *MCPProvider) Extract(ctx *agentContext.Context, content string, opts *t
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseResult extracts keywords from the MCP tool response
|
// parseResult extracts keywords from the MCP tool response
|
||||||
func (p *MCPProvider) parseResult(result *gouMCPTypes.CallToolResponse) ([]string, error) {
|
func (p *MCPProvider) parseResult(result *gouMCPTypes.CallToolResponse) ([]types.Keyword, error) {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return []string{}, nil
|
return []types.Keyword{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for errors in result
|
// Check for errors in result
|
||||||
|
|
@ -72,7 +72,7 @@ func (p *MCPProvider) parseResult(result *gouMCPTypes.CallToolResponse) ([]strin
|
||||||
|
|
||||||
// Parse content - expect JSON data with "keywords" field
|
// Parse content - expect JSON data with "keywords" field
|
||||||
if len(result.Content) == 0 {
|
if len(result.Content) == 0 {
|
||||||
return []string{}, nil
|
return []types.Keyword{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract keywords from content
|
// Try to extract keywords from content
|
||||||
|
|
@ -88,36 +88,50 @@ func (p *MCPProvider) parseResult(result *gouMCPTypes.CallToolResponse) ([]strin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as direct array
|
// Try to parse as direct array of keywords
|
||||||
var keywords []string
|
var keywords []types.Keyword
|
||||||
if err := json.Unmarshal([]byte(content.Text), &keywords); err == nil {
|
if err := json.Unmarshal([]byte(content.Text), &keywords); err == nil {
|
||||||
return keywords, nil
|
return keywords, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return []string{}, nil
|
return []types.Keyword{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractKeywordsFromValue extracts string array from various types
|
// extractKeywordsFromValue extracts Keyword array from various types
|
||||||
func (p *MCPProvider) extractKeywordsFromValue(v interface{}) ([]string, error) {
|
func (p *MCPProvider) extractKeywordsFromValue(v interface{}) ([]types.Keyword, error) {
|
||||||
switch kw := v.(type) {
|
switch kw := v.(type) {
|
||||||
case []string:
|
case []types.Keyword:
|
||||||
return kw, nil
|
return kw, nil
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
keywords := make([]string, 0, len(kw))
|
keywords := make([]types.Keyword, 0, len(kw))
|
||||||
for _, item := range kw {
|
for _, item := range kw {
|
||||||
if s, ok := item.(string); ok {
|
switch v := item.(type) {
|
||||||
keywords = append(keywords, s)
|
case map[string]interface{}:
|
||||||
|
// Handle {k: "keyword", w: 0.9} format
|
||||||
|
k, _ := v["k"].(string)
|
||||||
|
w, _ := v["w"].(float64)
|
||||||
|
if k != "" {
|
||||||
|
if w == 0 {
|
||||||
|
w = 0.5 // Default weight
|
||||||
|
}
|
||||||
|
keywords = append(keywords, types.Keyword{K: k, W: w})
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
// Plain string, use default weight
|
||||||
|
if v != "" {
|
||||||
|
keywords = append(keywords, types.Keyword{K: v, W: 0.5})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return keywords, nil
|
return keywords, nil
|
||||||
case string:
|
case string:
|
||||||
var keywords []string
|
var keywords []types.Keyword
|
||||||
if err := json.Unmarshal([]byte(kw), &keywords); err == nil {
|
if err := json.Unmarshal([]byte(kw), &keywords); err == nil {
|
||||||
return keywords, nil
|
return keywords, nil
|
||||||
}
|
}
|
||||||
return []string{kw}, nil
|
return []types.Keyword{{K: kw, W: 0.5}}, nil
|
||||||
}
|
}
|
||||||
return []string{}, nil
|
return []types.Keyword{}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,12 @@ type ProcessedQuery struct {
|
||||||
DSL *gou.QueryDSL `json:"dsl,omitempty"` // For DB search, uses GOU QueryDSL
|
DSL *gou.QueryDSL `json:"dsl,omitempty"` // For DB search, uses GOU QueryDSL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyword represents an extracted keyword with weight
|
||||||
|
type Keyword struct {
|
||||||
|
K string `json:"k"` // Keyword text
|
||||||
|
W float64 `json:"w"` // Weight (0.1-1.0), higher means more relevant
|
||||||
|
}
|
||||||
|
|
||||||
// Note: For QueryDSL and Model types, use GOU types directly:
|
// Note: For QueryDSL and Model types, use GOU types directly:
|
||||||
// - github.com/yaoapp/gou/query/gou.QueryDSL
|
// - github.com/yaoapp/gou/query/gou.QueryDSL
|
||||||
// - github.com/yaoapp/gou/model.Model
|
// - github.com/yaoapp/gou/model.Model
|
||||||
|
|
|
||||||
|
|
@ -254,18 +254,9 @@ func (a *Asserter) assertJSONPath(assertion *Assertion, output interface{}) *Ass
|
||||||
actual := a.extractPath(jsonData, path)
|
actual := a.extractPath(jsonData, path)
|
||||||
result.Actual = actual
|
result.Actual = actual
|
||||||
|
|
||||||
// Support array of expected values (OR logic - any match passes)
|
// Compare expected value with actual value
|
||||||
if expectedArr, ok := assertion.Value.([]interface{}); ok && len(expectedArr) > 0 {
|
// First, try direct comparison (handles both primitive values and arrays)
|
||||||
for _, expected := range expectedArr {
|
if validateOutput(actual, assertion.Value) {
|
||||||
if validateOutput(actual, expected) {
|
|
||||||
result.Passed = true
|
|
||||||
result.Message = fmt.Sprintf("path '%s' equals one of expected values", assertion.Path)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.Passed = false
|
|
||||||
result.Message = fmt.Sprintf("path '%s': expected one of %v, got %v", assertion.Path, assertion.Value, actual)
|
|
||||||
} else if validateOutput(actual, assertion.Value) {
|
|
||||||
result.Passed = true
|
result.Passed = true
|
||||||
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
|
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
330
data/bindata.go
330
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -1,21 +1,25 @@
|
||||||
- role: system
|
- role: system
|
||||||
content: |
|
content: |
|
||||||
Extract keywords from text content.
|
You are a keyword extraction tool, NOT a chatbot. Do NOT answer questions or provide explanations.
|
||||||
|
Your ONLY job: analyze text and output a JSON array of keywords with weights.
|
||||||
|
|
||||||
Task:
|
Output format: ["keyword:weight", ...]
|
||||||
1. Analyze input text
|
|
||||||
2. Extract important keywords
|
|
||||||
3. Return JSON format
|
|
||||||
4. Match input language
|
|
||||||
|
|
||||||
Response Format (JSON only):
|
Weight:
|
||||||
```json
|
- 1.0: Core topic
|
||||||
{"keywords": ["keyword1", "keyword2", ...]}
|
- 0.8-0.9: Key concepts
|
||||||
```
|
- 0.6-0.7: Supporting themes
|
||||||
|
- 0.4-0.5: Peripheral concepts
|
||||||
|
|
||||||
Guidelines:
|
Examples:
|
||||||
- Extract 5-15 keywords based on content length
|
- Input(EN): "Developers frustrated with callback hell. ES2017 async/await improved readability."
|
||||||
- Prioritize nouns, proper nouns, key concepts
|
- Output: ["async/await:1", "asynchronous programming:0.9", "ES2017:0.8", "code readability:0.7"]
|
||||||
- Include single words and short phrases
|
|
||||||
- Exclude common stop words
|
- Input(中文): "用户反馈APP启动慢、页面卡顿。需要优化首屏加载和内存占用。"
|
||||||
- Keywords MUST be in the same language as input
|
- Output: ["性能优化:1", "启动速度:0.9", "内存管理:0.8", "用户体验:0.7"]
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- ONLY output JSON array, nothing else
|
||||||
|
- Max 5 keywords, sorted by weight
|
||||||
|
- Summarize related concepts
|
||||||
|
- Match input language (EN→EN, 中文→中文)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* Keyword Extraction Agent - Next Hook
|
* Keyword Extraction Agent - Next Hook
|
||||||
* Parses LLM response and extracts keywords with error tolerance
|
* Parses LLM response and extracts keywords with weight
|
||||||
|
* Format: ["keyword:weight", ...] -> [{k, w}, ...]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
|
/** Keyword with weight */
|
||||||
|
interface Keyword {
|
||||||
|
k: string; // keyword
|
||||||
|
w: number; // weight (0.1-1.0)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next hook - processes keyword extraction response
|
* Next hook - processes keyword extraction response
|
||||||
* Uses text.ExtractJSON for fault-tolerant JSON extraction from LLM output
|
* Parses format: ["keyword1:0.9", "keyword2:0.8", ...]
|
||||||
*/
|
*/
|
||||||
function Next(
|
function Next(
|
||||||
ctx: agent.Context,
|
ctx: agent.Context,
|
||||||
|
|
@ -21,22 +28,17 @@ function Next(
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = completion.content;
|
const content = completion.content;
|
||||||
let keywords: string[] = [];
|
let keywords: Keyword[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use text.ExtractJSON for fault-tolerant extraction
|
// Extract JSON array from response
|
||||||
// Handles markdown code blocks, broken JSON, etc.
|
const parsed = Process("text.ExtractJSON", content) as string[] | null;
|
||||||
const parsed = Process("text.ExtractJSON", content) as {
|
|
||||||
keywords?: string[];
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
if (parsed && Array.isArray(parsed.keywords)) {
|
if (parsed && Array.isArray(parsed)) {
|
||||||
keywords = parsed.keywords.filter(
|
keywords = parseKeywordArray(parsed);
|
||||||
(k) => typeof k === "string" && k.trim().length > 0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If extraction fails, try to extract keywords from text
|
// If extraction fails, try to extract from text
|
||||||
keywords = extractKeywordsFromText(content);
|
keywords = extractKeywordsFromText(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,6 +47,9 @@ function Next(
|
||||||
keywords = extractKeywordsFromText(content);
|
keywords = extractKeywordsFromText(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by weight descending and limit to 5
|
||||||
|
keywords = keywords.sort((a, b) => b.w - a.w).slice(0, 5);
|
||||||
|
|
||||||
// Return parsed keywords
|
// Return parsed keywords
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -54,49 +59,99 @@ function Next(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract keywords from plain text when JSON parsing fails
|
* Parse keyword array format: ["keyword:weight", ...]
|
||||||
* Handles formats like:
|
* Examples: ["AI:0.9", "机器学习:0.8", "deep learning:0.7"]
|
||||||
* - Comma-separated: "keyword1, keyword2, keyword3"
|
|
||||||
* - Line-separated: "keyword1\nkeyword2\nkeyword3"
|
|
||||||
* - Bullet points: "- keyword1\n- keyword2"
|
|
||||||
* - Numbered: "1. keyword1\n2. keyword2"
|
|
||||||
*/
|
*/
|
||||||
function extractKeywordsFromText(text: string): string[] {
|
function parseKeywordArray(items: (string | any)[]): Keyword[] {
|
||||||
const keywords: string[] = [];
|
const keywords: Keyword[] = [];
|
||||||
|
|
||||||
// Remove common prefixes/suffixes
|
for (const item of items) {
|
||||||
let cleaned = text
|
if (typeof item === "string") {
|
||||||
.replace(/^[\s\S]*?keywords?[\s::]*\[?/i, "") // Remove "keywords:" prefix
|
const parsed = parseKeywordString(item);
|
||||||
.replace(/\][\s\S]*$/, "") // Remove trailing ]
|
if (parsed) {
|
||||||
.trim();
|
keywords.push(parsed);
|
||||||
|
}
|
||||||
|
} else if (item && typeof item === "object" && item.k) {
|
||||||
|
// Fallback: handle {k, w} format
|
||||||
|
const k = String(item.k).trim();
|
||||||
|
const w =
|
||||||
|
typeof item.w === "number" ? Math.min(1.0, Math.max(0.1, item.w)) : 0.5;
|
||||||
|
if (k.length > 0) {
|
||||||
|
keywords.push({ k, w });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try line-by-line extraction
|
return keywords;
|
||||||
const lines = cleaned.split(/[\n\r]+/);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse single keyword string: "keyword:weight" or "keyword"
|
||||||
|
*/
|
||||||
|
function parseKeywordString(str: string): Keyword | null {
|
||||||
|
const trimmed = str.trim().replace(/^["']+|["']+$/g, ""); // Remove quotes
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
// Try to split by last colon (keyword may contain colons)
|
||||||
|
const lastColonIdx = trimmed.lastIndexOf(":");
|
||||||
|
if (lastColonIdx > 0) {
|
||||||
|
const keyword = trimmed.substring(0, lastColonIdx).trim();
|
||||||
|
const weightStr = trimmed.substring(lastColonIdx + 1).trim();
|
||||||
|
const weight = parseFloat(weightStr);
|
||||||
|
|
||||||
|
if (keyword && !isNaN(weight)) {
|
||||||
|
return {
|
||||||
|
k: keyword,
|
||||||
|
w: Math.min(1.0, Math.max(0.1, weight)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No weight found, return with default weight
|
||||||
|
return { k: trimmed, w: 0.5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract keywords from plain text when JSON parsing fails
|
||||||
|
*/
|
||||||
|
function extractKeywordsFromText(text: string): Keyword[] {
|
||||||
|
const keywords: Keyword[] = [];
|
||||||
|
|
||||||
|
// Try to find array-like content
|
||||||
|
const arrayMatch = text.match(/\[([^\]]+)\]/);
|
||||||
|
if (arrayMatch) {
|
||||||
|
const items = arrayMatch[1].split(",");
|
||||||
|
for (const item of items) {
|
||||||
|
const parsed = parseKeywordString(item);
|
||||||
|
if (parsed) {
|
||||||
|
keywords.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keywords.length > 0) return keywords;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: line-by-line extraction
|
||||||
|
const lines = text.split(/[\n\r,]+/);
|
||||||
|
let defaultWeight = 1.0;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
// Remove bullet points, numbers, quotes
|
let cleaned = line
|
||||||
let keyword = line
|
.replace(/^[\s\-\*\•\d\.\[\]"'`]+/, "") // Remove prefixes
|
||||||
.replace(/^[\s\-\*\•\d\.]+/, "") // Remove bullets/numbers
|
.replace(/[\]"'`]+$/, "") // Remove suffixes
|
||||||
.replace(/^["'`]+|["'`]+$/g, "") // Remove quotes
|
|
||||||
.replace(/,\s*$/, "") // Remove trailing comma
|
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Skip empty or too long
|
if (cleaned.length > 0 && cleaned.length < 100) {
|
||||||
if (keyword.length > 0 && keyword.length < 100) {
|
const parsed = parseKeywordString(cleaned);
|
||||||
// Split by comma if contains multiple
|
if (parsed) {
|
||||||
if (keyword.includes(",")) {
|
// Use parsed weight or assign decreasing default
|
||||||
const parts = keyword.split(",").map((p) => p.trim());
|
if (parsed.w === 0.5) {
|
||||||
for (const part of parts) {
|
parsed.w = Math.max(0.1, defaultWeight);
|
||||||
if (part.length > 0 && part.length < 100) {
|
defaultWeight -= 0.1;
|
||||||
keywords.push(part);
|
|
||||||
}
|
}
|
||||||
}
|
keywords.push(parsed);
|
||||||
} else {
|
|
||||||
keywords.push(keyword);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate
|
return keywords;
|
||||||
return [...new Set(keywords)];
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,41 @@
|
||||||
- DO NOT extract keywords, DO NOT answer the question, DO NOT add explanations
|
- DO NOT extract keywords, DO NOT answer the question, DO NOT add explanations
|
||||||
|
|
||||||
## Classification Rules
|
## Classification Rules
|
||||||
need_search=false: greetings, chitchat, math, code requests, text processing, general knowledge, philosophy
|
|
||||||
need_search=true with search_types=["web"]: weather, news, prices, exchange rates, live events, real-time info
|
### need_search=false (No search needed)
|
||||||
need_search=true with search_types=["kb"]: docs, how-to, config, FAQ, product info, policies
|
Use when the question can be answered from LLM's internal knowledge:
|
||||||
need_search=true with search_types=["db"]: user data (my orders, my balance), account info, business records
|
- Greetings & chitchat: "hello", "how are you", casual conversation
|
||||||
|
- Math & calculations: arithmetic, equations, formulas
|
||||||
|
- Code generation: write code, debug, explain code, algorithms
|
||||||
|
- Text processing: translate, summarize, rewrite, format
|
||||||
|
- General knowledge: history, science, concepts (not time-sensitive)
|
||||||
|
- Creative tasks: write stories, poems, brainstorm ideas
|
||||||
|
- Reasoning & logic: philosophy, opinions, hypothetical questions
|
||||||
|
|
||||||
|
### need_search=true with search_types=["web"] (Web search)
|
||||||
|
Use when real-time or frequently changing information is needed:
|
||||||
|
- Current events: news, breaking stories, recent happenings
|
||||||
|
- Time-sensitive data: weather, stock prices, exchange rates, sports scores
|
||||||
|
- Live information: event schedules, store hours, availability
|
||||||
|
- Recent updates: latest versions, new releases, current status
|
||||||
|
- Location-based: nearby places, local info, addresses
|
||||||
|
|
||||||
|
### need_search=true with search_types=["kb"] (Knowledge base)
|
||||||
|
Use when querying internal documentation or product knowledge:
|
||||||
|
- Documentation: how-to guides, tutorials, setup instructions
|
||||||
|
- Configuration: settings, parameters, options explained
|
||||||
|
- Product info: features, specifications, capabilities
|
||||||
|
- Policies: terms, rules, guidelines, compliance
|
||||||
|
- FAQ: common questions about the system/product
|
||||||
|
- Troubleshooting: error messages, known issues, solutions
|
||||||
|
|
||||||
|
### need_search=true with search_types=["db"] (Database)
|
||||||
|
Use when querying user-specific or transactional data:
|
||||||
|
- Personal data: "my orders", "my profile", "my history"
|
||||||
|
- Account info: balance, subscription, membership status
|
||||||
|
- Business records: invoices, transactions, payments
|
||||||
|
- User preferences: settings, saved items, favorites
|
||||||
|
- Keywords: "my", "mine", specific order/ID numbers
|
||||||
|
|
||||||
## Required Output Format (JSON only, no markdown)
|
## Required Output Format (JSON only, no markdown)
|
||||||
{"need_search": true/false, "search_types": [], "confidence": 0.0-1.0}
|
{"need_search": true/false, "search_types": [], "confidence": 0.0-1.0}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue