Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
1303 lines
47 KiB
Go
1303 lines
47 KiB
Go
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
|
|
}
|