Files

1303 lines
47 KiB
Go
Raw Permalink Normal View History

package service
import (
"context"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/agent/tools"
chatpipeline "github.com/Tencent/WeKnora/internal/application/service/chat_pipeline"
"github.com/Tencent/WeKnora/internal/common"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/modelcontext"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
"github.com/Tencent/WeKnora/internal/types"
)
// KnowledgeQA performs knowledge base question answering with LLM summarization
// Events are emitted through eventBus (references, answer chunks, completion)
// customAgent is optional - if provided, uses custom agent configuration for multiTurnEnabled and historyTurns
func (s *sessionService) KnowledgeQA(
ctx context.Context,
req *types.QARequest,
eventBus *event.EventBus,
) error {
ctx, err := s.prepareQAAgentExecution(ctx, req)
if err != nil {
return err
}
logger.Infof(
ctx,
"Knowledge base question answering parameters, session ID: %s, query: %s, webSearchEnabled: %v",
req.Session.ID,
req.Query,
req.WebSearchEnabled,
)
// Span the request setup (KB / model resolution, search target building,
// agent override application). This covers the visible gap between trace
// start and the first stage observation in the Langfuse timeline.
setupCtx, setupSpan := langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
Name: "qa.setup",
Metadata: map[string]interface{}{
"session_id": req.Session.ID,
},
})
ctx = setupCtx
// Resolve knowledge bases using shared helper
knowledgeBaseIDs, knowledgeIDs, err := s.resolveKnowledgeBases(ctx, req)
if err != nil {
return err
}
// Resolve chat model ID using shared helper
chatModelID, err := s.resolveChatModelID(ctx, req, knowledgeBaseIDs, knowledgeIDs)
if err != nil {
return err
}
// Initialize ChatManage defaults from config.yaml
summaryConfig := types.SummaryConfig{
Prompt: s.cfg.Conversation.Summary.Prompt,
ContextTemplate: s.cfg.Conversation.Summary.ContextTemplate,
Temperature: s.cfg.Conversation.Summary.Temperature,
NoMatchPrefix: s.cfg.Conversation.Summary.NoMatchPrefix,
MaxCompletionTokens: s.cfg.Conversation.Summary.MaxCompletionTokens,
Thinking: s.cfg.Conversation.Summary.Thinking,
}
fallbackStrategy := types.FallbackStrategy(s.cfg.Conversation.FallbackStrategy)
if fallbackStrategy == "" {
fallbackStrategy = types.FallbackStrategyFixed
logger.Infof(ctx, "Fallback strategy not set, using default: %v", fallbackStrategy)
}
// Resolve chat model vision capability and VLM model ID for image routing
var chatModelSupportsVision bool
var vlmModelID string
if chatModelID != "" {
if chatModelInfo, err := s.modelService.GetModelByID(ctx, chatModelID); err == nil && chatModelInfo != nil {
chatModelSupportsVision = chatModelInfo.Parameters.SupportsVision
}
}
if req.CustomAgent != nil {
vlmModelID = req.CustomAgent.Config.VLMModelID
}
// Resolve retrieval tenant scope using shared helper
retrievalTenantID := s.resolveRetrievalTenantID(ctx, req)
// Build unified search targets (computed once, used throughout pipeline)
searchTargets, err := s.buildSearchTargets(ctx, retrievalTenantID, knowledgeBaseIDs, knowledgeIDs, req.TagScopes)
if err != nil {
return fmt.Errorf("build search targets: %w", err)
}
// Create chat management object with session settings
logger.Infof(
ctx,
"Creating chat manage object, knowledge base IDs: %v, knowledge IDs: %v, chat model ID: %s, search targets: %d",
knowledgeBaseIDs,
knowledgeIDs,
chatModelID,
len(searchTargets),
)
chatManage := &types.ChatManage{
PipelineRequest: types.PipelineRequest{
Query: req.Query,
SessionID: req.Session.ID,
UserID: types.SessionOwnerIDFromContext(ctx),
MaxRounds: s.cfg.Conversation.MaxRounds,
KnowledgeBaseIDs: knowledgeBaseIDs,
KnowledgeIDs: knowledgeIDs,
SearchTargets: searchTargets,
VectorThreshold: s.cfg.Conversation.VectorThreshold,
KeywordThreshold: s.cfg.Conversation.KeywordThreshold,
EmbeddingTopK: s.cfg.Conversation.EmbeddingTopK,
RerankTopK: s.cfg.Conversation.RerankTopK,
RerankThreshold: s.cfg.Conversation.RerankThreshold,
ChatModelID: chatModelID,
SummaryConfig: summaryConfig,
FallbackStrategy: fallbackStrategy,
FallbackResponse: s.cfg.Conversation.FallbackResponse,
FallbackPrompt: s.cfg.Conversation.FallbackPrompt,
EnableRewrite: s.cfg.Conversation.EnableRewrite,
EnableQueryExpansion: s.cfg.Conversation.EnableQueryExpansion,
RewritePromptSystem: s.cfg.Conversation.RewritePromptSystem,
RewritePromptUser: s.cfg.Conversation.RewritePromptUser,
WebSearchEnabled: req.WebSearchEnabled,
WebSearchProviderID: s.resolveWebSearchProviderID(ctx, req, retrievalTenantID),
WebSearchMaxResults: s.resolveWebSearchMaxResults(ctx, req),
WebFetchEnabled: s.resolveWebFetchEnabled(req),
WebFetchTopN: s.resolveWebFetchTopN(req),
TenantID: retrievalTenantID,
Images: req.ImageURLs,
VLMModelID: vlmModelID,
ChatModelSupportsVision: chatModelSupportsVision,
Attachments: req.Attachments,
Language: types.LanguageNameFromContext(ctx),
},
PipelineState: types.PipelineState{
RewriteQuery: req.Query,
ImageDescription: req.ImageDescription,
QuotedContext: req.QuotedContext,
},
PipelineContext: types.PipelineContext{
EventBus: eventBus.AsEventBusInterface(),
MessageID: req.AssistantMessageID,
UserMessageID: req.UserMessageID,
},
}
// Apply custom agent overrides (system prompt, temperature, retrieval params,
// rewrite, fallback, FAQ strategy, history turns)
s.applyAgentOverridesToChatManage(ctx, req.CustomAgent, chatManage)
instructionPrompt, err := ResolveAgentInstructionPrompt(ctx, s.tenantSkillRepo, req.CustomAgent)
if err != nil {
return err
}
chatManage.SummaryConfig.Prompt += instructionPrompt
// An agent may opt out of long-term memory. The preference is per-request
// rather than per-user, so it travels in the context that the recall
// plugin reads.
if req.CustomAgent != nil {
ctx = types.ApplyAgentMemoryPreference(ctx, req.CustomAgent.Config.MemoryEnabled)
}
// Determine pipeline based on the effective knowledge retrieval scope and
// web search setting. Tag-only mentions leave the raw KB/knowledge ID slices
// empty but produce SearchTargets, so the unified targets must participate in
// this decision or the request is incorrectly downgraded to pure chat.
hasKB := types.HasKnowledgeRetrievalScope(searchTargets, knowledgeBaseIDs, knowledgeIDs)
needsRAG := hasKB || req.WebSearchEnabled
hasHistory := chatManage.MaxRounds > 0
var pipeline []types.EventType
if !needsRAG {
// Pure chat — no retrieval needed.
userContent := req.Query
if req.ImageDescription != "" && !chatModelSupportsVision {
userContent += "\n\n[用户上传图片内容]\n" + req.ImageDescription
}
if req.QuotedContext != "" {
userContent += "\n\n" + req.QuotedContext
}
// Inject attachment content for pure-chat path (RAG path handles this in INTO_CHAT_MESSAGE).
if len(req.Attachments) > 0 {
userContent += req.Attachments.BuildPrompt()
}
chatManage.UserContent = userContent
pipeline = types.NewPipelineBuilder().
AddIf(hasHistory, types.LOAD_HISTORY).
Add(types.MEMORY_RECALL).
Add(types.CHAT_COMPLETION_STREAM).
Build()
} else {
// RAG — dynamically assemble based on feature flags.
pipeline = types.NewPipelineBuilder().
AddIf(hasHistory, types.LOAD_HISTORY).
Add(types.MEMORY_RECALL).
Add(types.QUERY_UNDERSTAND).
Add(types.CHUNK_SEARCH_PARALLEL).
Add(types.CHUNK_RERANK).
AddIf(req.WebSearchEnabled, types.WEB_FETCH).
Add(types.CHUNK_MERGE).
Add(types.FILTER_TOP_K).
AddIf(chatManage.DataAnalysisEnabled, types.DATA_ANALYSIS).
Add(types.INTO_CHAT_MESSAGE).
Add(types.CHAT_COMPLETION_STREAM).
Build()
}
logger.Infof(ctx, "Assembled pipeline (%d stages), hasKB=%v, webSearch=%v, history=%v",
len(pipeline), hasKB, req.WebSearchEnabled, hasHistory)
// Start knowledge QA event processing (set session tenant so pipeline session/message lookups use session owner)
ctx = context.WithValue(ctx, types.SessionTenantIDContextKey, req.Session.TenantID)
// Propagate the session ID so stateful sandbox backends (CubeSandbox) can
// bind script execution to a per-session MicroVM instance.
ctx = types.WithSessionID(ctx, req.Session.ID)
logger.Info(ctx, "Triggering question answering event")
setupSpan.Finish(map[string]interface{}{
"stages": len(pipeline),
"knowledge_base_ids": knowledgeBaseIDs,
"search_targets": len(searchTargets),
}, nil, nil)
err = s.KnowledgeQAByEvent(ctx, chatManage, pipeline)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"session_id": req.Session.ID,
})
return err
}
// Note: Answer events are now emitted directly by chat_completion_stream plugin
// Completion event will be emitted when the last answer event has Done=true
// We can optionally add a completion watcher here if needed, but for now
// the frontend can detect completion from the Done flag
logger.Info(ctx, "Knowledge base question answering initiated")
return nil
}
// selectChatModelID selects the appropriate chat model ID with priority for Remote models
// Priority order:
// 1. Session's SummaryModelID if it's a Remote model
// 2. First knowledge base with a Remote model (from knowledgeBaseIDs or derived from knowledgeIDs)
// 3. Session's SummaryModelID (if not Remote)
// 4. First knowledge base's SummaryModelID
func (s *sessionService) selectChatModelID(
ctx context.Context,
session *types.Session,
knowledgeBaseIDs []string,
knowledgeIDs []string,
) (string, error) {
// If no knowledge base IDs but have knowledge IDs, derive KB IDs from knowledge IDs (include shared KB files)
if len(knowledgeBaseIDs) == 0 && len(knowledgeIDs) > 0 {
tenantID := types.MustTenantIDFromContext(ctx)
knowledgeList, err := s.knowledgeService.GetKnowledgeBatchWithSharedAccess(ctx, tenantID, knowledgeIDs)
if err != nil {
logger.Warnf(ctx, "Failed to get knowledge batch for model selection: %v", err)
} else {
// Collect unique KB IDs from knowledge items
kbIDSet := make(map[string]bool)
for _, k := range knowledgeList {
if k != nil && k.KnowledgeBaseID != "" {
kbIDSet[k.KnowledgeBaseID] = true
}
}
for kbID := range kbIDSet {
knowledgeBaseIDs = append(knowledgeBaseIDs, kbID)
}
logger.Infof(ctx, "Derived %d knowledge base IDs from %d knowledge IDs for model selection",
len(knowledgeBaseIDs), len(knowledgeIDs))
}
}
// Check knowledge bases for models
if len(knowledgeBaseIDs) > 0 {
// Try to find a knowledge base with Remote model
for _, kbID := range knowledgeBaseIDs {
kb, err := s.knowledgeBaseService.GetKnowledgeBaseByID(ctx, kbID)
if err != nil {
logger.Warnf(ctx, "Failed to get knowledge base: %v", err)
continue
}
if kb != nil && kb.SummaryModelID != "" {
model, err := s.modelService.GetModelByID(ctx, kb.SummaryModelID)
if err == nil && model != nil && model.Source == types.ModelSourceRemote {
logger.Info(ctx, "Using Remote summary model from knowledge base")
return kb.SummaryModelID, nil
}
}
}
// If no Remote model found, use first knowledge base's model
kb, err := s.knowledgeBaseService.GetKnowledgeBaseByID(ctx, knowledgeBaseIDs[0])
if err != nil {
logger.Errorf(ctx, "Failed to get knowledge base for model ID: %v", err)
return "", fmt.Errorf("failed to get knowledge base %s: %w", knowledgeBaseIDs[0], err)
}
if kb != nil && kb.SummaryModelID != "" {
logger.Infof(
ctx,
"Using summary model from first knowledge base %s: %s",
knowledgeBaseIDs[0],
kb.SummaryModelID,
)
return kb.SummaryModelID, nil
}
}
// No knowledge bases - try to find any available chat model
models, err := s.modelService.ListModels(ctx)
if err != nil {
logger.Errorf(ctx, "Failed to list models: %v", err)
return "", fmt.Errorf("failed to list models: %w", err)
}
for _, model := range models {
if model != nil && model.Type == types.ModelTypeKnowledgeQA {
logger.Infof(ctx, "Using first available KnowledgeQA model: %s", model.ID)
return model.ID, nil
}
}
logger.Error(ctx, "No chat model ID available")
return "", fmt.Errorf("no chat model ID available: no knowledge bases configured and no available models")
}
// resolveKnowledgeBasesFromAgent resolves knowledge base IDs based on agent's KBSelectionMode.
// sessionTenantID is the tenant of the current session (caller); it is compared with
// customAgent.TenantID to detect the shared-agent scenario and avoid leaking the
// current user's personal shared KBs into the agent's retrieval scope.
//
// Returns the resolved knowledge base IDs based on the selection mode:
// - "all": fetches all knowledge bases for the tenant
// - "selected": uses the explicitly configured knowledge bases
// - "none": returns empty slice
// - default: falls back to configured knowledge bases for backward compatibility
func (s *sessionService) resolveKnowledgeBasesFromAgent(
ctx context.Context,
customAgent *types.CustomAgent,
sessionTenantID uint64,
) []string {
if customAgent == nil {
return nil
}
switch customAgent.Config.KBSelectionMode {
case "all":
// Authoritative capability filter for the runtime path. The frontend
// editor and @mention dropdown apply the same filter, but we don't
// trust the client here: a stale session payload or API caller could
// still ask us to retrieve against an incompatible KB and we'd rather
// just drop it (and log) than feed it to tools that would no-op.
capFilter := tools.DeriveKBFilterForAgent(customAgent.Config.AgentMode, customAgent.Config.AllowedTools)
accept := func(kb *types.KnowledgeBase) bool {
if kb == nil {
return false
}
if capFilter.IsEmpty() {
return true
}
return tools.KBSatisfiesAgentRequirements(kb.Capabilities(), customAgent.Config.AgentMode, customAgent.Config.AllowedTools)
}
// Get own knowledge bases (uses ctx TenantID = agent's tenant)
allKBs, err := s.knowledgeBaseService.ListKnowledgeBases(ctx)
if err != nil {
logger.Warnf(ctx, "Failed to list all knowledge bases: %v", err)
}
kbIDSet := make(map[string]bool)
kbIDs := make([]string, 0, len(allKBs))
ownSkipped := 0
for _, kb := range allKBs {
if !accept(kb) {
ownSkipped++
continue
}
kbIDs = append(kbIDs, kb.ID)
kbIDSet[kb.ID] = true
}
// For shared agents (session tenant != agent tenant), only use the agent
// tenant's own KBs. Including the current user's shared KBs would leak
// unrelated KBs from other organisations into the agent's retrieval scope.
isSharedAgent := sessionTenantID != 0 && sessionTenantID != customAgent.TenantID
sharedSkipped := 0
if !isSharedAgent {
tenantID := types.MustTenantIDFromContext(ctx)
userIDVal := ctx.Value(types.UserIDContextKey)
if userIDVal != nil {
if userID, ok := userIDVal.(string); ok && userID != "" && s.kbShareService != nil {
callerTenantRole := types.TenantRoleFromContext(ctx)
sharedList, err := s.kbShareService.ListSharedKnowledgeBases(ctx, tenantID, callerTenantRole)
if err != nil {
logger.Warnf(ctx, "Failed to list shared knowledge bases: %v", err)
} else {
for _, info := range sharedList {
if info == nil || info.KnowledgeBase == nil || kbIDSet[info.KnowledgeBase.ID] {
continue
}
if !accept(info.KnowledgeBase) {
sharedSkipped++
continue
}
kbIDs = append(kbIDs, info.KnowledgeBase.ID)
kbIDSet[info.KnowledgeBase.ID] = true
}
}
}
}
} else {
logger.Infof(ctx, "Shared agent detected (session tenant %d != agent tenant %d): skipping user's shared KBs",
sessionTenantID, customAgent.TenantID)
}
if ownSkipped+sharedSkipped > 0 {
logger.Infof(ctx,
"KBSelectionMode=all: tool-capability filter removed %d own + %d shared KBs (agent=%s, tools=%v)",
ownSkipped, sharedSkipped, customAgent.ID, customAgent.Config.AllowedTools)
}
logger.Infof(ctx, "KBSelectionMode=all: loaded %d knowledge bases (own + shared)", len(kbIDs))
return kbIDs
case "selected":
logger.Infof(ctx, "KBSelectionMode=selected: using %d configured knowledge bases", len(customAgent.Config.KnowledgeBases))
return customAgent.Config.KnowledgeBases
case "none":
logger.Infof(ctx, "KBSelectionMode=none: no knowledge bases configured")
return nil
default:
// Default to "selected" behavior for backward compatibility
if len(customAgent.Config.KnowledgeBases) > 0 {
logger.Infof(ctx, "KBSelectionMode not set: using %d configured knowledge bases", len(customAgent.Config.KnowledgeBases))
}
return customAgent.Config.KnowledgeBases
}
}
// buildSearchTargets computes the unified search targets from knowledgeBaseIDs and knowledgeIDs.
// tenantID is the retrieval scope: session.TenantID or effective tenant from shared agent (set by handler).
// This is called once at the request entry point to avoid repeated queries later in the pipeline.
// Logic:
// - For each knowledgeBaseID: resolve actual TenantID (own, org-shared, or in retrieval-tenant scope for shared agent)
// - For each knowledgeID: find its knowledgeBaseID; if the KB is already in the list, skip; otherwise add SearchTargetTypeKnowledge
func (s *sessionService) buildSearchTargets(
ctx context.Context,
tenantID uint64,
knowledgeBaseIDs []string,
knowledgeIDs []string,
tagScopes []types.TagScope,
) (types.SearchTargets, error) {
var targets types.SearchTargets
tagIDsByKB := mergeTagScopesByKB(tagScopes)
// Build a map from KB ID to TenantID for all KBs we need to process
kbTenantMap := make(map[string]uint64)
// Track which KBs are fully searched
fullKBSet := make(map[string]bool)
// First pass: batch-fetch KBs, then resolve tenant per ID (tenant scope already set by caller)
callerTenantRole := types.TenantRoleFromContext(ctx)
kbIDsToFetch := append([]string(nil), knowledgeBaseIDs...)
for kbID := range tagIDsByKB {
kbIDsToFetch = append(kbIDsToFetch, kbID)
}
kbIDsToFetch = uniqueNonEmptyStrings(kbIDsToFetch)
kbByID := make(map[string]*types.KnowledgeBase)
if len(kbIDsToFetch) > 0 {
kbs, kbFetchErr := s.knowledgeBaseService.GetKnowledgeBasesByIDsOnly(ctx, kbIDsToFetch)
if kbFetchErr != nil {
return nil, fmt.Errorf("resolve knowledge base access for search targets: %w", kbFetchErr)
}
for _, kb := range kbs {
if kb != nil {
kbByID[kb.ID] = kb
}
}
}
userID, _ := types.UserIDFromContext(ctx)
resolveKBTenant := func(kbID string) uint64 {
if kbTenant, resolved := kbTenantMap[kbID]; resolved {
return kbTenant
}
// An omitted KB was missing or filtered by policy; never restore its scope.
kbTenantMap[kbID] = 0
kb := kbByID[kbID]
if kb == nil {
return 0
}
if kb.TenantID == tenantID {
kbTenantMap[kbID] = tenantID
} else if s.kbShareService != nil && userID != "" {
hasAccess, err := s.kbShareService.HasTenantKBPermission(ctx, kbID, tenantID, callerTenantRole, types.OrgRoleViewer)
if err == nil && hasAccess {
kbTenantMap[kbID] = kb.TenantID
}
}
return kbTenantMap[kbID]
}
if len(knowledgeBaseIDs) > 0 {
for _, kbID := range knowledgeBaseIDs {
kbTenant := resolveKBTenant(kbID)
if kbTenant == 0 || fullKBSet[kbID] {
continue
}
fullKBSet[kbID] = true
if len(tagIDsByKB[kbID]) > 0 {
continue
}
targets = append(targets, &types.SearchTarget{
Type: types.SearchTargetTypeKnowledgeBase,
KnowledgeBaseID: kbID,
TenantID: kbTenant,
})
}
}
kbToKnowledgeIDs := make(map[string][]string)
knowledgeTenantByKB := make(map[string]uint64)
// Process individual knowledge IDs (include shared KB files the user has access to)
if len(knowledgeIDs) > 0 {
knowledgeList, err := s.knowledgeService.GetKnowledgeBatchWithSharedAccess(ctx, tenantID, knowledgeIDs)
if err != nil {
logger.Warnf(ctx, "Failed to get knowledge batch for search targets: %v", err)
return targets, nil // Return what we have, don't fail
}
// Group knowledge IDs by their KB, excluding those already covered by full KB search
// Also track KB tenant IDs from knowledge items
for _, k := range knowledgeList {
if k == nil || k.KnowledgeBaseID == "" {
continue
}
// Document authorization never grants a wider KB or tag scope.
knowledgeTenantByKB[k.KnowledgeBaseID] = k.TenantID
// Skip if this KB is already fully searched without a tag scope.
if fullKBSet[k.KnowledgeBaseID] && len(tagIDsByKB[k.KnowledgeBaseID]) == 0 {
continue
}
kbToKnowledgeIDs[k.KnowledgeBaseID] = append(kbToKnowledgeIDs[k.KnowledgeBaseID], k.ID)
}
// Create SearchTargetTypeKnowledge targets for each KB with specific files
for kbID, kidList := range kbToKnowledgeIDs {
if len(tagIDsByKB[kbID]) > 0 {
continue
}
kbTenant := knowledgeTenantByKB[kbID]
if kbTenant == 0 {
continue
}
targets = append(targets, &types.SearchTarget{
Type: types.SearchTargetTypeKnowledge,
KnowledgeBaseID: kbID,
TenantID: kbTenant,
KnowledgeIDs: kidList,
DisableRecallThresholds: true,
})
}
}
for kbID, tagIDs := range tagIDsByKB {
if kbID == "" || len(tagIDs) == 0 {
continue
}
kbTenant := resolveKBTenant(kbID)
kb := kbByID[kbID]
if kbTenant == 0 || kb == nil {
continue
}
explicitKnowledgeIDs := uniqueNonEmptyStrings(kbToKnowledgeIDs[kbID])
if kb.Type != types.KnowledgeBaseTypeFAQ {
tagKnowledgeIDs, err := s.knowledgeService.ListKnowledgeIDsByTagIDs(ctx, kbTenant, kbID, tagIDs)
if err != nil {
return nil, fmt.Errorf("resolve knowledge IDs for tag scope kb_id=%s: %w", kbID, err)
}
if len(explicitKnowledgeIDs) > 0 {
tagKnowledgeIDs = intersectStrings(tagKnowledgeIDs, explicitKnowledgeIDs)
}
tagKnowledgeIDs = uniqueNonEmptyStrings(tagKnowledgeIDs)
if len(tagKnowledgeIDs) == 0 {
continue
}
targets = append(targets, &types.SearchTarget{
Type: types.SearchTargetTypeKnowledge,
KnowledgeBaseID: kbID,
TenantID: kbTenant,
KnowledgeIDs: tagKnowledgeIDs,
ScopeTagIDs: append([]string(nil), tagIDs...),
DisableRecallThresholds: true,
})
continue
}
target := &types.SearchTarget{
Type: types.SearchTargetTypeKnowledgeBase,
KnowledgeBaseID: kbID,
TenantID: kbTenant,
TagIDs: append([]string(nil), tagIDs...),
ScopeTagIDs: append([]string(nil), tagIDs...),
DisableRecallThresholds: true,
}
if len(explicitKnowledgeIDs) > 0 {
target.Type = types.SearchTargetTypeKnowledge
target.KnowledgeIDs = explicitKnowledgeIDs
target.DisableRecallThresholds = true
}
targets = append(targets, target)
}
logger.Infof(ctx, "Built %d search targets: %d full KB, %d partial/tag KB, kbTenantMap=%v",
len(targets), len(knowledgeBaseIDs), len(targets)-len(knowledgeBaseIDs), kbTenantMap)
return targets, nil
}
func mergeTagScopesByKB(scopes []types.TagScope) map[string][]string {
byKB := make(map[string][]string)
seen := make(map[string]map[string]bool)
for _, scope := range scopes {
if scope.KnowledgeBaseID == "" {
continue
}
if seen[scope.KnowledgeBaseID] == nil {
seen[scope.KnowledgeBaseID] = make(map[string]bool)
}
for _, tagID := range scope.TagIDs {
if tagID == "" || seen[scope.KnowledgeBaseID][tagID] {
continue
}
seen[scope.KnowledgeBaseID][tagID] = true
byKB[scope.KnowledgeBaseID] = append(byKB[scope.KnowledgeBaseID], tagID)
}
}
return byKB
}
func uniqueNonEmptyStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func intersectStrings(left []string, right []string) []string {
if len(left) == 0 || len(right) == 0 {
return nil
}
rightSet := make(map[string]bool, len(right))
for _, value := range right {
rightSet[value] = true
}
out := make([]string, 0)
for _, value := range left {
if rightSet[value] {
out = append(out, value)
}
}
return out
}
// KnowledgeQAByEvent processes knowledge QA through a series of events in the pipeline
func (s *sessionService) KnowledgeQAByEvent(ctx context.Context,
chatManage *types.ChatManage, eventList []types.EventType,
) error {
logger.Info(ctx, "Start processing knowledge base question answering through events")
logger.Infof(ctx, "Knowledge base question answering parameters, session ID: %s, query: %s",
chatManage.SessionID, chatManage.Query)
methods := make([]string, len(eventList))
for i, event := range eventList {
methods[i] = string(event)
}
logger.Infof(ctx, "Trigger event list: %v", methods)
pipelineStart := time.Now()
lastRetrievalStage := chatpipeline.LastConsolidatedRetrievalStage(eventList, chatManage)
var retrievalProgress *chatpipeline.StageProgress
var retrievalStart time.Time
var understandProgress *chatpipeline.StageProgress
var understandStart time.Time
for _, eventType := range eventList {
stageStart := time.Now()
// Wrap each pipeline stage in a Langfuse span so the trace timeline
// shows the gaps between LLM/embedding/rerank generations (the work
// that happens between them — vector DB search, merge, filter, prompt
// assembly — was previously invisible). Generations created inside
// the stage automatically nest under this span.
//
// CHAT_COMPLETION_STREAM is intentionally skipped: its OnEvent kicks
// off a streaming goroutine and returns immediately, so a span would
// finish well before the chat.completion.stream generation does. The
// generation already captures the full stream duration; adding a
// stage span here would just produce a child observation that
// visually exceeds its parent.
stageCtx := ctx
var stageSpan *langfuse.Span
if eventType != types.CHAT_COMPLETION_STREAM {
stageCtx, stageSpan = langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
Name: "pipeline." + string(eventType),
Metadata: map[string]interface{}{
"event_type": string(eventType),
"session_id": chatManage.SessionID,
},
})
}
if eventType == types.QUERY_UNDERSTAND && chatpipeline.ShouldEmitQueryUnderstandProgress(chatManage) {
understandStart = stageStart
understandProgress = chatpipeline.BeginQueryUnderstandProgress(stageCtx, chatManage)
}
if chatpipeline.IsConsolidatedRetrievalStage(eventType, chatManage) && retrievalProgress == nil {
retrievalStart = stageStart
retrievalProgress = chatpipeline.BeginRetrievalProgress(stageCtx, chatManage)
}
// Emit references before answer streaming so the SSE client receives
// them while the connection is still open. Previously references were
// emitted after the pipeline returned — by then the `complete` event had
// already closed the stream, so the frontend only saw citations on refresh.
if eventType == types.CHAT_COMPLETION_STREAM {
emitKnowledgeReferencesEvent(ctx, chatManage)
}
err := s.eventManager.Trigger(stageCtx, eventType, chatManage)
if understandProgress != nil && eventType == types.QUERY_UNDERSTAND {
chatpipeline.EndQueryUnderstandProgress(stageCtx, chatManage, understandProgress, understandStart, err)
understandProgress = nil
}
// Close the consolidated retrieval progress window as soon as retrieval
// is done: either the planned last retrieval stage completed, or a
// retrieval stage short-circuited the pipeline (ErrSearchNothing or a
// hard error). The early returns below (fallback / stage_failed) would
// otherwise skip EndRetrievalProgress, leaving the "knowledge_search"
// tool_call pending — so the frontend keeps spinning on "正在检索知识库"
// forever even though the fallback answer has already streamed.
if retrievalProgress != nil && chatpipeline.ShouldCloseRetrievalProgress(eventType, lastRetrievalStage, err) {
chatpipeline.EndRetrievalProgress(stageCtx, chatManage, retrievalProgress, retrievalStart, err)
retrievalProgress = nil
}
stageDuration := time.Since(stageStart)
var spanErr error
if err != nil && err != chatpipeline.ErrSearchNothing {
spanErr = err.Err
}
if stageSpan != nil {
stageSpan.Finish(map[string]interface{}{
"duration_ms": stageDuration.Milliseconds(),
}, nil, spanErr)
}
// If the user stopped generation, the context is cancelled. A cancelled
// retrieval stage surfaces as ErrSearchNothing (the search goroutines
// return no results when their embedding/vector calls are aborted), so
// this check MUST come before the ErrSearchNothing handling below.
// Otherwise we would persist the fixed fallback response ("Sorry, I am
// unable to answer this question.") over the intentionally-empty stopped
// message, and the user would see the fallback text after refreshing.
// This is not single-machine specific: the stop arrives via the shared
// StreamManager and cancels asyncCtx on whichever node is generating.
if ctxErr := ctx.Err(); ctxErr != nil {
common.PipelineWarn(ctx, "Pipeline", "stage_cancelled", map[string]interface{}{
"event": string(eventType),
"duration_ms": stageDuration.Milliseconds(),
"reason": ctxErr.Error(),
})
return ctxErr
}
if err == chatpipeline.ErrSearchNothing {
common.PipelineWarn(ctx, "Pipeline", "stage_fallback", map[string]interface{}{
"event": string(eventType),
"duration_ms": stageDuration.Milliseconds(),
"reason": "search_nothing",
"strategy": string(chatManage.FallbackStrategy),
})
s.handleFallbackResponse(ctx, chatManage)
return nil
}
if err != nil {
common.PipelineError(ctx, "Pipeline", "stage_failed", map[string]interface{}{
"event": string(eventType),
"duration_ms": stageDuration.Milliseconds(),
"error_type": err.ErrorType,
"description": err.Description,
})
return err.Err
}
common.PipelineInfo(ctx, "Pipeline", "stage_complete", map[string]interface{}{
"event": string(eventType),
"duration_ms": stageDuration.Milliseconds(),
})
}
common.PipelineInfo(ctx, "Pipeline", "all_stages_complete", map[string]interface{}{
"session_id": chatManage.SessionID,
"total_stages": len(eventList),
"total_duration_ms": time.Since(pipelineStart).Milliseconds(),
})
return nil
}
// SearchKnowledge performs knowledge base search without LLM summarization
// knowledgeBaseIDs: list of knowledge base IDs to search (supports multi-KB)
// knowledgeIDs: list of specific knowledge (file) IDs to search
func (s *sessionService) SearchKnowledge(ctx context.Context,
knowledgeBaseIDs []string, knowledgeIDs []string, tagScopes []types.TagScope, query string,
) ([]*types.SearchResult, error) {
logger.Info(ctx, "Start knowledge base search without LLM summary")
logger.Infof(ctx, "Knowledge base search parameters, knowledge base IDs: %v, knowledge IDs: %v, tag scopes: %d, query: %s",
knowledgeBaseIDs, knowledgeIDs, len(tagScopes), query)
// Get tenant ID from context
tenantID, ok := types.TenantIDFromContext(ctx)
if !ok {
logger.Error(ctx, "Failed to get tenant ID from context")
return nil, fmt.Errorf("workspace ID not found in context")
}
// Build unified search targets (computed once, used throughout pipeline)
searchTargets, err := s.buildSearchTargets(ctx, tenantID, knowledgeBaseIDs, knowledgeIDs, tagScopes)
if err != nil {
return nil, fmt.Errorf("build search targets: %w", err)
}
if len(searchTargets) == 0 {
logger.Warn(ctx, "No search targets available, returning empty results")
return []*types.SearchResult{}, nil
}
// Create default retrieval parameters — prefer tenant RetrievalConfig, fallback to built-in defaults
userID := types.SessionOwnerIDFromContext(ctx)
// Load tenant-level retrieval config (nil is safe — GetEffective* methods handle nil receiver)
var rc *types.RetrievalConfig
if tenant, err2 := s.tenantService.GetTenantByID(ctx, tenantID); err2 == nil {
rc = tenant.RetrievalConfig
}
chatManage := &types.ChatManage{
PipelineRequest: types.PipelineRequest{
Query: query,
UserID: userID,
KnowledgeBaseIDs: knowledgeBaseIDs,
KnowledgeIDs: knowledgeIDs,
SearchTargets: searchTargets,
MaxRounds: s.cfg.Conversation.MaxRounds,
EmbeddingTopK: rc.GetEffectiveEmbeddingTopK(),
VectorThreshold: rc.GetEffectiveVectorThreshold(),
KeywordThreshold: rc.GetEffectiveKeywordThreshold(),
RerankTopK: rc.GetEffectiveRerankTopK(),
RerankThreshold: rc.GetEffectiveRerankThreshold(),
},
PipelineState: types.PipelineState{
RewriteQuery: query,
},
}
// Get default models
if options, ok := types.WorkbenchRetrievalFromContext(ctx); ok {
chatManage.EmbeddingTopK = max(chatManage.EmbeddingTopK, min(100, options.Candidates))
chatManage.RerankTopK = max(chatManage.RerankTopK, min(60, options.Results))
}
// Get default models
models, err := s.modelService.ListModels(ctx)
if err != nil {
logger.Errorf(ctx, "Failed to get models: %v", err)
return nil, err
}
// Use rerank model from RetrievalConfig if set, otherwise auto-select the first available
if rc != nil && rc.RerankModelID != "" {
chatManage.RerankModelID = rc.RerankModelID
} else {
for _, model := range models {
if model == nil {
continue
}
if model.Type == types.ModelTypeRerank {
chatManage.RerankModelID = model.ID
break
}
}
}
// Use specific event list, only including retrieval-related events, not LLM summarization
searchEvents := []types.EventType{
types.CHUNK_SEARCH, // Vector search
types.CHUNK_RERANK, // Rerank search results
types.CHUNK_MERGE, // Merge search results
types.FILTER_TOP_K, // Filter top K results
}
logger.Infof(ctx, "Trigger search event list: %v", searchEvents)
for _, event := range searchEvents {
logger.Infof(ctx, "Starting to trigger search event: %v", event)
stageCtx, stageSpan := langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
Name: "pipeline." + string(event),
Metadata: map[string]interface{}{
"event_type": string(event),
"flow": "search_knowledge",
},
})
err := s.eventManager.Trigger(stageCtx, event, chatManage)
var spanErr error
if err != nil && err != chatpipeline.ErrSearchNothing {
spanErr = err.Err
}
stageSpan.Finish(nil, nil, spanErr)
if err == chatpipeline.ErrSearchNothing {
logger.Warnf(ctx, "Event %v triggered, search result is empty", event)
return []*types.SearchResult{}, nil
}
if err != nil {
logger.Errorf(ctx, "Event triggering failed, event: %v, error type: %s, description: %s, error: %v",
event, err.ErrorType, err.Description, err.Err)
return nil, err.Err
}
logger.Infof(ctx, "Event %v triggered successfully", event)
}
logger.Infof(ctx, "Knowledge base search completed, found %d results", len(chatManage.MergeResult))
return chatManage.MergeResult, nil
}
// handleFallbackResponse handles fallback response based on strategy
func (s *sessionService) handleFallbackResponse(ctx context.Context, chatManage *types.ChatManage) {
if chatManage.FallbackStrategy == types.FallbackStrategyModel {
s.handleModelFallback(ctx, chatManage)
} else {
s.handleFixedFallback(ctx, chatManage)
}
}
// handleFixedFallback handles fixed fallback response
func (s *sessionService) handleFixedFallback(ctx context.Context, chatManage *types.ChatManage) {
fallbackContent := chatManage.FallbackResponse
chatManage.ChatResponse = &types.ChatResponse{Content: fallbackContent}
s.emitFallbackAnswer(ctx, chatManage, fallbackContent)
}
// handleModelFallback handles model-based fallback response using streaming
func (s *sessionService) handleModelFallback(ctx context.Context, chatManage *types.ChatManage) {
// Check if FallbackPrompt is available
if chatManage.FallbackPrompt == "" {
logger.Warnf(ctx, "Fallback strategy is 'model' but FallbackPrompt is empty, falling back to fixed response")
s.handleFixedFallback(ctx, chatManage)
return
}
// Render template with Query variable
promptContent, err := s.renderFallbackPrompt(ctx, chatManage)
if err != nil {
logger.Errorf(ctx, "Failed to render fallback prompt: %v, falling back to fixed response", err)
s.handleFixedFallback(ctx, chatManage)
return
}
// Check if EventBus is available for streaming
if chatManage.EventBus == nil {
logger.Warnf(ctx, "EventBus not available for streaming fallback, falling back to fixed response")
s.handleFixedFallback(ctx, chatManage)
return
}
// Get chat model
chatModel, err := s.modelService.GetChatModel(ctx, chatManage.ChatModelID)
if err != nil {
logger.Errorf(ctx, "Failed to get chat model for fallback: %v, falling back to fixed response", err)
s.handleFixedFallback(ctx, chatManage)
return
}
// Prepare chat options
thinking := false
opt := &chat.ChatOptions{
Temperature: chatManage.SummaryConfig.Temperature,
MaxCompletionTokens: chatManage.SummaryConfig.MaxCompletionTokens,
Thinking: &thinking,
}
// Start streaming response
fallbackMessages, modelContext := prepareFallbackMessages(chatManage, promptContent)
responseChan, err := chatModel.ChatStream(ctx, fallbackMessages, opt)
if err != nil {
logger.Errorf(ctx, "Failed to start streaming fallback response: %v, falling back to fixed response", err)
s.handleFixedFallback(ctx, chatManage)
return
}
if responseChan == nil {
logger.Errorf(ctx, "Chat stream returned nil channel, falling back to fixed response")
s.handleFixedFallback(ctx, chatManage)
return
}
// Start goroutine to consume stream and emit events
go s.consumeFallbackStream(ctx, chatManage, responseChan, modelContext)
}
func prepareFallbackMessages(
chatManage *types.ChatManage,
promptContent string,
) ([]chat.Message, *modelcontext.Registry) {
messages := buildFallbackMessages(chatManage, promptContent)
citationsEnabled := chatManage == nil || chatManage.CitationsEnabled()
registry := modelcontext.NewRegistry(citationsEnabled)
if len(messages) > 0 && messages[0].Role == "system" {
messages[0].Content = strings.TrimRight(messages[0].Content, " \t\r\n") + registry.ProtocolPrompt()
} else {
messages = append([]chat.Message{{Role: "system", Content: strings.TrimSpace(registry.ProtocolPrompt())}}, messages...)
}
return registry.EncodeMessages(messages), registry
}
func buildFallbackMessages(chatManage *types.ChatManage, promptContent string) []chat.Message {
messages := make([]chat.Message, 0, len(chatManage.History)*2+2)
// The model-fallback prompt is a system-style instruction (KB document
// listing + "use general knowledge when nothing matched" guidance). Carry
// it in the system role so the LLM input keeps a proper system message
// instead of starting with a bare user turn. We deliberately do NOT reuse
// the RAG summary system prompt (SummaryConfig.Prompt) here: that template
// forbids prior knowledge ("reply ONLY based on retrieved information"),
// which directly contradicts the fallback's purpose.
if strings.TrimSpace(promptContent) != "" {
messages = append(messages, chat.Message{Role: "system", Content: promptContent})
}
messages = chatpipeline.AppendHistoryMessages(messages, chatManage.History)
// End on the user's actual question so generation is prompted by a user
// turn (the query is also embedded in the system instruction above, but a
// trailing user message keeps the chat shape valid for all providers).
query := chatManage.Query
if rq := strings.TrimSpace(chatManage.RewriteQuery); rq != "" {
query = rq
}
userMsg := chat.Message{Role: "user", Content: query}
if chatManage.ChatModelSupportsVision && len(chatManage.Images) > 0 {
userMsg.Images = chatManage.Images
}
return append(messages, userMsg)
}
// renderFallbackPrompt renders the fallback prompt template with query and image context.
func (s *sessionService) renderFallbackPrompt(ctx context.Context, chatManage *types.ChatManage) (string, error) {
query := chatManage.Query
if rq := strings.TrimSpace(chatManage.RewriteQuery); rq != "" {
query = rq
}
kbDocuments := s.buildKBDocumentListing(ctx, chatManage)
result := types.RenderPromptPlaceholders(chatManage.FallbackPrompt, types.PlaceholderValues{
"query": query,
"language": chatManage.Language,
"kb_documents": kbDocuments,
})
if chatManage.ImageDescription != "" && !chatManage.ChatModelSupportsVision {
result += "\n\n[用户上传图片内容]\n" + chatManage.ImageDescription
}
if chatManage.QuotedContext != "" {
result += "\n\n" + chatManage.QuotedContext
}
return result, nil
}
// buildKBDocumentListing returns a concise listing of documents in the knowledge bases
// associated with the current pipeline. This gives the LLM visibility into KB contents
// when vector/keyword search returns empty (e.g., broad browse queries).
func (s *sessionService) buildKBDocumentListing(ctx context.Context, chatManage *types.ChatManage) string {
// Collect unique KB IDs from search targets
kbIDs := make(map[string]struct{})
for _, t := range chatManage.SearchTargets {
kbIDs[t.KnowledgeBaseID] = struct{}{}
}
for _, id := range chatManage.KnowledgeBaseIDs {
kbIDs[id] = struct{}{}
}
if len(kbIDs) == 0 {
return ""
}
const maxDocuments = 50
var b strings.Builder
total := 0
for kbID := range kbIDs {
if total >= maxDocuments {
break
}
knowledges, err := s.knowledgeService.ListKnowledgeByKnowledgeBaseID(ctx, kbID)
if err != nil {
logger.Warnf(ctx, "buildKBDocumentListing: failed to list knowledge for KB %s: %v", kbID, err)
continue
}
for _, k := range knowledges {
if total >= maxDocuments {
break
}
if k.EnableStatus != "enabled" {
continue
}
title := k.Title
if title == "" {
title = k.FileName
}
if title == "" {
continue
}
fmt.Fprintf(&b, "- %s", title)
if k.FileType != "" {
fmt.Fprintf(&b, " (%s)", k.FileType)
}
if k.Description != "" {
desc := k.Description
if len([]rune(desc)) > 100 {
desc = string([]rune(desc)[:100]) + "..."
}
fmt.Fprintf(&b, ": %s", desc)
}
b.WriteString("\n")
total++
}
}
if b.Len() == 0 {
return ""
}
if total >= maxDocuments {
fmt.Fprintf(&b, "... (showing first %d documents)\n", maxDocuments)
}
return b.String()
}
// consumeFallbackStream consumes the streaming response and emits events
func (s *sessionService) consumeFallbackStream(
ctx context.Context,
chatManage *types.ChatManage,
responseChan <-chan types.StreamResponse,
modelContext *modelcontext.Registry,
) {
fallbackID := generateEventID("fallback")
eventBus := chatManage.EventBus
var finalContent string
streamCompleted := false
decoder := modelContext.StreamDecoder()
for response := range responseChan {
// Emit event for each answer chunk
if response.ResponseType == types.ResponseTypeAnswer {
response.Content = decoder.Feed(response.Content)
if response.Done {
response.Content += decoder.Flush()
}
finalContent += response.Content
if err := eventBus.Emit(ctx, types.Event{
ID: fallbackID,
Type: types.EventType(event.EventAgentFinalAnswer),
SessionID: chatManage.SessionID,
Data: event.AgentFinalAnswerData{
Content: response.Content,
Done: response.Done,
IsFallback: true,
},
}); err != nil {
logger.Errorf(ctx, "Failed to emit fallback answer chunk event: %v", err)
}
// Update ChatResponse with final content when done
if response.Done {
chatManage.ChatResponse = &types.ChatResponse{Content: finalContent}
streamCompleted = true
logger.Infof(ctx, "Fallback streaming response completed")
break
}
}
}
// If channel closed without Done=true, emit final event with fixed response
if !streamCompleted {
logger.Warnf(ctx, "Fallback stream closed without completion, emitting final event with fixed response")
s.emitFallbackAnswer(ctx, chatManage, chatManage.FallbackResponse)
}
}
// emitKnowledgeReferencesEvent streams retrieved chunks to the client as a
// `references` SSE event. These references drive the retrieval-results UI and
// remain available even when inline citations in the model answer are disabled.
// Must run before CHAT_COMPLETION_STREAM so the event arrives while the
// connection is still open (complete closes the stream).
func emitKnowledgeReferencesEvent(ctx context.Context, chatManage *types.ChatManage) {
if chatManage == nil || chatManage.EventBus == nil || len(chatManage.MergeResult) == 0 {
return
}
logger.Infof(ctx, "Emitting references event with %d results (pre-answer)", len(chatManage.MergeResult))
if err := chatManage.EventBus.Emit(ctx, types.Event{
ID: generateEventID("references"),
Type: types.EventType(event.EventAgentReferences),
SessionID: chatManage.SessionID,
Data: event.AgentReferencesData{
References: chatManage.MergeResult,
},
}); err != nil {
logger.Errorf(ctx, "Failed to emit references event: %v", err)
}
}
// emitFallbackAnswer emits fallback answer event
func (s *sessionService) emitFallbackAnswer(ctx context.Context, chatManage *types.ChatManage, content string) {
if chatManage.EventBus == nil {
return
}
if !chatManage.CitationsEnabled() {
content = modelcontext.NewRegistry(false).DecodeOutputText(content)
}
fallbackID := generateEventID("fallback")
if err := chatManage.EventBus.Emit(ctx, types.Event{
ID: fallbackID,
Type: types.EventType(event.EventAgentFinalAnswer),
SessionID: chatManage.SessionID,
Data: event.AgentFinalAnswerData{
Content: content,
Done: true,
IsFallback: true,
},
}); err != nil {
logger.Errorf(ctx, "Failed to emit fallback answer event: %v", err)
} else {
logger.Infof(ctx, "Fallback answer event emitted successfully")
}
}
// resolveWebSearchProviderID returns the web search provider ID to use for a pipeline request.
// Priority: agent config > tenant default (is_default=true)
func (s *sessionService) resolveWebSearchProviderID(ctx context.Context, req *types.QARequest, tenantID uint64) string {
// 1. Agent-level override
if req.CustomAgent != nil && req.CustomAgent.Config.WebSearchProviderID != "" {
return req.CustomAgent.Config.WebSearchProviderID
}
// 2. Tenant default
if s.webSearchProviderRepo != nil {
if defaultProvider, err := s.webSearchProviderRepo.GetDefault(ctx, tenantID); err == nil && defaultProvider != nil {
return defaultProvider.ID
}
}
return ""
}
// resolveWebFetchEnabled returns whether auto web fetch is enabled for this request.
func (s *sessionService) resolveWebFetchEnabled(req *types.QARequest) bool {
if req.CustomAgent != nil {
return req.CustomAgent.Config.WebFetchEnabled
}
return false
}
// resolveWebFetchTopN returns how many pages to fetch after rerank.
func (s *sessionService) resolveWebFetchTopN(req *types.QARequest) int {
if req.CustomAgent != nil && req.CustomAgent.Config.WebFetchTopN > 0 {
return req.CustomAgent.Config.WebFetchTopN
}
return 3
}
// resolveWebSearchMaxResults returns the max results for web search.
// Priority: agent config > tenant default > default (10)
func (s *sessionService) resolveWebSearchMaxResults(ctx context.Context, req *types.QARequest) int {
if req.CustomAgent != nil && req.CustomAgent.Config.WebSearchMaxResults > 0 {
return req.CustomAgent.Config.WebSearchMaxResults
}
tenantInfo, _ := types.TenantInfoFromContext(ctx)
if tenantInfo != nil {
return types.EffectiveWebSearchConfig(tenantInfo.WebSearchConfig).MaxResults
}
return types.DefaultWebSearchMaxResults
}