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
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
|
||
}
|