1322 lines
41 KiB
Go
1322 lines
41 KiB
Go
package service
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"errors"
|
|||
|
|
"math/rand"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/Tencent/WeKnora/internal/agent/tools"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/application/repository"
|
|||
|
|
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|||
|
|
"github.com/google/uuid"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Custom agent related errors
|
|||
|
|
var (
|
|||
|
|
ErrAgentNotFound = errors.New("agent not found")
|
|||
|
|
ErrCannotModifyBuiltin = errors.New("cannot modify built-in agent basic info")
|
|||
|
|
ErrCannotDeleteBuiltin = errors.New("cannot delete built-in agent")
|
|||
|
|
ErrAgentNameRequired = errors.New("agent name is required")
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
// suggestionDefaultLimit is the fallback count when neither the caller nor
|
|||
|
|
// the agent configuration specifies how many suggestions to return.
|
|||
|
|
suggestionDefaultLimit = 6
|
|||
|
|
// suggestionMaxLimit caps how many suggestions a single request may ask for.
|
|||
|
|
// It bounds the downstream candidate pool (limit*5) so an oversized limit
|
|||
|
|
// cannot turn into an unbounded chunk query.
|
|||
|
|
suggestionMaxLimit = 30
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// customAgentService implements the CustomAgentService interface
|
|||
|
|
type customAgentService struct {
|
|||
|
|
repo interfaces.CustomAgentRepository
|
|||
|
|
chunkRepo interfaces.ChunkRepository
|
|||
|
|
kbService interfaces.KnowledgeBaseService
|
|||
|
|
kbShareService interfaces.KBShareService
|
|||
|
|
wikiPageRepo interfaces.WikiPageRepository
|
|||
|
|
tagRepo interfaces.KnowledgeTagRepository
|
|||
|
|
knowledgeRepo interfaces.KnowledgeRepository
|
|||
|
|
models interfaces.ModelService
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type digitalHumanAgentAccess interface {
|
|||
|
|
FilterDigitalHumanAgents(context.Context, []*types.CustomAgent) ([]*types.CustomAgent, error)
|
|||
|
|
CheckDigitalHumanAgentDeletion(context.Context, string, uint64) error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *customAgentService) digitalHumanAgentReadable(ctx context.Context, agent *types.CustomAgent) error {
|
|||
|
|
if !nativeAgentReadable(ctx, agent) && !nativeAgentExecutableDependency(ctx, agent) {
|
|||
|
|
return ErrAgentNotFound
|
|||
|
|
}
|
|||
|
|
guard, ok := s.repo.(digitalHumanAgentAccess)
|
|||
|
|
if !ok {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
visible, err := guard.FilterDigitalHumanAgents(ctx, []*types.CustomAgent{agent})
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
if len(visible) == 0 {
|
|||
|
|
return ErrAgentNotFound
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *customAgentService) CheckDigitalHumanAgentDeletion(ctx context.Context, id string) error {
|
|||
|
|
guard, ok := s.repo.(digitalHumanAgentAccess)
|
|||
|
|
if !ok {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
tenantID, ok := types.TenantIDFromContext(ctx)
|
|||
|
|
if !ok {
|
|||
|
|
return ErrInvalidTenantID
|
|||
|
|
}
|
|||
|
|
return guard.CheckDigitalHumanAgentDeletion(ctx, id, tenantID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewCustomAgentService creates a new custom agent service
|
|||
|
|
func NewCustomAgentService(
|
|||
|
|
repo interfaces.CustomAgentRepository,
|
|||
|
|
chunkRepo interfaces.ChunkRepository,
|
|||
|
|
kbService interfaces.KnowledgeBaseService,
|
|||
|
|
kbShareService interfaces.KBShareService,
|
|||
|
|
wikiPageRepo interfaces.WikiPageRepository,
|
|||
|
|
tagRepo interfaces.KnowledgeTagRepository,
|
|||
|
|
knowledgeRepo interfaces.KnowledgeRepository,
|
|||
|
|
models interfaces.ModelService,
|
|||
|
|
) interfaces.CustomAgentService {
|
|||
|
|
return &customAgentService{
|
|||
|
|
repo: repo,
|
|||
|
|
chunkRepo: chunkRepo,
|
|||
|
|
kbService: kbService,
|
|||
|
|
kbShareService: kbShareService,
|
|||
|
|
wikiPageRepo: wikiPageRepo,
|
|||
|
|
tagRepo: tagRepo,
|
|||
|
|
knowledgeRepo: knowledgeRepo,
|
|||
|
|
models: models,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreateAgent creates a new custom agent
|
|||
|
|
func (s *customAgentService) CreateAgent(ctx context.Context, agent *types.CustomAgent) (*types.CustomAgent, error) {
|
|||
|
|
if err := agent.Config.ValidateDuty(); err != nil {
|
|||
|
|
return nil, apperrors.NewBadRequestError(err.Error())
|
|||
|
|
}
|
|||
|
|
// Validate required fields
|
|||
|
|
if strings.TrimSpace(agent.Name) == "" {
|
|||
|
|
return nil, ErrAgentNameRequired
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Generate UUID and set creation timestamps
|
|||
|
|
if agent.ID == "" {
|
|||
|
|
agent.ID = uuid.New().String()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get tenant ID from context
|
|||
|
|
tenantID, ok := types.TenantIDFromContext(ctx)
|
|||
|
|
if !ok {
|
|||
|
|
return nil, ErrInvalidTenantID
|
|||
|
|
}
|
|||
|
|
agent.TenantID = tenantID
|
|||
|
|
|
|||
|
|
// Record the creator. Mirrors KnowledgeBase.CreatorID — needed by
|
|||
|
|
// RBAC's RequireOwnershipOrRole so Contributors can edit their own
|
|||
|
|
// agents. Synthetic system-{tenantID} users (X-API-Key path) leave
|
|||
|
|
// the field empty via IsSyntheticUserID, which makes the agent
|
|||
|
|
// tenant-owned (Admin+ only).
|
|||
|
|
agent.CreatedBy = ""
|
|||
|
|
if uid, ok := types.UserIDFromContext(ctx); ok && !types.IsSyntheticUserID(uid) {
|
|||
|
|
agent.CreatedBy = uid
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Set timestamps
|
|||
|
|
agent.CreatedAt = time.Now()
|
|||
|
|
agent.UpdatedAt = time.Now()
|
|||
|
|
|
|||
|
|
// Ensure agent mode is set for user-created agents
|
|||
|
|
if agent.Config.AgentMode == "" {
|
|||
|
|
agent.Config.AgentMode = types.AgentModeQuickAnswer
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Cannot create built-in agents
|
|||
|
|
agent.IsBuiltin = false
|
|||
|
|
|
|||
|
|
if agent.Visibility == "" {
|
|||
|
|
agent.Visibility = "private"
|
|||
|
|
agent.Enabled = true
|
|||
|
|
}
|
|||
|
|
// Set defaults
|
|||
|
|
agent.EnsureDefaults()
|
|||
|
|
if err := s.validateFusionAgents(ctx, agent); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if agent.Visibility != "private" && agent.Visibility != "global" {
|
|||
|
|
return nil, apperrors.NewBadRequestError("Agent 可见范围必须为 private 或 global")
|
|||
|
|
}
|
|||
|
|
if err := agent.Config.QuestionSuggestions.Validate(); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Creating custom agent, ID: %s, tenant ID: %d, name: %s, agent_mode: %s",
|
|||
|
|
agent.ID, agent.TenantID, agent.Name, agent.Config.AgentMode)
|
|||
|
|
|
|||
|
|
if err := s.repo.CreateAgent(ctx, agent); err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"agent_id": agent.ID,
|
|||
|
|
"tenant_id": agent.TenantID,
|
|||
|
|
})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Custom agent created successfully, ID: %s, name: %s", agent.ID, agent.Name)
|
|||
|
|
return agent, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetAgentByID retrieves an agent by its ID (including built-in agents)
|
|||
|
|
func (s *customAgentService) GetAgentByID(ctx context.Context, id string) (*types.CustomAgent, error) {
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Agent ID is empty")
|
|||
|
|
return nil, errors.New("agent ID cannot be empty")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get tenant ID from context
|
|||
|
|
tenantID, ok := types.TenantIDFromContext(ctx)
|
|||
|
|
if !ok {
|
|||
|
|
return nil, ErrInvalidTenantID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check if it's a built-in agent using the registry
|
|||
|
|
if types.IsBuiltinAgentID(id) {
|
|||
|
|
// Try to get from database first (for customized config)
|
|||
|
|
agent, err := s.repo.GetAgentByID(ctx, id, tenantID)
|
|||
|
|
if err == nil {
|
|||
|
|
if err := s.digitalHumanAgentReadable(ctx, agent); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
// Found in database, overlay locale-specific name/description/avatar
|
|||
|
|
agent.EnsureDefaults()
|
|||
|
|
types.ApplyBuiltinAgentLocalization(ctx, agent)
|
|||
|
|
return agent, nil
|
|||
|
|
}
|
|||
|
|
if !errors.Is(err, repository.ErrCustomAgentNotFound) {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
// Not in database, return default built-in agent from registry (i18n-aware)
|
|||
|
|
if builtinAgent := types.GetBuiltinAgentWithContext(ctx, id, tenantID); builtinAgent != nil {
|
|||
|
|
return builtinAgent, nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Query from database
|
|||
|
|
agent, err := s.repo.GetAgentByID(ctx, id, tenantID)
|
|||
|
|
if err != nil {
|
|||
|
|
if errors.Is(err, repository.ErrCustomAgentNotFound) {
|
|||
|
|
return nil, ErrAgentNotFound
|
|||
|
|
}
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"agent_id": id,
|
|||
|
|
})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := s.digitalHumanAgentReadable(ctx, agent); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
agent.EnsureDefaults()
|
|||
|
|
return agent, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetAgentByIDAndTenant reads an explicit local tenant. Organization shares
|
|||
|
|
// use AgentShareService, which verifies the share before reading its source.
|
|||
|
|
func (s *customAgentService) GetAgentByIDAndTenant(ctx context.Context, id string, tenantID uint64) (*types.CustomAgent, error) {
|
|||
|
|
if current, ok := types.TenantIDFromContext(ctx); !ok || current != tenantID || tenantID == 0 {
|
|||
|
|
return nil, ErrAgentNotFound
|
|||
|
|
}
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Agent ID is empty")
|
|||
|
|
return nil, errors.New("agent ID cannot be empty")
|
|||
|
|
}
|
|||
|
|
agent, err := s.repo.GetAgentByID(ctx, id, tenantID)
|
|||
|
|
if err != nil {
|
|||
|
|
if errors.Is(err, repository.ErrCustomAgentNotFound) {
|
|||
|
|
return nil, ErrAgentNotFound
|
|||
|
|
}
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if err := s.digitalHumanAgentReadable(ctx, agent); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
agent.EnsureDefaults()
|
|||
|
|
return agent, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListAgents lists all agents for the current tenant (including built-in agents)
|
|||
|
|
func (s *customAgentService) ListAgents(ctx context.Context) ([]*types.CustomAgent, error) {
|
|||
|
|
tenantID, ok := types.TenantIDFromContext(ctx)
|
|||
|
|
if !ok {
|
|||
|
|
return nil, ErrInvalidTenantID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get all agents from database (including built-in agents with customized config)
|
|||
|
|
allAgents, err := s.repo.ListAgentsByTenantID(ctx, tenantID)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"tenant_id": tenantID,
|
|||
|
|
})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
// Track persisted builtins before filtering so unavailable overrides
|
|||
|
|
// cannot be resurrected from registry defaults.
|
|||
|
|
builtinInDB := make(map[string]bool)
|
|||
|
|
visible := make([]*types.CustomAgent, 0, len(allAgents))
|
|||
|
|
for _, agent := range allAgents {
|
|||
|
|
if agent != nil && types.IsBuiltinAgentID(agent.ID) {
|
|||
|
|
builtinInDB[agent.ID] = true
|
|||
|
|
}
|
|||
|
|
if nativeAgentReadable(ctx, agent) {
|
|||
|
|
visible = append(visible, agent)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
allAgents = visible
|
|||
|
|
if guard, ok := s.repo.(digitalHumanAgentAccess); ok {
|
|||
|
|
allAgents, err = guard.FilterDigitalHumanAgents(ctx, allAgents)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Build result: built-in agents first, then custom agents
|
|||
|
|
builtinIDs := types.GetBuiltinAgentIDs()
|
|||
|
|
result := make([]*types.CustomAgent, 0, len(allAgents)+len(builtinIDs))
|
|||
|
|
|
|||
|
|
// Add built-in agents in order
|
|||
|
|
for _, builtinID := range builtinIDs {
|
|||
|
|
if builtinInDB[builtinID] {
|
|||
|
|
// Use customized config from database
|
|||
|
|
for _, agent := range allAgents {
|
|||
|
|
if agent.ID == builtinID {
|
|||
|
|
types.ApplyBuiltinAgentLocalization(ctx, agent)
|
|||
|
|
result = append(result, agent)
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Use default built-in agent (i18n-aware)
|
|||
|
|
if agent := types.GetBuiltinAgentWithContext(ctx, builtinID, tenantID); agent != nil {
|
|||
|
|
result = append(result, agent)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Add custom agents
|
|||
|
|
for _, agent := range allAgents {
|
|||
|
|
if !types.IsBuiltinAgentID(agent.ID) {
|
|||
|
|
result = append(result, agent)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateAgent updates an agent's information
|
|||
|
|
func (s *customAgentService) UpdateAgent(ctx context.Context, agent *types.CustomAgent) (*types.CustomAgent, error) {
|
|||
|
|
if err := agent.Config.ValidateDuty(); err != nil {
|
|||
|
|
return nil, apperrors.NewBadRequestError(err.Error())
|
|||
|
|
}
|
|||
|
|
if agent.ID == "" {
|
|||
|
|
logger.Error(ctx, "Agent ID is empty")
|
|||
|
|
return nil, errors.New("agent ID cannot be empty")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get tenant ID from context
|
|||
|
|
tenantID, ok := types.TenantIDFromContext(ctx)
|
|||
|
|
if !ok {
|
|||
|
|
return nil, ErrInvalidTenantID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle built-in agents specially using registry
|
|||
|
|
if types.IsBuiltinAgentID(agent.ID) {
|
|||
|
|
return s.updateBuiltinAgent(ctx, agent, tenantID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get existing agent
|
|||
|
|
existingAgent, err := s.repo.GetAgentByID(ctx, agent.ID, tenantID)
|
|||
|
|
if err != nil {
|
|||
|
|
if errors.Is(err, repository.ErrCustomAgentNotFound) {
|
|||
|
|
return nil, ErrAgentNotFound
|
|||
|
|
}
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Cannot modify built-in status
|
|||
|
|
if existingAgent.IsBuiltin {
|
|||
|
|
return nil, ErrCannotModifyBuiltin
|
|||
|
|
}
|
|||
|
|
if !assetOwnedByCaller(ctx, existingAgent.TenantID, existingAgent.CreatedBy) {
|
|||
|
|
return nil, apperrors.NewForbiddenError("没有此 Agent 的修改权限")
|
|||
|
|
}
|
|||
|
|
if err := s.digitalHumanAgentReadable(ctx, existingAgent); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
agent.TenantID = tenantID
|
|||
|
|
if err := s.validateFusionAgents(ctx, agent); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate name
|
|||
|
|
if strings.TrimSpace(agent.Name) == "" {
|
|||
|
|
return nil, ErrAgentNameRequired
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Update fields
|
|||
|
|
existingAgent.Name = agent.Name
|
|||
|
|
existingAgent.Description = agent.Description
|
|||
|
|
existingAgent.Avatar = agent.Avatar
|
|||
|
|
existingAgent.Config = agent.Config
|
|||
|
|
if agent.Visibility != "" {
|
|||
|
|
if agent.Visibility != "private" && agent.Visibility != "global" {
|
|||
|
|
return nil, apperrors.NewBadRequestError("Agent 可见范围必须为 private 或 global")
|
|||
|
|
}
|
|||
|
|
existingAgent.Visibility = agent.Visibility
|
|||
|
|
existingAgent.Enabled = agent.Enabled
|
|||
|
|
}
|
|||
|
|
existingAgent.UpdatedAt = time.Now()
|
|||
|
|
|
|||
|
|
// Ensure defaults
|
|||
|
|
existingAgent.EnsureDefaults()
|
|||
|
|
if err := existingAgent.Config.QuestionSuggestions.Validate(); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Updating custom agent, ID: %s, name: %s", agent.ID, agent.Name)
|
|||
|
|
|
|||
|
|
if err := s.repo.UpdateAgent(ctx, existingAgent); err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"agent_id": agent.ID,
|
|||
|
|
})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Custom agent updated successfully, ID: %s", agent.ID)
|
|||
|
|
return existingAgent, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// updateBuiltinAgent updates a built-in agent's configuration (but not basic info)
|
|||
|
|
func (s *customAgentService) updateBuiltinAgent(ctx context.Context, agent *types.CustomAgent, tenantID uint64) (*types.CustomAgent, error) {
|
|||
|
|
if !assetAdministrator(ctx) {
|
|||
|
|
return nil, apperrors.NewForbiddenError("只有管理员可以修改内置 Agent")
|
|||
|
|
}
|
|||
|
|
agent.TenantID = tenantID
|
|||
|
|
if err := s.validateFusionAgents(ctx, agent); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
// Persist locale-independent display fields (the YAML "default" locale) so
|
|||
|
|
// read paths that skip builtin localization see a stable language.
|
|||
|
|
defaultAgent := types.GetBuiltinAgent(agent.ID, tenantID)
|
|||
|
|
if defaultAgent == nil {
|
|||
|
|
return nil, ErrAgentNotFound
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Try to get existing customized config from database
|
|||
|
|
existingAgent, err := s.repo.GetAgentByID(ctx, agent.ID, tenantID)
|
|||
|
|
if err != nil && !errors.Is(err, repository.ErrCustomAgentNotFound) {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if existingAgent != nil {
|
|||
|
|
// Update existing record - only update config, keep basic info unchanged
|
|||
|
|
existingAgent.Config = agent.Config
|
|||
|
|
existingAgent.UpdatedAt = time.Now()
|
|||
|
|
existingAgent.EnsureDefaults()
|
|||
|
|
if err := existingAgent.Config.QuestionSuggestions.Validate(); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Updating built-in agent config, ID: %s", agent.ID)
|
|||
|
|
|
|||
|
|
if err := s.repo.UpdateAgent(ctx, existingAgent); err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"agent_id": agent.ID,
|
|||
|
|
})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Built-in agent config updated successfully, ID: %s", agent.ID)
|
|||
|
|
types.ApplyBuiltinAgentLocalization(ctx, existingAgent)
|
|||
|
|
return existingAgent, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Create new record for built-in agent with customized config
|
|||
|
|
newAgent := &types.CustomAgent{
|
|||
|
|
ID: defaultAgent.ID,
|
|||
|
|
Name: defaultAgent.Name,
|
|||
|
|
Description: defaultAgent.Description,
|
|||
|
|
Avatar: defaultAgent.Avatar,
|
|||
|
|
IsBuiltin: true,
|
|||
|
|
TenantID: tenantID,
|
|||
|
|
Config: agent.Config,
|
|||
|
|
CreatedAt: time.Now(),
|
|||
|
|
UpdatedAt: time.Now(),
|
|||
|
|
}
|
|||
|
|
newAgent.EnsureDefaults()
|
|||
|
|
if err := newAgent.Config.QuestionSuggestions.Validate(); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Creating built-in agent config record, ID: %s, tenant ID: %d", agent.ID, tenantID)
|
|||
|
|
|
|||
|
|
if err := s.repo.CreateAgent(ctx, newAgent); err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"agent_id": agent.ID,
|
|||
|
|
"tenant_id": tenantID,
|
|||
|
|
})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Built-in agent config record created successfully, ID: %s", agent.ID)
|
|||
|
|
types.ApplyBuiltinAgentLocalization(ctx, newAgent)
|
|||
|
|
return newAgent, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteAgent deletes an agent
|
|||
|
|
func (s *customAgentService) DeleteAgent(ctx context.Context, id string) error {
|
|||
|
|
_, err := s.DeleteAgentWithIMChannels(ctx, id)
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *customAgentService) DeleteAgentWithIMChannels(ctx context.Context, id string) ([]string, error) {
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Agent ID is empty")
|
|||
|
|
return nil, errors.New("agent ID cannot be empty")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Cannot delete built-in agents using registry check
|
|||
|
|
if types.IsBuiltinAgentID(id) {
|
|||
|
|
return nil, ErrCannotDeleteBuiltin
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get tenant ID from context
|
|||
|
|
tenantID, ok := types.TenantIDFromContext(ctx)
|
|||
|
|
if !ok {
|
|||
|
|
return nil, ErrInvalidTenantID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get existing agent to verify ownership
|
|||
|
|
existingAgent, err := s.repo.GetAgentByID(ctx, id, tenantID)
|
|||
|
|
if err != nil {
|
|||
|
|
if errors.Is(err, repository.ErrCustomAgentNotFound) {
|
|||
|
|
return nil, ErrAgentNotFound
|
|||
|
|
}
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Cannot delete built-in agents
|
|||
|
|
if existingAgent.IsBuiltin {
|
|||
|
|
return nil, ErrCannotDeleteBuiltin
|
|||
|
|
}
|
|||
|
|
if !assetOwnedByCaller(ctx, existingAgent.TenantID, existingAgent.CreatedBy) {
|
|||
|
|
return nil, apperrors.NewForbiddenError("没有此 Agent 的删除权限")
|
|||
|
|
}
|
|||
|
|
if err := s.digitalHumanAgentReadable(ctx, existingAgent); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if err := s.CheckDigitalHumanAgentDeletion(ctx, id); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Deleting custom agent, ID: %s", id)
|
|||
|
|
|
|||
|
|
var channelIDs []string
|
|||
|
|
if guard, ok := s.repo.(interface {
|
|||
|
|
DeleteAgentWithDigitalHumanBindings(context.Context, string, uint64) ([]string, error)
|
|||
|
|
}); ok {
|
|||
|
|
channelIDs, err = guard.DeleteAgentWithDigitalHumanBindings(ctx, id, tenantID)
|
|||
|
|
} else {
|
|||
|
|
err = s.repo.DeleteAgent(ctx, id, tenantID)
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"agent_id": id,
|
|||
|
|
})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Custom agent deleted successfully, ID: %s", id)
|
|||
|
|
return channelIDs, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CopyAgent creates a copy of an existing agent
|
|||
|
|
func (s *customAgentService) CopyAgent(ctx context.Context, id string) (*types.CustomAgent, error) {
|
|||
|
|
if id == "" {
|
|||
|
|
logger.Error(ctx, "Agent ID is empty")
|
|||
|
|
return nil, errors.New("agent ID cannot be empty")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get tenant ID from context
|
|||
|
|
tenantID, ok := types.TenantIDFromContext(ctx)
|
|||
|
|
if !ok {
|
|||
|
|
return nil, ErrInvalidTenantID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get the source agent
|
|||
|
|
sourceAgent, err := s.GetAgentByID(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if !assetOwnedByCaller(ctx, sourceAgent.TenantID, sourceAgent.CreatedBy) {
|
|||
|
|
return nil, apperrors.NewForbiddenError("共享 Agent 仅支持查看和使用,不能复制")
|
|||
|
|
}
|
|||
|
|
if guard, ok := s.repo.(interface {
|
|||
|
|
CheckDigitalHumanAgentCopy(context.Context, string, uint64) error
|
|||
|
|
}); ok {
|
|||
|
|
if err := guard.CheckDigitalHumanAgentCopy(ctx, id, tenantID); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Create a new agent with copied data
|
|||
|
|
newAgent := &types.CustomAgent{
|
|||
|
|
ID: uuid.New().String(),
|
|||
|
|
Name: sourceAgent.Name + " (副本)",
|
|||
|
|
Description: sourceAgent.Description,
|
|||
|
|
Avatar: sourceAgent.Avatar,
|
|||
|
|
IsBuiltin: false, // Copied agents are never built-in
|
|||
|
|
TenantID: tenantID,
|
|||
|
|
Config: sourceAgent.Config,
|
|||
|
|
Visibility: "private",
|
|||
|
|
Enabled: true,
|
|||
|
|
CreatedAt: time.Now(),
|
|||
|
|
UpdatedAt: time.Now(),
|
|||
|
|
}
|
|||
|
|
// The clone is owned by whoever ran the copy, not the original
|
|||
|
|
// creator — same reasoning as CopyKnowledgeBase. Skip synthetic
|
|||
|
|
// API-key users.
|
|||
|
|
if uid, ok := types.UserIDFromContext(ctx); ok && !types.IsSyntheticUserID(uid) {
|
|||
|
|
newAgent.CreatedBy = uid
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Ensure defaults
|
|||
|
|
newAgent.EnsureDefaults()
|
|||
|
|
if err := s.validateFusionAgents(ctx, newAgent); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Copying agent, source ID: %s, new ID: %s", id, newAgent.ID)
|
|||
|
|
|
|||
|
|
if err := s.createAgentCopy(ctx, id, newAgent); err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"source_agent_id": id,
|
|||
|
|
"new_agent_id": newAgent.ID,
|
|||
|
|
})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.Infof(ctx, "Agent copied successfully, source ID: %s, new ID: %s", id, newAgent.ID)
|
|||
|
|
return newAgent, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *customAgentService) createAgentCopy(ctx context.Context, sourceID string, agent *types.CustomAgent) error {
|
|||
|
|
if copier, ok := s.repo.(interface {
|
|||
|
|
CreateAgentCopy(context.Context, string, *types.CustomAgent) error
|
|||
|
|
}); ok {
|
|||
|
|
return copier.CreateAgentCopy(ctx, sourceID, agent)
|
|||
|
|
}
|
|||
|
|
return s.repo.CreateAgent(ctx, agent)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetSuggestedQuestions returns suggested questions for the agent based on its
|
|||
|
|
// associated knowledge bases.
|
|||
|
|
func (s *customAgentService) GetSuggestedQuestions(
|
|||
|
|
ctx context.Context,
|
|||
|
|
agentID string,
|
|||
|
|
kbIDs []string,
|
|||
|
|
knowledgeIDs []string,
|
|||
|
|
tagScopes []types.TagScope,
|
|||
|
|
limit int,
|
|||
|
|
) ([]types.SuggestedQuestion, error) {
|
|||
|
|
return s.getSuggestedQuestions(ctx, agentID, kbIDs, knowledgeIDs, tagScopes, limit, true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *customAgentService) GetKnowledgeSuggestedQuestions(
|
|||
|
|
ctx context.Context,
|
|||
|
|
agentID string,
|
|||
|
|
kbIDs []string,
|
|||
|
|
knowledgeIDs []string,
|
|||
|
|
tagScopes []types.TagScope,
|
|||
|
|
limit int,
|
|||
|
|
) ([]types.SuggestedQuestion, error) {
|
|||
|
|
return s.getSuggestedQuestions(ctx, agentID, kbIDs, knowledgeIDs, tagScopes, limit, false)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *customAgentService) getSuggestedQuestions(
|
|||
|
|
ctx context.Context,
|
|||
|
|
agentID string,
|
|||
|
|
kbIDs []string,
|
|||
|
|
knowledgeIDs []string,
|
|||
|
|
tagScopes []types.TagScope,
|
|||
|
|
limit int,
|
|||
|
|
includeCurated bool,
|
|||
|
|
) ([]types.SuggestedQuestion, error) {
|
|||
|
|
// A non-positive limit means "unspecified": fall back to the agent's
|
|||
|
|
// configured Starters.Count (below) or the default. An explicit limit is
|
|||
|
|
// authoritative and only bounded by the safety cap.
|
|||
|
|
limitProvided := limit > 0
|
|||
|
|
if !limitProvided {
|
|||
|
|
limit = suggestionDefaultLimit
|
|||
|
|
}
|
|||
|
|
if limit > suggestionMaxLimit {
|
|||
|
|
limit = suggestionMaxLimit
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := types.AuthorizeTenantAPIKeyKnowledgeTargets(ctx, kbIDs, knowledgeIDs); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
scopeTagIDs := flattenTagScopeIDs(tagScopes)
|
|||
|
|
if err := types.AuthorizeTenantAPIKeyOptionalTagIDs(ctx, scopeTagIDs); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get tenant ID from context
|
|||
|
|
tenantID, ok := types.TenantIDFromContext(ctx)
|
|||
|
|
if !ok {
|
|||
|
|
return nil, ErrInvalidTenantID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get agent configuration
|
|||
|
|
agent, err := s.GetAgentByID(ctx, agentID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if !agent.Enabled {
|
|||
|
|
return nil, apperrors.NewForbiddenError("Agent 已停用,无法生成建议")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var curated []types.SuggestedQuestion
|
|||
|
|
starterMode := types.SuggestionModeKnowledge
|
|||
|
|
|
|||
|
|
if includeCurated {
|
|||
|
|
suggestionConfig := agent.Config.QuestionSuggestions
|
|||
|
|
if suggestionConfig == nil || !suggestionConfig.Starters.Enabled {
|
|||
|
|
return []types.SuggestedQuestion{}, nil
|
|||
|
|
}
|
|||
|
|
// Starters.Count is the agent-author default, applied only when the
|
|||
|
|
// caller did not request a specific limit. An explicit limit stays
|
|||
|
|
// authoritative so ?limit=N actually changes how many are returned.
|
|||
|
|
if !limitProvided && suggestionConfig.Starters.Count > 0 {
|
|||
|
|
limit = suggestionConfig.Starters.Count
|
|||
|
|
}
|
|||
|
|
starterMode = suggestionConfig.Starters.Mode
|
|||
|
|
// Add curated agent prompts first (highest priority).
|
|||
|
|
if suggestionConfig.Starters.Mode == types.SuggestionModeCurated ||
|
|||
|
|
suggestionConfig.Starters.Mode == types.SuggestionModeHybrid {
|
|||
|
|
for _, prompt := range suggestionConfig.Starters.Items {
|
|||
|
|
if strings.TrimSpace(prompt) == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
curated = append(curated, types.SuggestedQuestion{
|
|||
|
|
Question: prompt,
|
|||
|
|
Source: "agent_config",
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if suggestionConfig.Starters.Mode == types.SuggestionModeCurated {
|
|||
|
|
return s.truncateQuestions(curated, limit), nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resolvedTags := resolvedSuggestionTagScopes{}
|
|||
|
|
if len(scopeTagIDs) > 0 {
|
|||
|
|
var err error
|
|||
|
|
resolvedTags, err = s.resolveSuggestionTagScopes(ctx, tenantID, tagScopes)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"agent_id": agentID,
|
|||
|
|
"scope_tag_ids": scopeTagIDs,
|
|||
|
|
})
|
|||
|
|
return finalizeStarterSuggestions(curated, nil, starterMode, limit), nil
|
|||
|
|
}
|
|||
|
|
knowledgeIDs = mergeUniqueStrings(knowledgeIDs, resolvedTags.KnowledgeIDs)
|
|||
|
|
if len(knowledgeIDs) == 0 && len(resolvedTags.TagIDsByTenant) == 0 {
|
|||
|
|
return finalizeStarterSuggestions(curated, nil, starterMode, limit), nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Determine knowledge base scope
|
|||
|
|
effectiveKBIDs := kbIDs
|
|||
|
|
if len(effectiveKBIDs) == 0 && len(knowledgeIDs) == 0 && len(resolvedTags.TagIDsByTenant) == 0 {
|
|||
|
|
// Use agent's KB configuration
|
|||
|
|
switch agent.Config.KBSelectionMode {
|
|||
|
|
case "all":
|
|||
|
|
kbs, err := s.kbService.ListKnowledgeBases(ctx)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"agent_id": agentID,
|
|||
|
|
})
|
|||
|
|
// Return what we have so far (agent_config suggestions)
|
|||
|
|
return finalizeStarterSuggestions(curated, nil, starterMode, limit), nil
|
|||
|
|
}
|
|||
|
|
// Honor the agent's implicit/explicit capability requirements so
|
|||
|
|
// e.g. a quick-answer (RAG-only) agent doesn't surface wiki-only
|
|||
|
|
// KBs whose wiki pages it could never answer from. Same filter
|
|||
|
|
// the @ mention dropdown applies on the frontend.
|
|||
|
|
capFilter := tools.DeriveKBFilterForAgent(agent.Config.AgentMode, agent.Config.AllowedTools)
|
|||
|
|
for _, kb := range kbs {
|
|||
|
|
if !capFilter.IsEmpty() &&
|
|||
|
|
!tools.KBSatisfiesAgentRequirements(kb.Capabilities(), agent.Config.AgentMode, agent.Config.AllowedTools) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
effectiveKBIDs = append(effectiveKBIDs, kb.ID)
|
|||
|
|
}
|
|||
|
|
case "selected":
|
|||
|
|
effectiveKBIDs = agent.Config.KnowledgeBases
|
|||
|
|
case "none":
|
|||
|
|
// No KB access, return agent_config suggestions only
|
|||
|
|
return finalizeStarterSuggestions(curated, nil, starterMode, limit), nil
|
|||
|
|
default:
|
|||
|
|
// Default to agent's configured KBs
|
|||
|
|
effectiveKBIDs = agent.Config.KnowledgeBases
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Match the chat retrieval target semantics: a tag scope narrows its parent
|
|||
|
|
// KB even when that KB is present in the agent's preselected KB list. Other
|
|||
|
|
// explicitly selected KBs remain additive.
|
|||
|
|
effectiveKBIDs = excludeSuggestionStrings(effectiveKBIDs, resolvedTags.KnowledgeBaseIDs)
|
|||
|
|
|
|||
|
|
filteredKBIDs, err := types.FilterKnowledgeBasesForTenantAPIKeyScope(ctx, kbIDs, effectiveKBIDs)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
effectiveKBIDs = filteredKBIDs
|
|||
|
|
|
|||
|
|
if len(effectiveKBIDs) == 0 && len(knowledgeIDs) == 0 && len(resolvedTags.TagIDsByTenant) == 0 {
|
|||
|
|
return finalizeStarterSuggestions(curated, nil, starterMode, limit), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Deduplicate questions we've already collected
|
|||
|
|
seen := make(map[string]bool)
|
|||
|
|
for _, q := range curated {
|
|||
|
|
seen[q.Question] = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
remaining := limit
|
|||
|
|
|
|||
|
|
// 3. Collect candidate chunks from both FAQ and Document KBs,
|
|||
|
|
// grouped by knowledge_id for diversity.
|
|||
|
|
// knowledgeID -> list of questions
|
|||
|
|
buckets := make(map[string][]types.SuggestedQuestion)
|
|||
|
|
|
|||
|
|
// Determine query scope
|
|||
|
|
queryKBIDs := effectiveKBIDs
|
|||
|
|
queryKnowledgeIDs := knowledgeIDs
|
|||
|
|
|
|||
|
|
// Fetch a large pool so DB-level random sampling covers multiple documents.
|
|||
|
|
fetchLimit := remaining * 5
|
|||
|
|
if fetchLimit < 20 {
|
|||
|
|
fetchLimit = 20
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Resolve each KB to the tenant whose chunks should be queried. Cross-tenant
|
|||
|
|
// KBs reached via an organization share map to the source tenant ID; the chunk
|
|||
|
|
// rows live under that tenant. Without this grouping a caller in tenant A
|
|||
|
|
// querying a KB shared from tenant B would hit `tenant_id = A` and get zero
|
|||
|
|
// rows back — the symptom is "suggested questions never appear for shared KBs".
|
|||
|
|
scopeKBIDs := mergeUniqueStrings(queryKBIDs, resolvedTags.KnowledgeBaseIDs)
|
|||
|
|
kbGroups := s.groupKBIDsByEffectiveTenant(ctx, tenantID, scopeKBIDs)
|
|||
|
|
knowledgeGroups := s.groupSuggestionKnowledgeByTenant(ctx, tenantID, queryKnowledgeIDs)
|
|||
|
|
for groupTenantID := range knowledgeGroups {
|
|||
|
|
if _, ok := kbGroups[groupTenantID]; !ok {
|
|||
|
|
kbGroups[groupTenantID] = nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Collect FAQ recommended chunks
|
|||
|
|
for groupTenantID, groupKBIDs := range kbGroups {
|
|||
|
|
explicitGroupKBIDs := intersectSuggestionStrings(groupKBIDs, queryKBIDs)
|
|||
|
|
groupTagIDs := resolvedTags.TagIDsByTenant[groupTenantID]
|
|||
|
|
faqChunks, err := s.chunkRepo.ListRecommendedFAQChunks(
|
|||
|
|
ctx, groupTenantID, explicitGroupKBIDs, knowledgeGroups[groupTenantID], groupTagIDs, fetchLimit,
|
|||
|
|
)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"agent_id": agentID,
|
|||
|
|
"tenant_id": groupTenantID,
|
|||
|
|
})
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
for _, chunk := range faqChunks {
|
|||
|
|
meta, err := chunk.FAQMetadata()
|
|||
|
|
if err != nil || meta == nil || meta.StandardQuestion == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if seen[meta.StandardQuestion] {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
seen[meta.StandardQuestion] = true
|
|||
|
|
buckets[chunk.KnowledgeID] = append(buckets[chunk.KnowledgeID], types.SuggestedQuestion{
|
|||
|
|
Question: meta.StandardQuestion,
|
|||
|
|
Source: "faq",
|
|||
|
|
KnowledgeBaseID: chunk.KnowledgeBaseID,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Collect Document chunks with generated questions
|
|||
|
|
for groupTenantID, groupKBIDs := range kbGroups {
|
|||
|
|
explicitGroupKBIDs := intersectSuggestionStrings(groupKBIDs, queryKBIDs)
|
|||
|
|
docChunks, err := s.chunkRepo.ListRecentDocumentChunksWithQuestions(ctx, groupTenantID, explicitGroupKBIDs, knowledgeGroups[groupTenantID], fetchLimit)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"agent_id": agentID,
|
|||
|
|
"tenant_id": groupTenantID,
|
|||
|
|
})
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
for _, chunk := range docChunks {
|
|||
|
|
meta, err := chunk.DocumentMetadata()
|
|||
|
|
if err != nil || meta == nil || len(meta.GeneratedQuestions) == 0 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
questions := meta.GetQuestionStrings()
|
|||
|
|
if len(questions) == 0 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
q := questions[0]
|
|||
|
|
if q == "" || seen[q] {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
seen[q] = true
|
|||
|
|
buckets[chunk.KnowledgeID] = append(buckets[chunk.KnowledgeID], types.SuggestedQuestion{
|
|||
|
|
Question: q,
|
|||
|
|
Source: "document",
|
|||
|
|
KnowledgeBaseID: chunk.KnowledgeBaseID,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Collect Wiki pages as a fallback source, but only for KBs the caller selected
|
|||
|
|
// explicitly. A tag's parent KB is merely an ownership boundary; widening a
|
|||
|
|
// tag-only scope to arbitrary Wiki pages would make the suggestions unanswerable
|
|||
|
|
// inside the user's selected range.
|
|||
|
|
//
|
|||
|
|
// Skip entirely for quick-answer (RAG-only) agents: those can't ever
|
|||
|
|
// retrieve a wiki page, so surfacing wiki-derived suggestions would lure
|
|||
|
|
// the user into asking questions the agent will then answer with empty
|
|||
|
|
// context. Smart-reasoning agents that opt in to wiki tools keep this.
|
|||
|
|
if agent.Config.AgentMode != types.AgentModeQuickAnswer && s.wikiPageRepo != nil {
|
|||
|
|
for groupTenantID, groupKBIDs := range kbGroups {
|
|||
|
|
explicitGroupKBIDs := intersectSuggestionStrings(groupKBIDs, queryKBIDs)
|
|||
|
|
if len(explicitGroupKBIDs) == 0 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
wikiPages, err := s.wikiPageRepo.ListRecentForSuggestions(ctx, groupTenantID, explicitGroupKBIDs, fetchLimit)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"agent_id": agentID,
|
|||
|
|
"tenant_id": groupTenantID,
|
|||
|
|
})
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
locale := types.LanguageFromContextOrDefault(ctx)
|
|||
|
|
for _, page := range wikiPages {
|
|||
|
|
q := wikiSuggestionFromPage(page, locale)
|
|||
|
|
if q == "" || seen[q] {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
seen[q] = true
|
|||
|
|
// Use page.ID as the bucket key so round-robin mixes pages from
|
|||
|
|
// different wiki entries rather than clumping them.
|
|||
|
|
buckets[page.ID] = append(buckets[page.ID], types.SuggestedQuestion{
|
|||
|
|
Question: q,
|
|||
|
|
Source: "wiki",
|
|||
|
|
KnowledgeBaseID: page.KnowledgeBaseID,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. Shuffle within each bucket, then round-robin across buckets
|
|||
|
|
// to ensure diversity across different documents.
|
|||
|
|
bucketKeys := make([]string, 0, len(buckets))
|
|||
|
|
for k, qs := range buckets {
|
|||
|
|
bucketKeys = append(bucketKeys, k)
|
|||
|
|
rand.Shuffle(len(qs), func(i, j int) { qs[i], qs[j] = qs[j], qs[i] })
|
|||
|
|
buckets[k] = qs
|
|||
|
|
}
|
|||
|
|
rand.Shuffle(len(bucketKeys), func(i, j int) {
|
|||
|
|
bucketKeys[i], bucketKeys[j] = bucketKeys[j], bucketKeys[i]
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Round-robin pick one question from each document in turn.
|
|||
|
|
knowledgeResult := make([]types.SuggestedQuestion, 0, limit)
|
|||
|
|
offsets := make(map[string]int, len(bucketKeys))
|
|||
|
|
for len(knowledgeResult) < limit {
|
|||
|
|
picked := false
|
|||
|
|
for _, key := range bucketKeys {
|
|||
|
|
if len(knowledgeResult) >= limit {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
qs := buckets[key]
|
|||
|
|
idx := offsets[key]
|
|||
|
|
if idx < len(qs) {
|
|||
|
|
knowledgeResult = append(knowledgeResult, qs[idx])
|
|||
|
|
offsets[key] = idx + 1
|
|||
|
|
picked = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !picked {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return finalizeStarterSuggestions(curated, knowledgeResult, starterMode, limit), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type resolvedSuggestionTagScopes struct {
|
|||
|
|
KnowledgeBaseIDs []string
|
|||
|
|
KnowledgeIDs []string
|
|||
|
|
TagIDsByTenant map[uint64][]string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// resolveSuggestionTagScopes keeps tag ownership separate from whole-KB
|
|||
|
|
// selection. Document tags become concrete knowledge IDs; FAQ tags remain
|
|||
|
|
// chunk tag filters. Scoped inputs also let shared-KB tags resolve against the
|
|||
|
|
// source tenant that owns the tag and chunk rows.
|
|||
|
|
func (s *customAgentService) resolveSuggestionTagScopes(
|
|||
|
|
ctx context.Context,
|
|||
|
|
callerTenantID uint64,
|
|||
|
|
tagScopes []types.TagScope,
|
|||
|
|
) (resolvedSuggestionTagScopes, error) {
|
|||
|
|
result := resolvedSuggestionTagScopes{TagIDsByTenant: make(map[uint64][]string)}
|
|||
|
|
if len(tagScopes) == 0 || s.tagRepo == nil || s.knowledgeRepo == nil || s.kbService == nil {
|
|||
|
|
return result, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
byKB := make(map[string][]string)
|
|||
|
|
for _, scope := range tagScopes {
|
|||
|
|
if scope.KnowledgeBaseID == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
for _, tagID := range scope.TagIDs {
|
|||
|
|
if tagID == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
byKB[scope.KnowledgeBaseID] = append(byKB[scope.KnowledgeBaseID], tagID)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if len(byKB) == 0 {
|
|||
|
|
return result, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
kbIDs := make([]string, 0, len(byKB))
|
|||
|
|
for kbID := range byKB {
|
|||
|
|
kbIDs = append(kbIDs, kbID)
|
|||
|
|
}
|
|||
|
|
kbGroups := s.groupKBIDsByEffectiveTenant(ctx, callerTenantID, kbIDs)
|
|||
|
|
for tenantID, groupKBIDs := range kbGroups {
|
|||
|
|
for _, kbID := range groupKBIDs {
|
|||
|
|
requested := mergeUniqueStrings(nil, byKB[kbID])
|
|||
|
|
tags, err := s.tagRepo.GetByIDs(ctx, tenantID, requested)
|
|||
|
|
if err != nil {
|
|||
|
|
return result, err
|
|||
|
|
}
|
|||
|
|
requestedSet := make(map[string]bool, len(requested))
|
|||
|
|
for _, id := range requested {
|
|||
|
|
requestedSet[id] = true
|
|||
|
|
}
|
|||
|
|
validTagIDs := make([]string, 0, len(tags))
|
|||
|
|
for _, tag := range tags {
|
|||
|
|
if tag != nil && tag.KnowledgeBaseID == kbID && requestedSet[tag.ID] {
|
|||
|
|
validTagIDs = append(validTagIDs, tag.ID)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if len(validTagIDs) == 0 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result.KnowledgeBaseIDs = mergeUniqueStrings(result.KnowledgeBaseIDs, []string{kbID})
|
|||
|
|
result.TagIDsByTenant[tenantID] = mergeUniqueStrings(result.TagIDsByTenant[tenantID], validTagIDs)
|
|||
|
|
knowledgeIDs, err := s.knowledgeRepo.ListIDsByTagIDs(ctx, tenantID, kbID, validTagIDs)
|
|||
|
|
if err != nil {
|
|||
|
|
return result, err
|
|||
|
|
}
|
|||
|
|
result.KnowledgeIDs = mergeUniqueStrings(result.KnowledgeIDs, knowledgeIDs)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return result, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func flattenTagScopeIDs(scopes []types.TagScope) []string {
|
|||
|
|
var ids []string
|
|||
|
|
for _, scope := range scopes {
|
|||
|
|
ids = mergeUniqueStrings(ids, scope.TagIDs)
|
|||
|
|
}
|
|||
|
|
return ids
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func intersectSuggestionStrings(values, allowed []string) []string {
|
|||
|
|
if len(values) == 0 || len(allowed) == 0 {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
allowedSet := make(map[string]bool, len(allowed))
|
|||
|
|
for _, value := range allowed {
|
|||
|
|
allowedSet[value] = true
|
|||
|
|
}
|
|||
|
|
var result []string
|
|||
|
|
for _, value := range values {
|
|||
|
|
if value != "" && allowedSet[value] {
|
|||
|
|
result = append(result, value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func excludeSuggestionStrings(values, excluded []string) []string {
|
|||
|
|
if len(values) == 0 || len(excluded) == 0 {
|
|||
|
|
return values
|
|||
|
|
}
|
|||
|
|
excludedSet := make(map[string]bool, len(excluded))
|
|||
|
|
for _, value := range excluded {
|
|||
|
|
excludedSet[value] = true
|
|||
|
|
}
|
|||
|
|
result := make([]string, 0, len(values))
|
|||
|
|
for _, value := range values {
|
|||
|
|
if value != "" && !excludedSet[value] {
|
|||
|
|
result = append(result, value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func finalizeStarterSuggestions(
|
|||
|
|
curated []types.SuggestedQuestion,
|
|||
|
|
knowledge []types.SuggestedQuestion,
|
|||
|
|
mode string,
|
|||
|
|
limit int,
|
|||
|
|
) []types.SuggestedQuestion {
|
|||
|
|
if limit <= 0 {
|
|||
|
|
return []types.SuggestedQuestion{}
|
|||
|
|
}
|
|||
|
|
switch mode {
|
|||
|
|
case types.SuggestionModeCurated:
|
|||
|
|
return truncateSuggestedQuestions(curated, limit)
|
|||
|
|
case types.SuggestionModeHybrid:
|
|||
|
|
return mergeHybridStarterSuggestions(curated, knowledge, limit)
|
|||
|
|
default:
|
|||
|
|
return truncateSuggestedQuestions(knowledge, limit)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// mergeHybridStarterSuggestions prioritizes curated starters while reserving
|
|||
|
|
// about one third of visible slots for scope-aware knowledge questions.
|
|||
|
|
func mergeHybridStarterSuggestions(
|
|||
|
|
curated []types.SuggestedQuestion,
|
|||
|
|
knowledge []types.SuggestedQuestion,
|
|||
|
|
limit int,
|
|||
|
|
) []types.SuggestedQuestion {
|
|||
|
|
if limit <= 0 {
|
|||
|
|
return []types.SuggestedQuestion{}
|
|||
|
|
}
|
|||
|
|
knowledgeSlots := 0
|
|||
|
|
if limit > 1 {
|
|||
|
|
knowledgeSlots = (limit + 1) / 3
|
|||
|
|
}
|
|||
|
|
curatedSlots := limit - knowledgeSlots
|
|||
|
|
result := make([]types.SuggestedQuestion, 0, limit)
|
|||
|
|
seen := make(map[string]bool, limit)
|
|||
|
|
appendFrom := func(items []types.SuggestedQuestion, max int) {
|
|||
|
|
added := 0
|
|||
|
|
for _, item := range items {
|
|||
|
|
if len(result) == limit || (max >= 0 && added == max) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
key := strings.ToLower(strings.TrimSpace(item.Question))
|
|||
|
|
if key == "" || seen[key] {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
seen[key] = true
|
|||
|
|
result = append(result, item)
|
|||
|
|
added++
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
appendFrom(curated, curatedSlots)
|
|||
|
|
appendFrom(knowledge, knowledgeSlots)
|
|||
|
|
appendFrom(curated, -1)
|
|||
|
|
appendFrom(knowledge, -1)
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func truncateSuggestedQuestions(questions []types.SuggestedQuestion, limit int) []types.SuggestedQuestion {
|
|||
|
|
if len(questions) > limit {
|
|||
|
|
return questions[:limit]
|
|||
|
|
}
|
|||
|
|
return questions
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func mergeUniqueStrings(base, extra []string) []string {
|
|||
|
|
if len(extra) == 0 {
|
|||
|
|
return base
|
|||
|
|
}
|
|||
|
|
seen := make(map[string]bool, len(base)+len(extra))
|
|||
|
|
out := make([]string, 0, len(base)+len(extra))
|
|||
|
|
for _, s := range base {
|
|||
|
|
if s == "" || seen[s] {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
seen[s] = true
|
|||
|
|
out = append(out, s)
|
|||
|
|
}
|
|||
|
|
for _, s := range extra {
|
|||
|
|
if s == "" || seen[s] {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
seen[s] = true
|
|||
|
|
out = append(out, s)
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// truncateQuestions truncates the question list to the specified limit
|
|||
|
|
func (s *customAgentService) truncateQuestions(questions []types.SuggestedQuestion, limit int) []types.SuggestedQuestion {
|
|||
|
|
if len(questions) > limit {
|
|||
|
|
return questions[:limit]
|
|||
|
|
}
|
|||
|
|
return questions
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// wikiSuggestionFromPage converts a wiki page into a human-readable suggested
|
|||
|
|
// question string. The template is chosen per page type so the chip reads
|
|||
|
|
// naturally for that kind of content:
|
|||
|
|
// - concept: "What is <title>?" works for abstract terms (RAG, embedding,
|
|||
|
|
// idempotency…).
|
|||
|
|
// - entity / summary: "Tell me about <title>" is neutral and works for
|
|||
|
|
// people, places, organizations, products and document summaries where
|
|||
|
|
// "what is <name>?" would read awkwardly ("什么是张三?").
|
|||
|
|
// - everything else (synthesis, comparison, …): the raw title is already a
|
|||
|
|
// good topical query on its own.
|
|||
|
|
func wikiSuggestionFromPage(page *types.WikiPage, locale string) string {
|
|||
|
|
if page == nil {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
title := strings.TrimSpace(page.Title)
|
|||
|
|
if title == "" {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
switch page.PageType {
|
|||
|
|
case types.WikiPageTypeConcept:
|
|||
|
|
if isEnglishLocale(locale) {
|
|||
|
|
return "What is " + title + "?"
|
|||
|
|
}
|
|||
|
|
return "什么是" + title + "?"
|
|||
|
|
case types.WikiPageTypeEntity, types.WikiPageTypeSummary:
|
|||
|
|
if isEnglishLocale(locale) {
|
|||
|
|
return "Tell me about " + title
|
|||
|
|
}
|
|||
|
|
return "介绍一下" + title
|
|||
|
|
default:
|
|||
|
|
return title
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// groupKBIDsByEffectiveTenant resolves each kbID to the tenant whose chunk
|
|||
|
|
// rows back that KB, so cross-tenant shares can be queried correctly:
|
|||
|
|
// - In-tenant KBs map to the caller's tenant id.
|
|||
|
|
// - KBs owned by another tenant are included only if the caller's tenant
|
|||
|
|
// has at least Viewer access via an organization share, in which case
|
|||
|
|
// the KB maps to its source tenant id (where the chunks actually live).
|
|||
|
|
// - KBs the caller cannot reach (no membership, no share) are silently
|
|||
|
|
// dropped — the suggestion endpoint never returns 403, it just shows
|
|||
|
|
// nothing for that KB, mirroring how search results are scoped.
|
|||
|
|
//
|
|||
|
|
// The result is keyed by effective tenant id so the caller can issue one
|
|||
|
|
// chunk / wiki query per tenant group. Returns an empty (non-nil) map when
|
|||
|
|
// kbIDs is empty.
|
|||
|
|
func (s *customAgentService) groupKBIDsByEffectiveTenant(
|
|||
|
|
ctx context.Context,
|
|||
|
|
callerTenantID uint64,
|
|||
|
|
kbIDs []string,
|
|||
|
|
) map[uint64][]string {
|
|||
|
|
out := make(map[uint64][]string)
|
|||
|
|
if len(kbIDs) == 0 {
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
kbs, err := s.kbService.GetKnowledgeBasesByIDsOnly(ctx, kbIDs)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
|||
|
|
"kb_ids": kbIDs,
|
|||
|
|
})
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
kbByID := make(map[string]*types.KnowledgeBase, len(kbs))
|
|||
|
|
for _, kb := range kbs {
|
|||
|
|
if kb != nil {
|
|||
|
|
kbByID[kb.ID] = kb
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
callerRole := types.TenantRoleFromContext(ctx)
|
|||
|
|
for _, kbID := range kbIDs {
|
|||
|
|
kb := kbByID[kbID]
|
|||
|
|
if kb == nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if kb.TenantID == callerTenantID {
|
|||
|
|
out[callerTenantID] = append(out[callerTenantID], kbID)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if s.kbShareService == nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
ok, err := s.kbShareService.HasTenantKBPermission(ctx, kbID, callerTenantID, callerRole, types.OrgRoleViewer)
|
|||
|
|
if err != nil || !ok {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
out[kb.TenantID] = append(out[kb.TenantID], kbID)
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Document IDs are a separate OR branch in suggestion queries, so each must
|
|||
|
|
// pass its parent KB's current access policy before reaching the chunk store.
|
|||
|
|
func (s *customAgentService) groupSuggestionKnowledgeByTenant(ctx context.Context, callerTenantID uint64, ids []string) map[uint64][]string {
|
|||
|
|
out := make(map[uint64][]string)
|
|||
|
|
if len(ids) == 0 || s.knowledgeRepo == nil || s.kbService == nil {
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
documents := make([]*types.Knowledge, 0, len(ids))
|
|||
|
|
var kbIDs []string
|
|||
|
|
for _, id := range mergeUniqueStrings(nil, ids) {
|
|||
|
|
doc, err := s.knowledgeRepo.GetKnowledgeByIDOnly(ctx, id)
|
|||
|
|
if err != nil || doc == nil || doc.DeletedAt.Valid {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
documents = append(documents, doc)
|
|||
|
|
kbIDs = mergeUniqueStrings(kbIDs, []string{doc.KnowledgeBaseID})
|
|||
|
|
}
|
|||
|
|
groups := s.groupKBIDsByEffectiveTenant(ctx, callerTenantID, kbIDs)
|
|||
|
|
for _, doc := range documents {
|
|||
|
|
for _, kbID := range groups[doc.TenantID] {
|
|||
|
|
if kbID == doc.KnowledgeBaseID {
|
|||
|
|
out[doc.TenantID] = append(out[doc.TenantID], doc.ID)
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// isEnglishLocale reports whether the locale string is an English variant.
|
|||
|
|
// Unknown / empty locales fall back to Chinese, matching the product default.
|
|||
|
|
func isEnglishLocale(locale string) bool {
|
|||
|
|
switch locale {
|
|||
|
|
case "en-US", "en", "en-GB":
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|