chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
33
internal/services/agent_evaluation_service.go
Normal file
33
internal/services/agent_evaluation_service.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
)
|
||||
|
||||
var AgentEvaluationService = newAgentEvaluationService()
|
||||
var AgentEvaluationRunHook func(context.Context, request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error)
|
||||
|
||||
type agentEvaluationService struct{}
|
||||
|
||||
func newAgentEvaluationService() *agentEvaluationService { return &agentEvaluationService{} }
|
||||
|
||||
func (s *agentEvaluationService) Run(ctx context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
|
||||
if req.AIAgentID <= 0 {
|
||||
return nil, errorsx.InvalidParam("ai agent id is required")
|
||||
}
|
||||
if len(req.Cases) == 0 {
|
||||
return nil, errorsx.InvalidParam("evaluation cases are required")
|
||||
}
|
||||
if len(req.Cases) > 100 {
|
||||
return nil, errorsx.InvalidParam("evaluation case limit exceeded")
|
||||
}
|
||||
if AgentEvaluationRunHook == nil {
|
||||
return nil, fmt.Errorf("agent evaluation runner is not initialized")
|
||||
}
|
||||
return AgentEvaluationRunHook(ctx, req)
|
||||
}
|
||||
26
internal/services/agent_evaluation_service_test.go
Normal file
26
internal/services/agent_evaluation_service_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
)
|
||||
|
||||
func TestAgentEvaluationServiceValidatesAndCallsRunner(t *testing.T) {
|
||||
previous := AgentEvaluationRunHook
|
||||
t.Cleanup(func() { AgentEvaluationRunHook = previous })
|
||||
called := false
|
||||
AgentEvaluationRunHook = func(_ context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
|
||||
called = true
|
||||
return &response.AgentEvaluationReportResponse{Total: len(req.Cases)}, nil
|
||||
}
|
||||
result, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{AIAgentID: 1, Cases: []request.AgentEvaluationCase{{ID: "faq", Message: "hello"}}})
|
||||
if err != nil || !called || result.Total != 1 {
|
||||
t.Fatalf("result=%#v called=%t err=%v", result, called, err)
|
||||
}
|
||||
if _, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{}); err == nil {
|
||||
t.Fatal("expected invalid request")
|
||||
}
|
||||
}
|
||||
207
internal/services/agent_profile_service.go
Normal file
207
internal/services/agent_profile_service.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var AgentProfileService = newAgentProfileService()
|
||||
|
||||
func newAgentProfileService() *agentProfileService {
|
||||
return &agentProfileService{}
|
||||
}
|
||||
|
||||
type agentProfileService struct {
|
||||
}
|
||||
|
||||
func (s *agentProfileService) Get(id int64) *models.AgentProfile {
|
||||
return repositories.AgentProfileRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) Take(where ...interface{}) *models.AgentProfile {
|
||||
return repositories.AgentProfileRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) Find(cnd *sqls.Cnd) []models.AgentProfile {
|
||||
return repositories.AgentProfileRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) FindOne(cnd *sqls.Cnd) *models.AgentProfile {
|
||||
return repositories.AgentProfileRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) FindPageByParams(params *params.QueryParams) (list []models.AgentProfile, paging *sqls.Paging) {
|
||||
return repositories.AgentProfileRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AgentProfile, paging *sqls.Paging) {
|
||||
return repositories.AgentProfileRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.AgentProfileRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) GetByUserID(userID int64) *models.AgentProfile {
|
||||
if userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AgentProfileRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("user_id", userID))
|
||||
}
|
||||
|
||||
func (s *agentProfileService) GetUserIDsByTeamID(teamID int64) []int64 {
|
||||
if teamID <= 0 {
|
||||
return nil
|
||||
}
|
||||
list := s.Find(sqls.NewCnd().Eq("team_id", teamID))
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item.UserID > 0 {
|
||||
result = append(result, item.UserID)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetDispatchAgents 获取可用于分配会话的客服
|
||||
func (s *agentProfileService) GetDispatchAgents(teamIds []int64) []models.AgentProfile {
|
||||
return AgentProfileService.Find(sqls.NewCnd().
|
||||
In("team_id", teamIds).
|
||||
Eq("status", enums.StatusOk).
|
||||
Eq("auto_assign_enabled", true).
|
||||
Eq("service_status", enums.ServiceStatusIdle))
|
||||
}
|
||||
|
||||
func (s *agentProfileService) CreateAgentProfile(req request.CreateAgentProfileRequest, operator *dto.AuthPrincipal) (*models.AgentProfile, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
item, err := s.buildProfileModel(0, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := repositories.AgentProfileRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.dispatchPendingConversationsIfEligible(item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *agentProfileService) UpdateAgentProfile(req request.UpdateAgentProfileRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
current := s.Get(req.ID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0164")
|
||||
}
|
||||
item, err := s.buildProfileModel(req.ID, req.CreateAgentProfileRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repositories.AgentProfileRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"user_id": item.UserID,
|
||||
"team_id": item.TeamID,
|
||||
"agent_code": item.AgentCode,
|
||||
"display_name": item.DisplayName,
|
||||
"avatar": item.Avatar,
|
||||
"service_status": item.ServiceStatus,
|
||||
"max_concurrent_count": item.MaxConcurrentCount,
|
||||
"priority_level": item.PriorityLevel,
|
||||
"auto_assign_enabled": item.AutoAssignEnabled,
|
||||
"receive_offline_message": item.ReceiveOfflineMessage,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.dispatchPendingConversationsIfEligible(item)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentProfileService) DeleteAgentProfile(id int64) error {
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0164")
|
||||
}
|
||||
repositories.AgentProfileRepository.Delete(sqls.DB(), id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentProfileService) buildProfileModel(id int64, req request.CreateAgentProfileRequest) (*models.AgentProfile, error) {
|
||||
if req.UserID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0325")
|
||||
}
|
||||
if UserService.Get(req.UserID) == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0127")
|
||||
}
|
||||
if req.TeamID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0328")
|
||||
}
|
||||
if AgentTeamService.Get(req.TeamID) == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0205")
|
||||
}
|
||||
req.AgentCode = strings.TrimSpace(req.AgentCode)
|
||||
req.DisplayName = strings.TrimSpace(req.DisplayName)
|
||||
if req.AgentCode == "" || req.DisplayName == "" {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0162")
|
||||
}
|
||||
if exists := s.Take("user_id = ? AND id <> ?", req.UserID, id); exists != nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0314")
|
||||
}
|
||||
if exists := s.Take("agent_code = ? AND id <> ?", req.AgentCode, id); exists != nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0163")
|
||||
}
|
||||
if !enums.IsValidServiceStatus(req.ServiceStatus) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0165")
|
||||
}
|
||||
if req.MaxConcurrentCount < 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0229")
|
||||
}
|
||||
return &models.AgentProfile{
|
||||
UserID: req.UserID,
|
||||
TeamID: req.TeamID,
|
||||
AgentCode: req.AgentCode,
|
||||
DisplayName: req.DisplayName,
|
||||
Avatar: strings.TrimSpace(req.Avatar),
|
||||
ServiceStatus: req.ServiceStatus,
|
||||
MaxConcurrentCount: req.MaxConcurrentCount,
|
||||
PriorityLevel: req.PriorityLevel,
|
||||
AutoAssignEnabled: req.AutoAssignEnabled,
|
||||
ReceiveOfflineMessage: req.ReceiveOfflineMessage,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *agentProfileService) dispatchPendingConversationsIfEligible(item *models.AgentProfile) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
if item.Status != enums.StatusOk {
|
||||
return
|
||||
}
|
||||
if !item.AutoAssignEnabled || item.MaxConcurrentCount <= 0 {
|
||||
return
|
||||
}
|
||||
if item.ServiceStatus != enums.ServiceStatusIdle {
|
||||
return
|
||||
}
|
||||
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
|
||||
}
|
||||
225
internal/services/agent_revision_service.go
Normal file
225
internal/services/agent_revision_service.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var AgentRevisionService = newAgentRevisionService()
|
||||
|
||||
func newAgentRevisionService() *agentRevisionService {
|
||||
return &agentRevisionService{}
|
||||
}
|
||||
|
||||
type agentRevisionService struct{}
|
||||
|
||||
func (s *agentRevisionService) Get(id int64) *models.AgentRevision {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AgentRevisionRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentRevisionService) FindByAgentID(agentID int64) []models.AgentRevision {
|
||||
return repositories.AgentRevisionRepository.FindByAgentID(sqls.DB(), agentID)
|
||||
}
|
||||
|
||||
type agentRevisionDefinition struct {
|
||||
Agent agentRevisionAgent `json:"agent"`
|
||||
Model agentRevisionModel `json:"model"`
|
||||
WorkflowBindings []AgentRevisionWorkflowBinding `json:"workflowBindings"`
|
||||
}
|
||||
|
||||
type AgentRevisionWorkflowBinding struct {
|
||||
WorkflowID int64 `json:"workflowId"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
ToolName string `json:"toolName"`
|
||||
TriggerInstruction string `json:"triggerInstruction"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// agentRevisionModel deliberately excludes APIKey. A revision must capture
|
||||
// reproducible routing/model parameters without duplicating credentials.
|
||||
type agentRevisionModel struct {
|
||||
ConfigID int64 `json:"configId"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
ModelType string `json:"modelType"`
|
||||
ModelName string `json:"modelName"`
|
||||
MaxContextTokens int `json:"maxContextTokens"`
|
||||
MaxOutputTokens int `json:"maxOutputTokens"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
MaxRetryCount int `json:"maxRetryCount"`
|
||||
}
|
||||
|
||||
type agentRevisionAgent struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AIConfigID int64 `json:"aiConfigId"`
|
||||
MaxSteps int `json:"maxSteps"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
ToolPolicy string `json:"toolPolicy"`
|
||||
KnowledgePolicy string `json:"knowledgePolicy"`
|
||||
ServiceMode int `json:"serviceMode"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
WelcomeMessage string `json:"welcomeMessage"`
|
||||
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
|
||||
TeamIDs string `json:"teamIds"`
|
||||
HandoffMode int `json:"handoffMode"`
|
||||
FallbackMode int `json:"fallbackMode"`
|
||||
FallbackMessage string `json:"fallbackMessage"`
|
||||
KnowledgeIDs string `json:"knowledgeIds"`
|
||||
SkillIDs string `json:"skillIds"`
|
||||
AllowedMCPTools string `json:"allowedMcpTools"`
|
||||
AutoHandoffOnFailure *bool `json:"autoHandoffOnFailure,omitempty"`
|
||||
HandoffMessage string `json:"handoffMessage,omitempty"`
|
||||
OffHoursMessage string `json:"offHoursMessage,omitempty"`
|
||||
}
|
||||
|
||||
// AgentRevisionSnapshot is the immutable runtime configuration restored from
|
||||
// a published revision. Model credentials deliberately remain on the current
|
||||
// AIConfig so credential rotation does not require republishing every Agent.
|
||||
type AgentRevisionSnapshot struct {
|
||||
Revision models.AgentRevision
|
||||
Agent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
WorkflowBindings []AgentRevisionWorkflowBinding
|
||||
}
|
||||
|
||||
// ResolvePublishedSnapshot restores an immutable published Agent revision.
|
||||
func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, config models.AIConfig) (*AgentRevisionSnapshot, error) {
|
||||
if agent.PublishedRevisionID <= 0 {
|
||||
return nil, errorsx.InvalidParam("Agent is not published")
|
||||
}
|
||||
revision := repositories.AgentRevisionRepository.Get(sqls.DB(), agent.PublishedRevisionID)
|
||||
if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("published Agent revision does not exist")
|
||||
}
|
||||
snapshot := &AgentRevisionSnapshot{Revision: *revision, Agent: agent, AIConfig: config}
|
||||
if strings.TrimSpace(revision.Definition) == "" {
|
||||
return nil, errorsx.InvalidParam("published Agent revision definition is empty")
|
||||
}
|
||||
definition := agentRevisionDefinition{}
|
||||
if err := json.Unmarshal([]byte(revision.Definition), &definition); err != nil {
|
||||
return nil, errorsx.InvalidParam("published Agent revision is invalid")
|
||||
}
|
||||
publishedConfigID := definition.Agent.AIConfigID
|
||||
if publishedConfigID <= 0 {
|
||||
publishedConfigID = definition.Model.ConfigID
|
||||
}
|
||||
if publishedConfigID > 0 && publishedConfigID != config.ID {
|
||||
publishedConfig := repositories.AIConfigRepository.Get(sqls.DB(), publishedConfigID)
|
||||
if publishedConfig == nil || publishedConfig.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("published agent model config is unavailable")
|
||||
}
|
||||
snapshot.AIConfig = *publishedConfig
|
||||
}
|
||||
applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent)
|
||||
snapshot.WorkflowBindings = append([]AgentRevisionWorkflowBinding(nil), definition.WorkflowBindings...)
|
||||
applyRevisionModelSnapshot(&snapshot.AIConfig, definition.Model)
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func applyRevisionAgentSnapshot(agent *models.AIAgent, definition agentRevisionAgent) {
|
||||
if agent == nil {
|
||||
return
|
||||
}
|
||||
agent.Name = definition.Name
|
||||
agent.Description = definition.Description
|
||||
agent.AIConfigID = definition.AIConfigID
|
||||
agent.MaxSteps = definition.MaxSteps
|
||||
agent.ContextWindow = definition.ContextWindow
|
||||
agent.ToolPolicy = definition.ToolPolicy
|
||||
agent.KnowledgePolicy = definition.KnowledgePolicy
|
||||
agent.ServiceMode = enums.IMConversationServiceMode(definition.ServiceMode)
|
||||
agent.SystemPrompt = definition.SystemPrompt
|
||||
agent.WelcomeMessage = definition.WelcomeMessage
|
||||
agent.ReplyTimeoutSeconds = definition.ReplyTimeoutSeconds
|
||||
agent.TeamIDs = definition.TeamIDs
|
||||
agent.HandoffMode = enums.AIAgentHandoffMode(definition.HandoffMode)
|
||||
agent.FallbackMode = enums.AIAgentFallbackMode(definition.FallbackMode)
|
||||
agent.FallbackMessage = definition.FallbackMessage
|
||||
agent.KnowledgeIDs = definition.KnowledgeIDs
|
||||
agent.SkillIDs = definition.SkillIDs
|
||||
agent.AllowedMCPTools = definition.AllowedMCPTools
|
||||
if definition.AutoHandoffOnFailure != nil {
|
||||
agent.AutoHandoffOnFailure = *definition.AutoHandoffOnFailure
|
||||
} else {
|
||||
agent.AutoHandoffOnFailure = true
|
||||
}
|
||||
agent.HandoffMessage = definition.HandoffMessage
|
||||
agent.OffHoursMessage = definition.OffHoursMessage
|
||||
}
|
||||
|
||||
func applyRevisionModelSnapshot(config *models.AIConfig, definition agentRevisionModel) {
|
||||
if config == nil || definition.ConfigID <= 0 {
|
||||
return
|
||||
}
|
||||
config.Provider = enums.AIProvider(definition.Provider)
|
||||
config.BaseURL = definition.BaseURL
|
||||
config.ModelType = enums.AIModelType(definition.ModelType)
|
||||
config.ModelName = definition.ModelName
|
||||
config.MaxContextTokens = definition.MaxContextTokens
|
||||
config.MaxOutputTokens = definition.MaxOutputTokens
|
||||
config.TimeoutMS = definition.TimeoutMS
|
||||
config.MaxRetryCount = definition.MaxRetryCount
|
||||
}
|
||||
|
||||
func (s *agentRevisionService) PublishSnapshot(db *gorm.DB, agent *models.AIAgent, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
|
||||
return s.publishSnapshot(db, agent, operator)
|
||||
}
|
||||
|
||||
func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgent, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
|
||||
model := agentRevisionModel{ConfigID: agent.AIConfigID}
|
||||
if config := repositories.AIConfigRepository.Get(db, agent.AIConfigID); config != nil {
|
||||
model = agentRevisionModel{
|
||||
ConfigID: config.ID, Provider: string(config.Provider), BaseURL: config.BaseURL, ModelType: string(config.ModelType),
|
||||
ModelName: config.ModelName, MaxContextTokens: config.MaxContextTokens, MaxOutputTokens: config.MaxOutputTokens,
|
||||
TimeoutMS: config.TimeoutMS, MaxRetryCount: config.MaxRetryCount,
|
||||
}
|
||||
}
|
||||
definition := agentRevisionDefinition{
|
||||
Agent: agentRevisionAgent{
|
||||
Name: agent.Name, Description: agent.Description, AIConfigID: agent.AIConfigID,
|
||||
MaxSteps: agent.MaxSteps, ContextWindow: agent.ContextWindow,
|
||||
ToolPolicy: agent.ToolPolicy, KnowledgePolicy: agent.KnowledgePolicy, ServiceMode: int(agent.ServiceMode), SystemPrompt: agent.SystemPrompt,
|
||||
WelcomeMessage: agent.WelcomeMessage, ReplyTimeoutSeconds: agent.ReplyTimeoutSeconds, TeamIDs: agent.TeamIDs, HandoffMode: int(agent.HandoffMode),
|
||||
FallbackMode: int(agent.FallbackMode), FallbackMessage: agent.FallbackMessage, KnowledgeIDs: agent.KnowledgeIDs,
|
||||
SkillIDs: agent.SkillIDs, AllowedMCPTools: agent.AllowedMCPTools,
|
||||
AutoHandoffOnFailure: &agent.AutoHandoffOnFailure, HandoffMessage: agent.HandoffMessage,
|
||||
OffHoursMessage: agent.OffHoursMessage,
|
||||
},
|
||||
Model: model,
|
||||
}
|
||||
for _, binding := range repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agent.ID) {
|
||||
definition.WorkflowBindings = append(definition.WorkflowBindings, AgentRevisionWorkflowBinding{WorkflowID: binding.WorkflowID, WorkflowVersionID: binding.WorkflowVersionID, ToolName: binding.ToolName, TriggerInstruction: binding.TriggerInstruction, Priority: binding.Priority})
|
||||
}
|
||||
data, err := json.Marshal(definition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
hash := sha256.Sum256(data)
|
||||
item := &models.AgentRevision{
|
||||
AgentID: agent.ID, Revision: repositories.AgentRevisionRepository.MaxRevisionByAgentID(db, agent.ID) + 1,
|
||||
Status: enums.StatusOk, Definition: string(data), DefinitionHash: hex.EncodeToString(hash[:]),
|
||||
PublishedAt: &now, PublishedByID: operator.UserID, PublishedByName: operator.Username, AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := repositories.AgentRevisionRepository.Create(db, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
113
internal/services/agent_revision_service_test.go
Normal file
113
internal/services/agent_revision_service_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAgentRevisionServiceRestoresPublishedSnapshotAndKeepsAPIKey(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIConfig{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
autoHandoffFalse := false
|
||||
definition := agentRevisionDefinition{
|
||||
Agent: agentRevisionAgent{
|
||||
Name: "published agent", AIConfigID: 8,
|
||||
MaxSteps: 5, ContextWindow: 9, SystemPrompt: "published instruction", KnowledgeIDs: "4", ReplyTimeoutSeconds: 90,
|
||||
AutoHandoffOnFailure: &autoHandoffFalse,
|
||||
HandoffMessage: "Custom notice",
|
||||
OffHoursMessage: "Custom off hours notice",
|
||||
},
|
||||
Model: agentRevisionModel{ConfigID: 8, Provider: string(enums.AIProviderOpenAI), BaseURL: "https://published.example/v1", ModelType: string(enums.AIModelTypeLLM), ModelName: "published-model", TimeoutMS: 12000},
|
||||
}
|
||||
data, err := json.Marshal(definition)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal definition: %v", err)
|
||||
}
|
||||
revision := &models.AgentRevision{AgentID: 7, Revision: 1, Status: enums.StatusOk, Definition: string(data)}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
snapshot, err := AgentRevisionService.ResolvePublishedSnapshot(models.AIAgent{ID: 7, PublishedRevisionID: revision.ID, SystemPrompt: "draft instruction"}, models.AIConfig{ID: 8, APIKey: "rotated-secret", ModelName: "draft-model"})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePublishedSnapshot: %v", err)
|
||||
}
|
||||
if snapshot.Agent.SystemPrompt != "published instruction" || snapshot.Agent.MaxSteps != 5 || snapshot.Agent.ReplyTimeoutSeconds != 90 {
|
||||
t.Fatalf("agent snapshot not restored: %#v", snapshot.Agent)
|
||||
}
|
||||
if snapshot.Agent.AutoHandoffOnFailure != false || snapshot.Agent.HandoffMessage != "Custom notice" || snapshot.Agent.OffHoursMessage != "Custom off hours notice" {
|
||||
t.Fatalf("auto handoff, notice or off-hours notice not restored: %#v", snapshot.Agent)
|
||||
}
|
||||
if snapshot.AIConfig.ModelName != "published-model" || snapshot.AIConfig.BaseURL != "https://published.example/v1" || snapshot.AIConfig.APIKey != "rotated-secret" {
|
||||
t.Fatalf("model snapshot not restored safely: %#v", snapshot.AIConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRevisionServiceUsesPublishedModelConfigAfterDraftConfigChanges(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIConfig{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
publishedConfig := &models.AIConfig{
|
||||
Name: "published", Status: enums.StatusOk, Provider: enums.AIProviderOpenAI,
|
||||
ModelType: enums.AIModelTypeLLM, ModelName: "current-published-model", APIKey: "published-secret",
|
||||
}
|
||||
draftConfig := &models.AIConfig{
|
||||
Name: "draft", Status: enums.StatusOk, Provider: enums.AIProviderOpenAI,
|
||||
ModelType: enums.AIModelTypeLLM, ModelName: "draft-model", APIKey: "draft-secret",
|
||||
}
|
||||
if err := db.Create(publishedConfig).Error; err != nil {
|
||||
t.Fatalf("create published config: %v", err)
|
||||
}
|
||||
if err := db.Create(draftConfig).Error; err != nil {
|
||||
t.Fatalf("create draft config: %v", err)
|
||||
}
|
||||
|
||||
definition := agentRevisionDefinition{
|
||||
Agent: agentRevisionAgent{AIConfigID: publishedConfig.ID, SystemPrompt: "published instruction"},
|
||||
Model: agentRevisionModel{
|
||||
ConfigID: publishedConfig.ID, Provider: string(enums.AIProviderOpenAI),
|
||||
ModelType: string(enums.AIModelTypeLLM), ModelName: "snapshotted-published-model",
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(definition)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal definition: %v", err)
|
||||
}
|
||||
revision := &models.AgentRevision{AgentID: 7, Revision: 1, Status: enums.StatusOk, Definition: string(data)}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := AgentRevisionService.ResolvePublishedSnapshot(
|
||||
models.AIAgent{ID: 7, AIConfigID: draftConfig.ID, PublishedRevisionID: revision.ID},
|
||||
*draftConfig,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePublishedSnapshot: %v", err)
|
||||
}
|
||||
if snapshot.AIConfig.ID != publishedConfig.ID {
|
||||
t.Fatalf("model config id = %d, want published config %d", snapshot.AIConfig.ID, publishedConfig.ID)
|
||||
}
|
||||
if snapshot.AIConfig.ModelName != "snapshotted-published-model" || snapshot.AIConfig.APIKey != "published-secret" {
|
||||
t.Fatalf("published model snapshot not restored safely: %#v", snapshot.AIConfig)
|
||||
}
|
||||
}
|
||||
389
internal/services/agent_run_service.go
Normal file
389
internal/services/agent_run_service.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var AgentRunService = newAgentRunService()
|
||||
|
||||
func newAgentRunService() *agentRunService {
|
||||
return &agentRunService{}
|
||||
}
|
||||
|
||||
type agentRunService struct{}
|
||||
|
||||
type AgentRunMetrics struct {
|
||||
TotalRuns int `json:"totalRuns"`
|
||||
CompletedRuns int `json:"completedRuns"`
|
||||
FailedRuns int `json:"failedRuns"`
|
||||
InterruptedRuns int `json:"interruptedRuns"`
|
||||
CompletionRate float64 `json:"completionRate"`
|
||||
ToolCalls int `json:"toolCalls"`
|
||||
ToolSuccessRate float64 `json:"toolSuccessRate"`
|
||||
AverageSteps float64 `json:"averageSteps"`
|
||||
AverageDurationMS int64 `json:"averageDurationMs"`
|
||||
P95DurationMS int64 `json:"p95DurationMs"`
|
||||
PromptTokens int64 `json:"promptTokens"`
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
HandoffRate float64 `json:"handoffRate"`
|
||||
KnowledgeFallbackRate float64 `json:"knowledgeFallbackRate"`
|
||||
ResumedInterrupts int `json:"resumedInterrupts"`
|
||||
ResolvedInterrupts int `json:"resolvedInterrupts"`
|
||||
InterruptRecoveryRate float64 `json:"interruptRecoveryRate"`
|
||||
ReviewedRuns int `json:"reviewedRuns"`
|
||||
ResolvedRuns int `json:"resolvedRuns"`
|
||||
ResolutionRate float64 `json:"resolutionRate"`
|
||||
UnsupportedEvidenceRuns int `json:"unsupportedEvidenceRuns"`
|
||||
UnsupportedEvidenceRate float64 `json:"unsupportedEvidenceRate"`
|
||||
}
|
||||
|
||||
const maxAgentAuditPreviewChars = 4000
|
||||
|
||||
var agentAuditSecretPattern = regexp.MustCompile(`(?i)(?:"|')?(api[_-]?key|authorization|password|secret|token|cookie)(?:"|')?\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)`)
|
||||
|
||||
func (s *agentRunService) Get(id int64) *models.AgentRun {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AgentRunRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentRunService) FindPageByParams(queryParams *params.QueryParams) (list []models.AgentRun, paging *sqls.Paging) {
|
||||
return repositories.AgentRunRepository.FindPageByParams(sqls.DB(), queryParams)
|
||||
}
|
||||
|
||||
func (s *agentRunService) GetDetail(id int64) (*models.AgentRun, []models.AgentStep, []models.AgentToolCall) {
|
||||
run := s.Get(id)
|
||||
if run == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return run,
|
||||
repositories.AgentStepRepository.FindByAgentRunID(sqls.DB(), id),
|
||||
repositories.AgentToolCallRepository.FindByAgentRunID(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentRunService) GetLatestStepID(agentRunID int64) int64 {
|
||||
step := repositories.AgentStepRepository.LastByAgentRunID(sqls.DB(), agentRunID)
|
||||
if step == nil {
|
||||
return 0
|
||||
}
|
||||
return step.ID
|
||||
}
|
||||
|
||||
func (s *agentRunService) GetQualityFeedback(agentRunID int64) *models.AgentRunQualityFeedback {
|
||||
return repositories.AgentRunQualityFeedbackRepository.GetByAgentRunID(sqls.DB(), agentRunID)
|
||||
}
|
||||
|
||||
func (s *agentRunService) SaveQualityFeedback(req request.SaveAgentRunQualityFeedbackRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if req.AgentRunID <= 0 {
|
||||
return errorsx.InvalidParam("agent run id is required")
|
||||
}
|
||||
if !slices.Contains(enums.AgentRunResolutionStatusValues, req.ResolutionStatus) || !slices.Contains(enums.AgentRunEvidenceStatusValues, req.EvidenceStatus) {
|
||||
return errorsx.InvalidParam("invalid agent run quality feedback status")
|
||||
}
|
||||
comment := strings.TrimSpace(req.Comment)
|
||||
if len([]rune(comment)) > 2000 {
|
||||
return errorsx.InvalidParam("agent run quality feedback comment is too long")
|
||||
}
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if repositories.AgentRunRepository.Get(ctx.Tx, req.AgentRunID) == nil {
|
||||
return errorsx.InvalidParam("agent run does not exist")
|
||||
}
|
||||
current := repositories.AgentRunQualityFeedbackRepository.GetByAgentRunID(ctx.Tx, req.AgentRunID)
|
||||
if current == nil {
|
||||
return repositories.AgentRunQualityFeedbackRepository.Create(ctx.Tx, &models.AgentRunQualityFeedback{
|
||||
AgentRunID: req.AgentRunID, ResolutionStatus: req.ResolutionStatus, EvidenceStatus: req.EvidenceStatus, Comment: comment,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
})
|
||||
}
|
||||
return repositories.AgentRunQualityFeedbackRepository.Updates(ctx.Tx, current.ID, map[string]any{
|
||||
"resolution_status": req.ResolutionStatus,
|
||||
"evidence_status": req.EvidenceStatus,
|
||||
"comment": comment,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// GetMetrics aggregates normalized audit records in Go so SQLite and MySQL
|
||||
// use identical percentile and rate semantics.
|
||||
func (s *agentRunService) GetMetrics(aiAgentID int64) AgentRunMetrics {
|
||||
runs := repositories.AgentRunRepository.FindRecent(sqls.DB(), aiAgentID, 5000)
|
||||
metrics := s.aggregateMetrics(sqls.DB(), runs)
|
||||
if len(runs) == 0 {
|
||||
return metrics
|
||||
}
|
||||
conversationCount := repositories.ConversationRepository.CountByAIAgentID(sqls.DB(), aiAgentID)
|
||||
if conversationCount > 0 {
|
||||
metrics.HandoffRate = float64(repositories.ConversationRepository.CountHandoffByAIAgentID(sqls.DB(), aiAgentID)) / float64(conversationCount)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func (s *agentRunService) aggregateMetrics(db *gorm.DB, runs []models.AgentRun) AgentRunMetrics {
|
||||
metrics := AgentRunMetrics{TotalRuns: len(runs)}
|
||||
if len(runs) == 0 {
|
||||
return metrics
|
||||
}
|
||||
runIDs := make([]int64, 0, len(runs))
|
||||
durations := make([]int64, 0, len(runs))
|
||||
var durationTotal int64
|
||||
for _, run := range runs {
|
||||
runIDs = append(runIDs, run.ID)
|
||||
switch run.Status {
|
||||
case "completed":
|
||||
metrics.CompletedRuns++
|
||||
case "failed":
|
||||
metrics.FailedRuns++
|
||||
case "interrupted":
|
||||
metrics.InterruptedRuns++
|
||||
}
|
||||
metrics.PromptTokens += int64(run.PromptTokens)
|
||||
metrics.CompletionTokens += int64(run.CompletionTokens)
|
||||
if run.EndedAt != nil {
|
||||
duration := run.EndedAt.Sub(run.StartedAt).Milliseconds()
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
durations = append(durations, duration)
|
||||
durationTotal += duration
|
||||
}
|
||||
}
|
||||
metrics.CompletionRate = float64(metrics.CompletedRuns) / float64(metrics.TotalRuns)
|
||||
if len(durations) > 0 {
|
||||
metrics.AverageDurationMS = durationTotal / int64(len(durations))
|
||||
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
|
||||
index := (len(durations)*95+99)/100 - 1
|
||||
metrics.P95DurationMS = durations[index]
|
||||
}
|
||||
steps := repositories.AgentStepRepository.FindByAgentRunIDs(db, runIDs)
|
||||
metrics.AverageSteps = float64(len(steps)) / float64(metrics.TotalRuns)
|
||||
fallbackRunIDs := make(map[int64]struct{})
|
||||
for _, step := range steps {
|
||||
if step.StepType == "policy" && step.StepCode == "knowledge_evidence" {
|
||||
fallbackRunIDs[step.AgentRunID] = struct{}{}
|
||||
}
|
||||
}
|
||||
metrics.KnowledgeFallbackRate = float64(len(fallbackRunIDs)) / float64(metrics.TotalRuns)
|
||||
toolCalls := repositories.AgentToolCallRepository.FindByAgentRunIDs(db, runIDs)
|
||||
metrics.ToolCalls = len(toolCalls)
|
||||
if len(toolCalls) > 0 {
|
||||
completed := 0
|
||||
for _, call := range toolCalls {
|
||||
if call.Status == "completed" {
|
||||
completed++
|
||||
}
|
||||
}
|
||||
metrics.ToolSuccessRate = float64(completed) / float64(len(toolCalls))
|
||||
}
|
||||
interrupts := repositories.ConversationInterruptRepository.FindByAgentRunIDs(db, runIDs)
|
||||
for _, interrupt := range interrupts {
|
||||
if interrupt.ResumeCount <= 0 {
|
||||
continue
|
||||
}
|
||||
metrics.ResumedInterrupts++
|
||||
if interrupt.Status == "resolved" {
|
||||
metrics.ResolvedInterrupts++
|
||||
}
|
||||
}
|
||||
if metrics.ResumedInterrupts > 0 {
|
||||
metrics.InterruptRecoveryRate = float64(metrics.ResolvedInterrupts) / float64(metrics.ResumedInterrupts)
|
||||
}
|
||||
feedbacks := repositories.AgentRunQualityFeedbackRepository.FindByAgentRunIDs(db, runIDs)
|
||||
metrics.ReviewedRuns = len(feedbacks)
|
||||
for _, feedback := range feedbacks {
|
||||
if feedback.ResolutionStatus == enums.AgentRunResolutionStatusResolved {
|
||||
metrics.ResolvedRuns++
|
||||
}
|
||||
if feedback.EvidenceStatus == enums.AgentRunEvidenceStatusUnsupported {
|
||||
metrics.UnsupportedEvidenceRuns++
|
||||
}
|
||||
}
|
||||
if metrics.ReviewedRuns > 0 {
|
||||
metrics.ResolutionRate = float64(metrics.ResolvedRuns) / float64(metrics.ReviewedRuns)
|
||||
metrics.UnsupportedEvidenceRate = float64(metrics.UnsupportedEvidenceRuns) / float64(metrics.ReviewedRuns)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
type AgentLoopRunInput struct {
|
||||
ConversationID int64
|
||||
AIAgentID int64
|
||||
AgentRevisionID int64
|
||||
SourceMessageID int64
|
||||
WorkflowRunID int64
|
||||
Status string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
StartedAt time.Time
|
||||
EndedAt *time.Time
|
||||
ErrorMessage string
|
||||
TraceData string
|
||||
StepType string
|
||||
StepCode string
|
||||
StepInputPreview string
|
||||
StepOutputPreview string
|
||||
AdditionalSteps []AgentLoopStepInput
|
||||
ToolCalls []AgentLoopToolCallInput
|
||||
}
|
||||
|
||||
type AgentLoopStepInput struct {
|
||||
StepType string
|
||||
StepCode string
|
||||
WorkflowRunID int64
|
||||
Status string
|
||||
InputPreview string
|
||||
OutputPreview string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
type AgentLoopToolCallInput struct {
|
||||
ToolCode string
|
||||
RiskLevel string
|
||||
RequireConfirm bool
|
||||
Status string
|
||||
ArgumentsPreview string
|
||||
ResultPreview string
|
||||
ErrorMessage string
|
||||
DurationMS int
|
||||
}
|
||||
|
||||
// RecordResume closes or re-interrupts the original Agent Loop parent run,
|
||||
// appends a normalized resume step, and records an optional resumed tool call.
|
||||
func (s *agentRunService) RecordResume(db *gorm.DB, agentRunID, workflowRunID int64, status, replyText string, toolCall *AgentLoopToolCallInput) error {
|
||||
if agentRunID <= 0 {
|
||||
return nil
|
||||
}
|
||||
run := repositories.AgentRunRepository.Get(db, agentRunID)
|
||||
if run == nil {
|
||||
return nil
|
||||
}
|
||||
status = firstNonEmptyString(status, "completed")
|
||||
now := time.Now()
|
||||
if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{
|
||||
"status": status, "ended_at": &now, "error_message": "", "updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
step := &models.AgentStep{
|
||||
AgentRunID: run.ID, WorkflowRunID: workflowRunID,
|
||||
StepType: "resume", StepCode: "confirmation_resume", Status: status,
|
||||
InputPreview: "customer confirmation", OutputPreview: sanitizeAgentAuditPreview(replyText),
|
||||
StartedAt: now, EndedAt: &now, CreatedAt: now,
|
||||
}
|
||||
if err := repositories.AgentStepRepository.Create(db, step); err != nil {
|
||||
return err
|
||||
}
|
||||
if toolCall == nil {
|
||||
return nil
|
||||
}
|
||||
return repositories.AgentToolCallRepository.Create(db, &models.AgentToolCall{
|
||||
AgentRunID: run.ID, AgentStepID: step.ID, ToolCode: strings.TrimSpace(toolCall.ToolCode),
|
||||
RiskLevel: strings.TrimSpace(toolCall.RiskLevel), RequireConfirm: toolCall.RequireConfirm,
|
||||
Status: firstNonEmptyString(toolCall.Status, status), ArgumentsPreview: sanitizeAgentAuditPreview(toolCall.ArgumentsPreview),
|
||||
ResultPreview: sanitizeAgentAuditPreview(toolCall.ResultPreview), ErrorMessage: sanitizeAgentAuditPreview(toolCall.ErrorMessage),
|
||||
DurationMS: toolCall.DurationMS, CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordAgentLoopRun writes the Agent Loop parent audit run and its normalized
|
||||
// root step in one transaction owned by the caller.
|
||||
func (s *agentRunService) RecordAgentLoopRun(db *gorm.DB, input AgentLoopRunInput) (int64, error) {
|
||||
now := time.Now()
|
||||
startedAt := input.StartedAt
|
||||
if startedAt.IsZero() {
|
||||
startedAt = now
|
||||
}
|
||||
status := strings.TrimSpace(input.Status)
|
||||
if status == "" {
|
||||
status = "completed"
|
||||
}
|
||||
run := &models.AgentRun{
|
||||
ConversationID: input.ConversationID, AIAgentID: input.AIAgentID, AgentRevisionID: input.AgentRevisionID,
|
||||
SourceMessageID: input.SourceMessageID, WorkflowRunID: input.WorkflowRunID, Status: status,
|
||||
PromptTokens: input.PromptTokens, CompletionTokens: input.CompletionTokens, StartedAt: startedAt, EndedAt: input.EndedAt,
|
||||
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage), TraceData: sanitizeAgentAuditPreview(input.TraceData), CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := repositories.AgentRunRepository.Create(db, run); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
durationMS := 0
|
||||
if input.EndedAt != nil {
|
||||
durationMS = int(input.EndedAt.Sub(startedAt).Milliseconds())
|
||||
if durationMS < 0 {
|
||||
durationMS = 0
|
||||
}
|
||||
}
|
||||
step := &models.AgentStep{
|
||||
AgentRunID: run.ID, StepType: strings.TrimSpace(input.StepType), StepCode: strings.TrimSpace(input.StepCode), Status: status,
|
||||
InputPreview: sanitizeAgentAuditPreview(input.StepInputPreview), OutputPreview: sanitizeAgentAuditPreview(input.StepOutputPreview), ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage),
|
||||
StartedAt: startedAt, EndedAt: input.EndedAt, DurationMS: durationMS, CreatedAt: now,
|
||||
}
|
||||
if err := repositories.AgentStepRepository.Create(db, step); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, extra := range input.AdditionalSteps {
|
||||
extraStep := &models.AgentStep{
|
||||
AgentRunID: run.ID, WorkflowRunID: extra.WorkflowRunID, StepType: strings.TrimSpace(extra.StepType), StepCode: strings.TrimSpace(extra.StepCode),
|
||||
Status: firstNonEmptyString(extra.Status, status), InputPreview: sanitizeAgentAuditPreview(extra.InputPreview), OutputPreview: sanitizeAgentAuditPreview(extra.OutputPreview),
|
||||
ErrorMessage: sanitizeAgentAuditPreview(extra.ErrorMessage), StartedAt: startedAt, EndedAt: input.EndedAt, DurationMS: durationMS, CreatedAt: now,
|
||||
}
|
||||
if err := repositories.AgentStepRepository.Create(db, extraStep); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
for _, call := range input.ToolCalls {
|
||||
toolCall := &models.AgentToolCall{
|
||||
AgentRunID: run.ID, AgentStepID: step.ID, ToolCode: strings.TrimSpace(call.ToolCode), RiskLevel: strings.TrimSpace(call.RiskLevel),
|
||||
RequireConfirm: call.RequireConfirm, Status: firstNonEmptyString(call.Status, status), ArgumentsPreview: sanitizeAgentAuditPreview(call.ArgumentsPreview),
|
||||
ResultPreview: sanitizeAgentAuditPreview(call.ResultPreview), ErrorMessage: sanitizeAgentAuditPreview(call.ErrorMessage), DurationMS: call.DurationMS, CreatedAt: now,
|
||||
}
|
||||
if err := repositories.AgentToolCallRepository.Create(db, toolCall); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return run.ID, nil
|
||||
}
|
||||
|
||||
func sanitizeAgentAuditPreview(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
value = agentAuditSecretPattern.ReplaceAllString(value, "$1$2***")
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxAgentAuditPreviewChars {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:maxAgentAuditPreviewChars])) + "\n[preview truncated]"
|
||||
}
|
||||
|
||||
func firstNonEmptyString(items ...string) string {
|
||||
for _, item := range items {
|
||||
if value := strings.TrimSpace(item); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
212
internal/services/agent_run_service_test.go
Normal file
212
internal/services/agent_run_service_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) {
|
||||
db := setupAgentRunServiceTestDB(t)
|
||||
now := time.Now()
|
||||
endedAt := now.Add(time.Second)
|
||||
run := &models.AgentRun{
|
||||
ConversationID: 11,
|
||||
AIAgentID: 12,
|
||||
WorkflowRunID: 13,
|
||||
|
||||
Status: "completed",
|
||||
StartedAt: now,
|
||||
EndedAt: &endedAt,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(run).Error; err != nil {
|
||||
t.Fatalf("create agent run: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.AgentStep{AgentRunID: run.ID, StepType: "workflow", Status: "completed", StartedAt: now, EndedAt: &endedAt, CreatedAt: now}).Error; err != nil {
|
||||
t.Fatalf("create agent step: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.AgentToolCall{AgentRunID: run.ID, ToolCode: "knowledge.retrieve", Status: "completed", CreatedAt: now}).Error; err != nil {
|
||||
t.Fatalf("create tool call: %v", err)
|
||||
}
|
||||
|
||||
cnd := sqls.NewCnd().Eq("conversation_id", run.ConversationID).Desc("id").Page(1, 20)
|
||||
queryParams := ¶ms.QueryParams{Cnd: *cnd}
|
||||
list, paging := AgentRunService.FindPageByParams(queryParams)
|
||||
if len(list) != 1 || paging.Total != 1 || list[0].ID != run.ID {
|
||||
t.Fatalf("unexpected agent run page: list=%#v paging=%#v", list, paging)
|
||||
}
|
||||
item, steps, toolCalls := AgentRunService.GetDetail(run.ID)
|
||||
if item == nil || len(steps) != 1 || len(toolCalls) != 1 {
|
||||
t.Fatalf("unexpected agent run detail: run=%#v steps=%#v toolCalls=%#v", item, steps, toolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRunServiceRecordsAgentLoopToolCall(t *testing.T) {
|
||||
db := setupAgentRunServiceTestDB(t)
|
||||
now := time.Now()
|
||||
runID, err := AgentRunService.RecordAgentLoopRun(db, AgentLoopRunInput{
|
||||
ConversationID: 1, AIAgentID: 2, AgentRevisionID: 3, Status: "completed", StartedAt: now,
|
||||
StepType: "model", StepCode: "chat_completion", StepInputPreview: "authorization=Bearer-secret", ToolCalls: []AgentLoopToolCallInput{{
|
||||
ToolCode: "knowledge/search", RiskLevel: "read", Status: "completed", ArgumentsPreview: `{"token":"abc123","query":"refund"}`, ResultPreview: "policy text",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecordAgentLoopRun returned error: %v", err)
|
||||
}
|
||||
_, steps, toolCalls := AgentRunService.GetDetail(runID)
|
||||
if len(toolCalls) != 1 || toolCalls[0].ToolCode != "knowledge/search" || toolCalls[0].AgentStepID <= 0 {
|
||||
t.Fatalf("unexpected tool audit: %#v", toolCalls)
|
||||
}
|
||||
if strings.Contains(toolCalls[0].ArgumentsPreview, "abc123") || len(steps) != 1 || strings.Contains(steps[0].InputPreview, "Bearer-secret") {
|
||||
t.Fatalf("sensitive audit data leaked: steps=%#v calls=%#v", steps, toolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRunServiceRecordsResumedToolCall(t *testing.T) {
|
||||
db := setupAgentRunServiceTestDB(t)
|
||||
now := time.Now()
|
||||
run := &models.AgentRun{Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(run).Error; err != nil {
|
||||
t.Fatalf("create interrupted run: %v", err)
|
||||
}
|
||||
err := AgentRunService.RecordResume(db, run.ID, 0, "completed", "操作已执行", &AgentLoopToolCallInput{
|
||||
ToolCode: "crm/update_customer", RiskLevel: "write", RequireConfirm: true,
|
||||
Status: "completed", ArgumentsPreview: `{"name":"Ada"}`, ResultPreview: "updated",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecordResume returned error: %v", err)
|
||||
}
|
||||
item, steps, toolCalls := AgentRunService.GetDetail(run.ID)
|
||||
if item == nil || item.Status != "completed" || len(steps) != 1 || steps[0].StepType != "resume" {
|
||||
t.Fatalf("unexpected resumed run audit: item=%#v steps=%#v", item, steps)
|
||||
}
|
||||
if len(toolCalls) != 1 || toolCalls[0].AgentStepID != steps[0].ID || !toolCalls[0].RequireConfirm || toolCalls[0].Status != "completed" {
|
||||
t.Fatalf("unexpected resumed tool audit: %#v", toolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRunServiceSavesQualityFeedbackPerRun(t *testing.T) {
|
||||
db := setupAgentRunServiceTestDB(t)
|
||||
now := time.Now()
|
||||
run := &models.AgentRun{AIAgentID: 4, Status: "completed", StartedAt: now, CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(run).Error; err != nil {
|
||||
t.Fatalf("create agent run: %v", err)
|
||||
}
|
||||
operator := &dto.AuthPrincipal{UserID: 7, Username: "reviewer"}
|
||||
if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{
|
||||
AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported, Comment: "issue resolved",
|
||||
}, operator); err != nil {
|
||||
t.Fatalf("save quality feedback: %v", err)
|
||||
}
|
||||
if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{
|
||||
AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported, Comment: "missing evidence",
|
||||
}, operator); err != nil {
|
||||
t.Fatalf("update quality feedback: %v", err)
|
||||
}
|
||||
feedback := AgentRunService.GetQualityFeedback(run.ID)
|
||||
if feedback == nil || feedback.ResolutionStatus != enums.AgentRunResolutionStatusUnresolved || feedback.EvidenceStatus != enums.AgentRunEvidenceStatusUnsupported || feedback.Comment != "missing evidence" || feedback.UpdateUserName != "reviewer" {
|
||||
t.Fatalf("unexpected quality feedback: %#v", feedback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRunServiceAggregatesMetrics(t *testing.T) {
|
||||
db := setupAgentRunServiceTestDB(t)
|
||||
base := time.Now().Add(-time.Minute)
|
||||
runs := []models.AgentRun{
|
||||
{AIAgentID: 8, Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(100 * time.Millisecond)), PromptTokens: 10, CompletionTokens: 5, CreatedAt: base, UpdatedAt: base},
|
||||
{AIAgentID: 8, Status: "failed", StartedAt: base, EndedAt: timePtr(base.Add(300 * time.Millisecond)), PromptTokens: 8, CompletionTokens: 2, CreatedAt: base, UpdatedAt: base},
|
||||
{AIAgentID: 9, Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(900 * time.Millisecond)), CreatedAt: base, UpdatedAt: base},
|
||||
}
|
||||
for index := range runs {
|
||||
if err := db.Create(&runs[index]).Error; err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.Create(&models.AgentStep{AgentRunID: runs[0].ID, Status: "completed", StartedAt: base, CreatedAt: base}).Error; err != nil {
|
||||
t.Fatalf("create step: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.AgentStep{AgentRunID: runs[1].ID, Status: "failed", StartedAt: base, CreatedAt: base}).Error; err != nil {
|
||||
t.Fatalf("create step: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.AgentToolCall{AgentRunID: runs[0].ID, Status: "completed", CreatedAt: base}).Error; err != nil {
|
||||
t.Fatalf("create completed tool call: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.AgentToolCall{AgentRunID: runs[1].ID, Status: "failed", CreatedAt: base}).Error; err != nil {
|
||||
t.Fatalf("create failed tool call: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.Conversation{AIAgentID: 8}).Error; err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
handoffAt := base
|
||||
if err := db.Create(&models.Conversation{AIAgentID: 8, HandoffAt: &handoffAt}).Error; err != nil {
|
||||
t.Fatalf("create handoff conversation: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[0].ID, CheckPointID: "metrics-resolved", Status: "resolved", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil {
|
||||
t.Fatalf("create resolved interrupt: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[1].ID, CheckPointID: "metrics-cancelled", Status: "cancelled", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil {
|
||||
t.Fatalf("create cancelled interrupt: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[0].ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported}).Error; err != nil {
|
||||
t.Fatalf("create resolved feedback: %v", err)
|
||||
}
|
||||
if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[1].ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported}).Error; err != nil {
|
||||
t.Fatalf("create unresolved feedback: %v", err)
|
||||
}
|
||||
metrics := AgentRunService.GetMetrics(8)
|
||||
if metrics.TotalRuns != 2 || metrics.CompletedRuns != 1 || metrics.FailedRuns != 1 || metrics.CompletionRate != 0.5 {
|
||||
t.Fatalf("unexpected run metrics: %#v", metrics)
|
||||
}
|
||||
if metrics.AverageDurationMS != 200 || metrics.P95DurationMS != 300 || metrics.ToolCalls != 2 || metrics.ToolSuccessRate != 0.5 || metrics.AverageSteps != 1 {
|
||||
t.Fatalf("unexpected aggregate metrics: %#v", metrics)
|
||||
}
|
||||
if metrics.PromptTokens != 18 || metrics.CompletionTokens != 7 {
|
||||
t.Fatalf("unexpected token metrics: %#v", metrics)
|
||||
}
|
||||
if metrics.HandoffRate != 0.5 || metrics.KnowledgeFallbackRate != 0 {
|
||||
t.Fatalf("unexpected business metrics: %#v", metrics)
|
||||
}
|
||||
if metrics.ResumedInterrupts != 2 || metrics.ResolvedInterrupts != 1 || metrics.InterruptRecoveryRate != 0.5 {
|
||||
t.Fatalf("unexpected interrupt recovery metrics: %#v", metrics)
|
||||
}
|
||||
if metrics.ReviewedRuns != 2 || metrics.ResolvedRuns != 1 || metrics.ResolutionRate != 0.5 || metrics.UnsupportedEvidenceRuns != 1 || metrics.UnsupportedEvidenceRate != 0.5 {
|
||||
t.Fatalf("unexpected quality metrics: %#v", metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func timePtr(value time.Time) *time.Time { return &value }
|
||||
|
||||
func setupAgentRunServiceTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.AgentRunQualityFeedback{}, &models.Conversation{}, &models.ConversationInterrupt{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
return db
|
||||
}
|
||||
561
internal/services/agent_team_schedule_service.go
Normal file
561
internal/services/agent_team_schedule_service.go
Normal file
@@ -0,0 +1,561 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var AgentTeamScheduleService = newAgentTeamScheduleService()
|
||||
|
||||
func newAgentTeamScheduleService() *agentTeamScheduleService {
|
||||
return &agentTeamScheduleService{}
|
||||
}
|
||||
|
||||
type agentTeamScheduleService struct {
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
const maxAgentTeamScheduleBatchItems = 500
|
||||
|
||||
type AgentTeamScheduleBatchPreviewResult struct {
|
||||
Total int
|
||||
Conflict bool
|
||||
Items []AgentTeamScheduleBatchPreviewItem
|
||||
}
|
||||
|
||||
type AgentTeamScheduleBatchPreviewItem struct {
|
||||
TeamID int64
|
||||
TeamName string
|
||||
Date time.Time
|
||||
Weekday int
|
||||
StartAt time.Time
|
||||
EndAt time.Time
|
||||
Remark string
|
||||
Conflict bool
|
||||
ConflictReason string
|
||||
}
|
||||
|
||||
type AgentTeamScheduleBatchGenerateResult struct {
|
||||
Created int
|
||||
}
|
||||
|
||||
type batchScheduleCandidate struct {
|
||||
TeamID int64
|
||||
TeamName string
|
||||
Date time.Time
|
||||
StartAt time.Time
|
||||
EndAt time.Time
|
||||
Remark string
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Get(id int64) *models.AgentTeamSchedule {
|
||||
return repositories.AgentTeamScheduleRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Take(where ...interface{}) *models.AgentTeamSchedule {
|
||||
return repositories.AgentTeamScheduleRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Find(cnd *sqls.Cnd) []models.AgentTeamSchedule {
|
||||
return repositories.AgentTeamScheduleRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) FindOne(cnd *sqls.Cnd) *models.AgentTeamSchedule {
|
||||
return repositories.AgentTeamScheduleRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) FindPageByParams(params *params.QueryParams) (list []models.AgentTeamSchedule, paging *sqls.Paging) {
|
||||
return repositories.AgentTeamScheduleRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AgentTeamSchedule, paging *sqls.Paging) {
|
||||
return repositories.AgentTeamScheduleRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.AgentTeamScheduleRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) FindCalendarSchedules(req request.AgentTeamScheduleCalendarRequest) ([]models.AgentTeamSchedule, error) {
|
||||
startAtValue, err := parseRequiredDateTime(req.StartAt, "error.agentTeamSchedule.startAtInvalid", "error.agentTeamSchedule.startAtInvalidWithFormat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endAtValue, err := parseRequiredDateTime(req.EndAt, "error.agentTeamSchedule.endAtInvalid", "error.agentTeamSchedule.endAtInvalidWithFormat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !endAtValue.After(startAtValue) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0296")
|
||||
}
|
||||
return repositories.AgentTeamScheduleRepository.FindByTimeRange(sqls.DB(), startAtValue, endAtValue, req.TeamID), nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Create(t *models.AgentTeamSchedule) error {
|
||||
return repositories.AgentTeamScheduleRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Update(t *models.AgentTeamSchedule) error {
|
||||
return repositories.AgentTeamScheduleRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.AgentTeamScheduleRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.AgentTeamScheduleRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Delete(id int64) {
|
||||
repositories.AgentTeamScheduleRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) CreateAgentTeamSchedule(req request.CreateAgentTeamScheduleRequest, operator *dto.AuthPrincipal) (*models.AgentTeamSchedule, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
s.writeMu.Lock()
|
||||
item, err := s.buildScheduleModel(0, req.TeamID, req.StartAt, req.EndAt, req.Remark)
|
||||
if err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := repositories.AgentTeamScheduleRepository.Create(sqls.DB(), item); err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
s.writeMu.Unlock()
|
||||
s.dispatchPendingConversationsIfActive(item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) UpdateAgentTeamSchedule(req request.UpdateAgentTeamScheduleRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
s.writeMu.Lock()
|
||||
if s.Get(req.ID) == nil {
|
||||
s.writeMu.Unlock()
|
||||
return errorsx.InvalidParamI18n("error.e0172")
|
||||
}
|
||||
item, err := s.buildScheduleModel(req.ID, req.TeamID, req.StartAt, req.EndAt, req.Remark)
|
||||
if err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return err
|
||||
}
|
||||
if err := repositories.AgentTeamScheduleRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"team_id": item.TeamID,
|
||||
"start_at": item.StartAt,
|
||||
"end_at": item.EndAt,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return err
|
||||
}
|
||||
s.writeMu.Unlock()
|
||||
s.dispatchPendingConversationsIfActive(item)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) DeleteAgentTeamSchedule(id int64) error {
|
||||
if s.Get(id) == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0172")
|
||||
}
|
||||
repositories.AgentTeamScheduleRepository.Delete(sqls.DB(), id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) BatchPreview(req request.AgentTeamScheduleBatchRequest, operator *dto.AuthPrincipal) (*AgentTeamScheduleBatchPreviewResult, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
candidates, err := s.buildBatchScheduleCandidates(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conflicts := s.findBatchConflict(candidates, req.Locale)
|
||||
return buildBatchPreviewResult(candidates, conflicts), nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) BatchGenerate(req request.AgentTeamScheduleBatchRequest, operator *dto.AuthPrincipal) (*AgentTeamScheduleBatchGenerateResult, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
s.writeMu.Lock()
|
||||
candidates, err := s.buildBatchScheduleCandidates(req)
|
||||
if err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
conflicts := s.findBatchConflict(candidates, req.Locale)
|
||||
for _, conflict := range conflicts {
|
||||
if conflict != "" {
|
||||
s.writeMu.Unlock()
|
||||
return nil, errorsx.InvalidParamI18n("error.e0151")
|
||||
}
|
||||
}
|
||||
|
||||
schedules := make([]models.AgentTeamSchedule, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
schedules = append(schedules, models.AgentTeamSchedule{
|
||||
TeamID: candidate.TeamID,
|
||||
StartAt: candidate.StartAt,
|
||||
EndAt: candidate.EndAt,
|
||||
Remark: candidate.Remark,
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
})
|
||||
}
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conflicts := s.findBatchConflictByDB(ctx.Tx, candidates, req.Locale)
|
||||
for _, conflict := range conflicts {
|
||||
if conflict != "" {
|
||||
return errorsx.InvalidParamI18n("error.e0151")
|
||||
}
|
||||
}
|
||||
return repositories.AgentTeamScheduleRepository.CreateBatch(ctx.Tx, schedules)
|
||||
}); err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
s.writeMu.Unlock()
|
||||
for i := range schedules {
|
||||
s.dispatchPendingConversationsIfActive(&schedules[i])
|
||||
}
|
||||
return &AgentTeamScheduleBatchGenerateResult{Created: len(schedules)}, nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) buildScheduleModel(id, teamID int64, startAt, endAt, remark string) (*models.AgentTeamSchedule, error) {
|
||||
if teamID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0326")
|
||||
}
|
||||
team := AgentTeamService.Get(teamID)
|
||||
if team == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0169")
|
||||
}
|
||||
if !slices.Contains(enums.StatusValues, team.Status) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0174")
|
||||
}
|
||||
startAtValue, err := parseRequiredDateTime(startAt, "error.agentTeamSchedule.startAtInvalid", "error.agentTeamSchedule.startAtInvalidWithFormat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endAtValue, err := parseRequiredDateTime(endAt, "error.agentTeamSchedule.endAtInvalid", "error.agentTeamSchedule.endAtInvalidWithFormat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !endAtValue.After(startAtValue) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0296")
|
||||
}
|
||||
if !sameLocalDay(startAtValue, endAtValue) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0132")
|
||||
}
|
||||
if startAtValue.Before(startOfLocalDay(time.Now())) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0085")
|
||||
}
|
||||
overlapping := repositories.AgentTeamScheduleRepository.FindOverlappingByTeamIDsAndTimeRange(sqls.DB(), []int64{teamID}, startAtValue, endAtValue)
|
||||
for _, item := range overlapping {
|
||||
if item.ID != id {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0309")
|
||||
}
|
||||
}
|
||||
return &models.AgentTeamSchedule{
|
||||
TeamID: teamID,
|
||||
StartAt: startAtValue,
|
||||
EndAt: endAtValue,
|
||||
Remark: strings.TrimSpace(remark),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) buildBatchScheduleCandidates(req request.AgentTeamScheduleBatchRequest) ([]batchScheduleCandidate, error) {
|
||||
teamIDs := uniquePositiveInt64s(req.TeamIDs)
|
||||
if len(teamIDs) == 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0326")
|
||||
}
|
||||
weekdays, err := normalizeBatchWeekdays(req.Weekdays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startDate, err := parseRequiredDate(req.StartDate, "error.agentTeamSchedule.startDateInvalid", "error.agentTeamSchedule.startDateInvalidWithFormat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endDate, err := parseRequiredDate(req.EndDate, "error.agentTeamSchedule.endDateInvalid", "error.agentTeamSchedule.endDateInvalidWithFormat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if endDate.Before(startDate) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0295")
|
||||
}
|
||||
if startDate.Before(startOfLocalDay(time.Now())) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0085")
|
||||
}
|
||||
startClock, err := parseRequiredClock(req.StartTime, "error.agentTeamSchedule.startTimeInvalid", "error.agentTeamSchedule.startTimeInvalidWithFormat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endClock, err := parseRequiredClock(req.EndTime, "error.agentTeamSchedule.endTimeInvalid", "error.agentTeamSchedule.endTimeInvalidWithFormat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firstStartAt := combineDateAndClock(startDate, startClock)
|
||||
firstEndAt := combineDateAndClock(startDate, endClock)
|
||||
if !firstEndAt.After(firstStartAt) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0296")
|
||||
}
|
||||
|
||||
teams := AgentTeamService.FindByIds(teamIDs)
|
||||
teamsByID := make(map[int64]models.AgentTeam, len(teams))
|
||||
for _, team := range teams {
|
||||
teamsByID[team.ID] = team
|
||||
}
|
||||
for _, teamID := range teamIDs {
|
||||
team, ok := teamsByID[teamID]
|
||||
if !ok || team.Status == enums.StatusDeleted {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0169")
|
||||
}
|
||||
if !slices.Contains(enums.StatusValues, team.Status) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0174")
|
||||
}
|
||||
}
|
||||
|
||||
weekdaySet := make(map[int]struct{}, len(weekdays))
|
||||
for _, weekday := range weekdays {
|
||||
weekdaySet[weekday] = struct{}{}
|
||||
}
|
||||
candidates := make([]batchScheduleCandidate, 0)
|
||||
remark := strings.TrimSpace(req.Remark)
|
||||
for _, teamID := range teamIDs {
|
||||
team := teamsByID[teamID]
|
||||
for date := startDate; !date.After(endDate); date = date.AddDate(0, 0, 1) {
|
||||
if _, ok := weekdaySet[weekdayForBatchRequest(date)]; !ok {
|
||||
continue
|
||||
}
|
||||
if len(candidates) >= maxAgentTeamScheduleBatchItems {
|
||||
return nil, errorsx.InvalidParamI18n("error.agentTeamSchedule.batchLimit", maxAgentTeamScheduleBatchItems)
|
||||
}
|
||||
candidates = append(candidates, batchScheduleCandidate{
|
||||
TeamID: teamID,
|
||||
TeamName: team.Name,
|
||||
Date: date,
|
||||
StartAt: combineDateAndClock(date, startClock),
|
||||
EndAt: combineDateAndClock(date, endClock),
|
||||
Remark: remark,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0232")
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func parseRequiredDate(value, emptyKey, formatKey string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, errorsx.InvalidParamI18n(emptyKey)
|
||||
}
|
||||
ret, err := time.ParseInLocation(time.DateOnly, value, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, errorsx.InvalidParamI18n(formatKey)
|
||||
}
|
||||
return startOfLocalDay(ret), nil
|
||||
}
|
||||
|
||||
func parseRequiredClock(value, emptyKey, formatKey string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, errorsx.InvalidParamI18n(emptyKey)
|
||||
}
|
||||
layouts := []string{"15:04", "15:04:05"}
|
||||
for _, layout := range layouts {
|
||||
if ret, err := time.ParseInLocation(layout, value, time.Local); err == nil {
|
||||
return ret, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errorsx.InvalidParamI18n(formatKey)
|
||||
}
|
||||
|
||||
func combineDateAndClock(date, clock time.Time) time.Time {
|
||||
year, month, day := date.In(time.Local).Date()
|
||||
hour, minute, second := clock.In(time.Local).Clock()
|
||||
return time.Date(year, month, day, hour, minute, second, 0, time.Local)
|
||||
}
|
||||
|
||||
func buildBatchPreviewResult(candidates []batchScheduleCandidate, conflicts map[int]string) *AgentTeamScheduleBatchPreviewResult {
|
||||
items := make([]AgentTeamScheduleBatchPreviewItem, 0, len(candidates))
|
||||
hasConflict := false
|
||||
for i, candidate := range candidates {
|
||||
conflictReason := conflicts[i]
|
||||
conflict := conflictReason != ""
|
||||
if conflict {
|
||||
hasConflict = true
|
||||
}
|
||||
items = append(items, AgentTeamScheduleBatchPreviewItem{
|
||||
TeamID: candidate.TeamID,
|
||||
TeamName: candidate.TeamName,
|
||||
Date: candidate.Date,
|
||||
Weekday: weekdayForBatchRequest(candidate.Date),
|
||||
StartAt: candidate.StartAt,
|
||||
EndAt: candidate.EndAt,
|
||||
Remark: candidate.Remark,
|
||||
Conflict: conflict,
|
||||
ConflictReason: conflictReason,
|
||||
})
|
||||
}
|
||||
return &AgentTeamScheduleBatchPreviewResult{
|
||||
Total: len(items),
|
||||
Conflict: hasConflict,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) findBatchConflict(candidates []batchScheduleCandidate, locale string) map[int]string {
|
||||
return s.findBatchConflictByDB(sqls.DB(), candidates, locale)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) findBatchConflictByDB(db *gorm.DB, candidates []batchScheduleCandidate, locale string) map[int]string {
|
||||
conflicts := make(map[int]string)
|
||||
if len(candidates) == 0 {
|
||||
return conflicts
|
||||
}
|
||||
teamIDs := make([]int64, 0, len(candidates))
|
||||
startAt := candidates[0].StartAt
|
||||
endAt := candidates[0].EndAt
|
||||
for _, candidate := range candidates {
|
||||
teamIDs = append(teamIDs, candidate.TeamID)
|
||||
if candidate.StartAt.Before(startAt) {
|
||||
startAt = candidate.StartAt
|
||||
}
|
||||
if candidate.EndAt.After(endAt) {
|
||||
endAt = candidate.EndAt
|
||||
}
|
||||
}
|
||||
existing := repositories.AgentTeamScheduleRepository.FindOverlappingByTeamIDsAndTimeRange(db, uniquePositiveInt64s(teamIDs), startAt, endAt)
|
||||
for i, candidate := range candidates {
|
||||
for _, item := range existing {
|
||||
if item.TeamID != candidate.TeamID {
|
||||
continue
|
||||
}
|
||||
if item.StartAt.Before(candidate.EndAt) && item.EndAt.After(candidate.StartAt) {
|
||||
conflicts[i] = i18nx.Getf(locale, "error.agentTeamSchedule.conflictRange", item.StartAt.Format(time.DateTime), item.EndAt.Format(time.DateTime))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
func normalizeBatchWeekdays(values []int) ([]int, error) {
|
||||
seen := make(map[int]struct{}, len(values))
|
||||
ret := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value < 1 || value > 7 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0228")
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
ret = append(ret, value)
|
||||
}
|
||||
if len(ret) == 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0329")
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func weekdayForBatchRequest(value time.Time) int {
|
||||
if value.Weekday() == time.Sunday {
|
||||
return 7
|
||||
}
|
||||
return int(value.Weekday())
|
||||
}
|
||||
|
||||
func uniquePositiveInt64s(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
ret := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
ret = append(ret, value)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func parseRequiredDateTime(value, emptyKey, formatKey string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, errorsx.InvalidParamI18n(emptyKey)
|
||||
}
|
||||
ret, err := parseDateTimeValue(value)
|
||||
if err != nil {
|
||||
return time.Time{}, errorsx.InvalidParamI18n(formatKey)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func parseDateTimeValue(value string) (time.Time, error) {
|
||||
layouts := []string{
|
||||
time.DateTime,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02T15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if ret, err := time.ParseInLocation(layout, value, time.Local); err == nil {
|
||||
return ret, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errorsx.InvalidParamI18n("error.e0227")
|
||||
}
|
||||
|
||||
func startOfLocalDay(value time.Time) time.Time {
|
||||
year, month, day := value.In(time.Local).Date()
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
func sameLocalDay(a, b time.Time) bool {
|
||||
aYear, aMonth, aDay := a.In(time.Local).Date()
|
||||
bYear, bMonth, bDay := b.In(time.Local).Date()
|
||||
return aYear == bYear && aMonth == bMonth && aDay == bDay
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) dispatchPendingConversationsIfActive(item *models.AgentTeamSchedule) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
if item.Status != enums.StatusOk {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if item.StartAt.After(now) || !item.EndAt.After(now) {
|
||||
return
|
||||
}
|
||||
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
|
||||
}
|
||||
591
internal/services/agent_team_schedule_service_test.go
Normal file
591
internal/services/agent_team_schedule_service_test.go
Normal file
@@ -0,0 +1,591 @@
|
||||
package services_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestAgentTeamScheduleServiceFindCalendarSchedulesReturnsIntersectingSchedules(t *testing.T) {
|
||||
db := setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestData(t, db)
|
||||
|
||||
list, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{
|
||||
StartAt: "2026-04-27 00:00:00",
|
||||
EndAt: "2026-05-04 00:00:00",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FindCalendarSchedules() error = %v", err)
|
||||
}
|
||||
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("expected 3 intersecting schedules, got %d: %+v", len(list), list)
|
||||
}
|
||||
gotIDs := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
gotIDs = append(gotIDs, item.ID)
|
||||
}
|
||||
wantIDs := []int64{1, 2, 3}
|
||||
for i, want := range wantIDs {
|
||||
if gotIDs[i] != want {
|
||||
t.Fatalf("expected ids %v, got %v", wantIDs, gotIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceFindCalendarSchedulesFiltersTeamID(t *testing.T) {
|
||||
db := setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestData(t, db)
|
||||
|
||||
list, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{
|
||||
StartAt: "2026-04-27 00:00:00",
|
||||
EndAt: "2026-05-04 00:00:00",
|
||||
TeamID: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FindCalendarSchedules() error = %v", err)
|
||||
}
|
||||
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 schedule for team 2, got %d: %+v", len(list), list)
|
||||
}
|
||||
if list[0].ID != 3 || list[0].TeamID != 2 {
|
||||
t.Fatalf("unexpected schedule: %+v", list[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceFindCalendarSchedulesValidatesTimeRange(t *testing.T) {
|
||||
setupAgentTeamScheduleTestDB(t)
|
||||
|
||||
_, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{
|
||||
StartAt: "2026-05-04 00:00:00",
|
||||
EndAt: "2026-04-27 00:00:00",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected invalid time range to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceCreateRejectsCrossDaySchedule(t *testing.T) {
|
||||
setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, sqls.DB())
|
||||
|
||||
tomorrow := time.Now().AddDate(0, 0, 1)
|
||||
_, err := services.AgentTeamScheduleService.CreateAgentTeamSchedule(request.CreateAgentTeamScheduleRequest{
|
||||
TeamID: 1,
|
||||
StartAt: formatTestDateTime(tomorrow, "22:00:00"),
|
||||
EndAt: formatTestDateTime(tomorrow.AddDate(0, 0, 1), "08:00:00"),
|
||||
}, testOperator())
|
||||
if err == nil {
|
||||
t.Fatalf("expected cross-day schedule to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "不能跨天") && !strings.Contains(err.Error(), "span multiple days") {
|
||||
t.Fatalf("expected cross-day error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceCreateRejectsHistoricalScheduleByDay(t *testing.T) {
|
||||
setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, sqls.DB())
|
||||
|
||||
yesterday := time.Now().AddDate(0, 0, -1)
|
||||
_, err := services.AgentTeamScheduleService.CreateAgentTeamSchedule(request.CreateAgentTeamScheduleRequest{
|
||||
TeamID: 1,
|
||||
StartAt: formatTestDateTime(yesterday, "09:00:00"),
|
||||
EndAt: formatTestDateTime(yesterday, "18:00:00"),
|
||||
}, testOperator())
|
||||
if err == nil {
|
||||
t.Fatalf("expected historical schedule to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "历史日期") && !strings.Contains(err.Error(), "past date") {
|
||||
t.Fatalf("expected historical date error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceCreateAllowsTodayEarlierThanCurrentTime(t *testing.T) {
|
||||
setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, sqls.DB())
|
||||
|
||||
today := time.Now()
|
||||
item, err := services.AgentTeamScheduleService.CreateAgentTeamSchedule(request.CreateAgentTeamScheduleRequest{
|
||||
TeamID: 1,
|
||||
StartAt: formatTestDateTime(today, "00:00:00"),
|
||||
EndAt: formatTestDateTime(today, "01:00:00"),
|
||||
}, testOperator())
|
||||
if err != nil {
|
||||
t.Fatalf("expected today's schedule to pass, got %v", err)
|
||||
}
|
||||
if item == nil || item.ID == 0 {
|
||||
t.Fatalf("expected created schedule, got %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceUpdateRejectsCrossDaySchedule(t *testing.T) {
|
||||
db := setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, db)
|
||||
existingID := createFutureAgentTeamSchedule(t, db)
|
||||
tomorrow := time.Now().AddDate(0, 0, 1)
|
||||
|
||||
err := services.AgentTeamScheduleService.UpdateAgentTeamSchedule(request.UpdateAgentTeamScheduleRequest{
|
||||
ID: existingID,
|
||||
CreateAgentTeamScheduleRequest: request.CreateAgentTeamScheduleRequest{
|
||||
TeamID: 1,
|
||||
StartAt: formatTestDateTime(tomorrow, "22:00:00"),
|
||||
EndAt: formatTestDateTime(tomorrow.AddDate(0, 0, 1), "08:00:00"),
|
||||
},
|
||||
}, testOperator())
|
||||
if err == nil {
|
||||
t.Fatalf("expected cross-day update to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "不能跨天") && !strings.Contains(err.Error(), "span multiple days") {
|
||||
t.Fatalf("expected cross-day error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceUpdateRejectsHistoricalScheduleByDay(t *testing.T) {
|
||||
db := setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, db)
|
||||
existingID := createFutureAgentTeamSchedule(t, db)
|
||||
yesterday := time.Now().AddDate(0, 0, -1)
|
||||
|
||||
err := services.AgentTeamScheduleService.UpdateAgentTeamSchedule(request.UpdateAgentTeamScheduleRequest{
|
||||
ID: existingID,
|
||||
CreateAgentTeamScheduleRequest: request.CreateAgentTeamScheduleRequest{
|
||||
TeamID: 1,
|
||||
StartAt: formatTestDateTime(yesterday, "09:00:00"),
|
||||
EndAt: formatTestDateTime(yesterday, "18:00:00"),
|
||||
},
|
||||
}, testOperator())
|
||||
if err == nil {
|
||||
t.Fatalf("expected historical update to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "历史日期") && !strings.Contains(err.Error(), "past date") {
|
||||
t.Fatalf("expected historical date error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceBatchPreviewExpandsSharedRule(t *testing.T) {
|
||||
setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, sqls.DB())
|
||||
nextMonday := nextTestWeekday(time.Monday)
|
||||
nextWednesday := nextMonday.AddDate(0, 0, 2)
|
||||
|
||||
preview, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
|
||||
TeamIDs: []int64{1, 2},
|
||||
StartDate: nextMonday.Format(time.DateOnly),
|
||||
EndDate: nextMonday.AddDate(0, 0, 6).Format(time.DateOnly),
|
||||
Weekdays: []int{1, 3},
|
||||
StartTime: "09:00",
|
||||
EndTime: "18:00",
|
||||
Remark: "工作日白班",
|
||||
}, testOperator())
|
||||
if err != nil {
|
||||
t.Fatalf("BatchPreview() error = %v", err)
|
||||
}
|
||||
if preview.Total != 4 || len(preview.Items) != 4 {
|
||||
t.Fatalf("expected 4 preview items, got total=%d len=%d", preview.Total, len(preview.Items))
|
||||
}
|
||||
if preview.Conflict {
|
||||
t.Fatalf("expected no conflict, got %+v", preview.Items)
|
||||
}
|
||||
teamNames := make(map[int64]string)
|
||||
for _, item := range preview.Items {
|
||||
if item.TeamName == "" {
|
||||
t.Fatalf("expected all preview items to have team names: %+v", preview.Items)
|
||||
}
|
||||
teamNames[item.TeamID] = item.TeamName
|
||||
}
|
||||
if teamNames[1] == "" || teamNames[2] == "" {
|
||||
t.Fatalf("expected team ids 1 and 2 with names, got %v", teamNames)
|
||||
}
|
||||
type previewKey struct {
|
||||
teamID int64
|
||||
date string
|
||||
}
|
||||
itemsByKey := make(map[previewKey]services.AgentTeamScheduleBatchPreviewItem)
|
||||
for _, item := range preview.Items {
|
||||
itemsByKey[previewKey{teamID: item.TeamID, date: item.Date.Format(time.DateOnly)}] = item
|
||||
}
|
||||
expected := []struct {
|
||||
teamID int64
|
||||
date time.Time
|
||||
weekday int
|
||||
}{
|
||||
{teamID: 1, date: nextMonday, weekday: 1},
|
||||
{teamID: 1, date: nextWednesday, weekday: 3},
|
||||
{teamID: 2, date: nextMonday, weekday: 1},
|
||||
{teamID: 2, date: nextWednesday, weekday: 3},
|
||||
}
|
||||
for _, want := range expected {
|
||||
wantDate := want.date.Format(time.DateOnly)
|
||||
item, ok := itemsByKey[previewKey{teamID: want.teamID, date: wantDate}]
|
||||
if !ok {
|
||||
t.Fatalf("expected preview item for teamID=%d date=%s, got %+v", want.teamID, wantDate, preview.Items)
|
||||
}
|
||||
if item.Weekday != want.weekday ||
|
||||
item.StartAt.Format(time.DateTime) != formatTestDateTime(want.date, "09:00:00") ||
|
||||
item.EndAt.Format(time.DateTime) != formatTestDateTime(want.date, "18:00:00") ||
|
||||
item.Remark != "工作日白班" {
|
||||
t.Fatalf("unexpected preview item for teamID=%d date=%s: %+v", want.teamID, wantDate, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceBatchPreviewRejectsHistoricalDate(t *testing.T) {
|
||||
setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, sqls.DB())
|
||||
yesterday := time.Now().AddDate(0, 0, -1)
|
||||
|
||||
_, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
|
||||
TeamIDs: []int64{1},
|
||||
StartDate: yesterday.Format(time.DateOnly),
|
||||
EndDate: yesterday.Format(time.DateOnly),
|
||||
Weekdays: []int{weekdayForRequest(yesterday)},
|
||||
StartTime: "09:00",
|
||||
EndTime: "18:00",
|
||||
}, testOperator())
|
||||
if err == nil {
|
||||
t.Fatalf("expected historical batch preview to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "历史日期") && !strings.Contains(err.Error(), "past date") {
|
||||
t.Fatalf("expected historical date error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceBatchPreviewRejectsInvalidTimeRange(t *testing.T) {
|
||||
setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, sqls.DB())
|
||||
tomorrow := time.Now().AddDate(0, 0, 1)
|
||||
|
||||
_, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
|
||||
TeamIDs: []int64{1},
|
||||
StartDate: tomorrow.Format(time.DateOnly),
|
||||
EndDate: tomorrow.Format(time.DateOnly),
|
||||
Weekdays: []int{weekdayForRequest(tomorrow)},
|
||||
StartTime: "18:00",
|
||||
EndTime: "09:00",
|
||||
}, testOperator())
|
||||
if err == nil {
|
||||
t.Fatalf("expected invalid time range to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "结束时间必须晚于开始时间") && !strings.Contains(err.Error(), "later than start time") {
|
||||
t.Fatalf("expected invalid time range error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceBatchPreviewRejectsOverLimit(t *testing.T) {
|
||||
setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, sqls.DB())
|
||||
today := time.Now()
|
||||
|
||||
_, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
|
||||
TeamIDs: []int64{1, 2},
|
||||
StartDate: today.Format(time.DateOnly),
|
||||
EndDate: today.AddDate(0, 0, 260).Format(time.DateOnly),
|
||||
Weekdays: []int{1, 2, 3, 4, 5, 6, 7},
|
||||
StartTime: "09:00",
|
||||
EndTime: "18:00",
|
||||
}, testOperator())
|
||||
if err == nil {
|
||||
t.Fatalf("expected over-limit preview to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Fatalf("expected 500 limit error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceBatchPreviewMarksConflicts(t *testing.T) {
|
||||
db := setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, db)
|
||||
targetDay := time.Now().AddDate(0, 0, 2)
|
||||
existing := models.AgentTeamSchedule{
|
||||
TeamID: 1,
|
||||
StartAt: parseTestDateTime(t, formatTestDateTime(targetDay, "10:00:00")),
|
||||
EndAt: parseTestDateTime(t, formatTestDateTime(targetDay, "12:00:00")),
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&existing).Error; err != nil {
|
||||
t.Fatalf("create existing schedule error = %v", err)
|
||||
}
|
||||
|
||||
preview, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
|
||||
TeamIDs: []int64{1, 2},
|
||||
StartDate: targetDay.Format(time.DateOnly),
|
||||
EndDate: targetDay.Format(time.DateOnly),
|
||||
Weekdays: []int{weekdayForRequest(targetDay)},
|
||||
StartTime: "09:00",
|
||||
EndTime: "18:00",
|
||||
}, testOperator())
|
||||
if err != nil {
|
||||
t.Fatalf("BatchPreview() error = %v", err)
|
||||
}
|
||||
if !preview.Conflict {
|
||||
t.Fatalf("expected preview conflict, got %+v", preview)
|
||||
}
|
||||
itemsByTeamID := make(map[int64]services.AgentTeamScheduleBatchPreviewItem)
|
||||
for _, item := range preview.Items {
|
||||
itemsByTeamID[item.TeamID] = item
|
||||
}
|
||||
team1Item, ok := itemsByTeamID[1]
|
||||
if !ok {
|
||||
t.Fatalf("expected team 1 preview item, got %+v", preview.Items)
|
||||
}
|
||||
team2Item, ok := itemsByTeamID[2]
|
||||
if !ok {
|
||||
t.Fatalf("expected team 2 preview item, got %+v", preview.Items)
|
||||
}
|
||||
if !team1Item.Conflict || team1Item.ConflictReason == "" {
|
||||
t.Fatalf("expected team 1 preview item to be marked as conflict: %+v", team1Item)
|
||||
}
|
||||
if team2Item.Conflict {
|
||||
t.Fatalf("expected team 2 preview item to have no conflict: %+v", team2Item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceBatchPreviewIgnoresDisabledOverlappingSchedule(t *testing.T) {
|
||||
db := setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, db)
|
||||
targetDay := time.Now().AddDate(0, 0, 2)
|
||||
existing := models.AgentTeamSchedule{
|
||||
TeamID: 1,
|
||||
StartAt: parseTestDateTime(t, formatTestDateTime(targetDay, "10:00:00")),
|
||||
EndAt: parseTestDateTime(t, formatTestDateTime(targetDay, "12:00:00")),
|
||||
Status: enums.StatusDisabled,
|
||||
}
|
||||
if err := db.Create(&existing).Error; err != nil {
|
||||
t.Fatalf("create existing schedule error = %v", err)
|
||||
}
|
||||
|
||||
preview, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
|
||||
TeamIDs: []int64{1},
|
||||
StartDate: targetDay.Format(time.DateOnly),
|
||||
EndDate: targetDay.Format(time.DateOnly),
|
||||
Weekdays: []int{weekdayForRequest(targetDay)},
|
||||
StartTime: "09:00",
|
||||
EndTime: "18:00",
|
||||
}, testOperator())
|
||||
if err != nil {
|
||||
t.Fatalf("BatchPreview() error = %v", err)
|
||||
}
|
||||
if preview.Conflict {
|
||||
t.Fatalf("expected disabled overlapping schedule to be ignored, got %+v", preview)
|
||||
}
|
||||
if len(preview.Items) != 1 || preview.Items[0].Conflict {
|
||||
t.Fatalf("expected one non-conflicting preview item, got %+v", preview.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceBatchGenerateCreatesAllSchedules(t *testing.T) {
|
||||
db := setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, db)
|
||||
nextMonday := nextTestWeekday(time.Monday)
|
||||
|
||||
result, err := services.AgentTeamScheduleService.BatchGenerate(request.AgentTeamScheduleBatchRequest{
|
||||
TeamIDs: []int64{1, 2},
|
||||
StartDate: nextMonday.Format(time.DateOnly),
|
||||
EndDate: nextMonday.AddDate(0, 0, 2).Format(time.DateOnly),
|
||||
Weekdays: []int{1, 3},
|
||||
StartTime: "09:00",
|
||||
EndTime: "18:00",
|
||||
Remark: "批量生成",
|
||||
}, testOperator())
|
||||
if err != nil {
|
||||
t.Fatalf("BatchGenerate() error = %v", err)
|
||||
}
|
||||
if result.Created != 4 {
|
||||
t.Fatalf("expected 4 created schedules, got %d", result.Created)
|
||||
}
|
||||
var schedules []models.AgentTeamSchedule
|
||||
if err := db.Where("remark = ?", "批量生成").
|
||||
Order("team_id ASC, start_at ASC").
|
||||
Find(&schedules).Error; err != nil {
|
||||
t.Fatalf("query generated schedules error = %v", err)
|
||||
}
|
||||
if len(schedules) != 4 {
|
||||
t.Fatalf("expected 4 stored schedules, got %d: %+v", len(schedules), schedules)
|
||||
}
|
||||
expected := []struct {
|
||||
teamID int64
|
||||
startAt string
|
||||
endAt string
|
||||
}{
|
||||
{teamID: 1, startAt: formatTestDateTime(nextMonday, "09:00:00"), endAt: formatTestDateTime(nextMonday, "18:00:00")},
|
||||
{teamID: 1, startAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "09:00:00"), endAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "18:00:00")},
|
||||
{teamID: 2, startAt: formatTestDateTime(nextMonday, "09:00:00"), endAt: formatTestDateTime(nextMonday, "18:00:00")},
|
||||
{teamID: 2, startAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "09:00:00"), endAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "18:00:00")},
|
||||
}
|
||||
for i, want := range expected {
|
||||
got := schedules[i]
|
||||
if got.TeamID != want.teamID ||
|
||||
got.StartAt.Format(time.DateTime) != want.startAt ||
|
||||
got.EndAt.Format(time.DateTime) != want.endAt {
|
||||
t.Fatalf("unexpected schedule at index %d: got teamID=%d startAt=%s endAt=%s, want teamID=%d startAt=%s endAt=%s",
|
||||
i,
|
||||
got.TeamID,
|
||||
got.StartAt.Format(time.DateTime),
|
||||
got.EndAt.Format(time.DateTime),
|
||||
want.teamID,
|
||||
want.startAt,
|
||||
want.endAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceBatchGenerateRejectsConflictsWithoutPartialCreate(t *testing.T) {
|
||||
db := setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, db)
|
||||
targetDay := time.Now().AddDate(0, 0, 2)
|
||||
existing := models.AgentTeamSchedule{
|
||||
TeamID: 1,
|
||||
StartAt: parseTestDateTime(t, formatTestDateTime(targetDay, "10:00:00")),
|
||||
EndAt: parseTestDateTime(t, formatTestDateTime(targetDay, "12:00:00")),
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&existing).Error; err != nil {
|
||||
t.Fatalf("create existing schedule error = %v", err)
|
||||
}
|
||||
|
||||
_, err := services.AgentTeamScheduleService.BatchGenerate(request.AgentTeamScheduleBatchRequest{
|
||||
TeamIDs: []int64{1, 2},
|
||||
StartDate: targetDay.Format(time.DateOnly),
|
||||
EndDate: targetDay.Format(time.DateOnly),
|
||||
Weekdays: []int{weekdayForRequest(targetDay)},
|
||||
StartTime: "09:00",
|
||||
EndTime: "18:00",
|
||||
Remark: "不应创建",
|
||||
}, testOperator())
|
||||
if err == nil {
|
||||
t.Fatalf("expected conflict batch generate to fail")
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.AgentTeamSchedule{}).Where("remark = ?", "不应创建").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("expected no partial creates, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAgentTeamScheduleTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.AgentTeam{}, &models.AgentTeamSchedule{}); err != nil {
|
||||
t.Fatalf("auto migrate error = %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
return db
|
||||
}
|
||||
|
||||
func createAgentTeamScheduleTestData(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
createAgentTeamScheduleTestTeams(t, db)
|
||||
|
||||
parse := func(value string) time.Time {
|
||||
t.Helper()
|
||||
ret, err := time.ParseInLocation(time.DateTime, value, time.Local)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time %q error = %v", value, err)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
schedules := []models.AgentTeamSchedule{
|
||||
{ID: 1, TeamID: 1, StartAt: parse("2026-04-26 20:00:00"), EndAt: parse("2026-04-27 10:00:00"), Status: enums.StatusOk},
|
||||
{ID: 2, TeamID: 1, StartAt: parse("2026-04-28 09:00:00"), EndAt: parse("2026-04-28 18:00:00"), Status: enums.StatusOk},
|
||||
{ID: 3, TeamID: 2, StartAt: parse("2026-05-03 20:00:00"), EndAt: parse("2026-05-04 08:00:00"), Status: enums.StatusOk},
|
||||
{ID: 4, TeamID: 1, StartAt: parse("2026-04-20 09:00:00"), EndAt: parse("2026-04-20 18:00:00"), Status: enums.StatusOk},
|
||||
{ID: 5, TeamID: 2, StartAt: parse("2026-05-04 09:00:00"), EndAt: parse("2026-05-04 18:00:00"), Status: enums.StatusOk},
|
||||
}
|
||||
if err := db.Create(&schedules).Error; err != nil {
|
||||
t.Fatalf("create schedules error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createAgentTeamScheduleTestTeams(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
teams := []models.AgentTeam{
|
||||
{ID: 1, Name: "售前组", Status: enums.StatusOk},
|
||||
{ID: 2, Name: "售后组", Status: enums.StatusOk},
|
||||
}
|
||||
if err := db.Create(&teams).Error; err != nil {
|
||||
t.Fatalf("create teams error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func formatTestDateTime(date time.Time, clock string) string {
|
||||
return date.Format(time.DateOnly) + " " + clock
|
||||
}
|
||||
|
||||
func nextTestWeekday(target time.Weekday) time.Time {
|
||||
ret := startOfTestDay(time.Now()).AddDate(0, 0, 1)
|
||||
for ret.Weekday() != target {
|
||||
ret = ret.AddDate(0, 0, 1)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func startOfTestDay(value time.Time) time.Time {
|
||||
year, month, day := value.In(time.Local).Date()
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
func weekdayForRequest(value time.Time) int {
|
||||
if value.Weekday() == time.Sunday {
|
||||
return 7
|
||||
}
|
||||
return int(value.Weekday())
|
||||
}
|
||||
|
||||
func createFutureAgentTeamSchedule(t *testing.T, db *gorm.DB) int64 {
|
||||
t.Helper()
|
||||
tomorrow := time.Now().AddDate(0, 0, 1)
|
||||
item := models.AgentTeamSchedule{
|
||||
TeamID: 1,
|
||||
StartAt: parseTestDateTime(t, formatTestDateTime(tomorrow, "09:00:00")),
|
||||
EndAt: parseTestDateTime(t, formatTestDateTime(tomorrow, "18:00:00")),
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatalf("create future schedule error = %v", err)
|
||||
}
|
||||
return item.ID
|
||||
}
|
||||
|
||||
func parseTestDateTime(t *testing.T, value string) time.Time {
|
||||
t.Helper()
|
||||
ret, err := time.ParseInLocation(time.DateTime, value, time.Local)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time %q error = %v", value, err)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func testOperator() *dto.AuthPrincipal {
|
||||
return &dto.AuthPrincipal{UserID: 1, Username: "tester", Status: enums.StatusOk}
|
||||
}
|
||||
173
internal/services/agent_team_service.go
Normal file
173
internal/services/agent_team_service.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var AgentTeamService = newAgentTeamService()
|
||||
|
||||
func newAgentTeamService() *agentTeamService {
|
||||
return &agentTeamService{}
|
||||
}
|
||||
|
||||
type agentTeamService struct {
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Get(id int64) *models.AgentTeam {
|
||||
return repositories.AgentTeamRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Take(where ...interface{}) *models.AgentTeam {
|
||||
return repositories.AgentTeamRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Find(cnd *sqls.Cnd) []models.AgentTeam {
|
||||
return repositories.AgentTeamRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) FindOne(cnd *sqls.Cnd) *models.AgentTeam {
|
||||
return repositories.AgentTeamRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) FindPageByParams(params *params.QueryParams) (list []models.AgentTeam, paging *sqls.Paging) {
|
||||
return repositories.AgentTeamRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AgentTeam, paging *sqls.Paging) {
|
||||
return repositories.AgentTeamRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.AgentTeamRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) FindByIds(ids []int64) []models.AgentTeam {
|
||||
return repositories.AgentTeamRepository.FindByIds(sqls.DB(), ids)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Create(t *models.AgentTeam) error {
|
||||
return repositories.AgentTeamRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Update(t *models.AgentTeam) error {
|
||||
return repositories.AgentTeamRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.AgentTeamRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.AgentTeamRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Delete(id int64) {
|
||||
repositories.AgentTeamRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) CreateAgentTeam(req request.CreateAgentTeamRequest, operator *dto.AuthPrincipal) (*models.AgentTeam, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
item, err := s.buildTeamModel(0, req.Name, req.LeaderUserID, req.Status, req.Description, req.Remark)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := repositories.AgentTeamRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *agentTeamService) UpdateAgentTeam(req request.UpdateAgentTeamRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
current := s.Get(req.ID)
|
||||
if current == nil || current.Status == enums.StatusDeleted {
|
||||
return errorsx.InvalidParamI18n("error.e0169")
|
||||
}
|
||||
item, err := s.buildTeamModel(req.ID, req.Name, req.LeaderUserID, req.Status, req.Description, req.Remark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
return repositories.AgentTeamRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"name": item.Name,
|
||||
"leader_user_id": item.LeaderUserID,
|
||||
"status": item.Status,
|
||||
"description": item.Description,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *agentTeamService) DeleteAgentTeam(id int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
current := s.Get(id)
|
||||
if current == nil || current.Status == enums.StatusDeleted {
|
||||
return errorsx.InvalidParamI18n("error.e0169")
|
||||
}
|
||||
if AgentProfileService.Take("team_id = ?", id) != nil {
|
||||
return errorsx.ForbiddenI18n("error.e0167")
|
||||
}
|
||||
if AgentTeamScheduleService.Take("team_id = ?", id) != nil {
|
||||
return errorsx.ForbiddenI18n("error.e0168")
|
||||
}
|
||||
if AIAgentService.Take(
|
||||
"(team_ids = ? OR team_ids LIKE ? OR team_ids LIKE ? OR team_ids LIKE ?) AND status <> ?",
|
||||
utils.JoinInt64s([]int64{id}),
|
||||
utils.JoinInt64s([]int64{id})+",%",
|
||||
"%,"+utils.JoinInt64s([]int64{id}),
|
||||
"%,"+utils.JoinInt64s([]int64{id})+",%",
|
||||
enums.StatusDeleted,
|
||||
) != nil {
|
||||
return errorsx.ForbiddenI18n("error.e0166")
|
||||
}
|
||||
return repositories.AgentTeamRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *agentTeamService) buildTeamModel(id int64, name string, leaderUserID int64, status int, description, remark string) (*models.AgentTeam, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0170")
|
||||
}
|
||||
if exists := s.Take("name = ? AND status <> ? AND id <> ?", name, enums.StatusDeleted, id); exists != nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0171")
|
||||
}
|
||||
if leaderUserID > 0 && UserService.Get(leaderUserID) == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0294")
|
||||
}
|
||||
if status != 0 && status != 1 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0174")
|
||||
}
|
||||
return &models.AgentTeam{
|
||||
Name: name,
|
||||
LeaderUserID: leaderUserID,
|
||||
Status: enums.Status(status),
|
||||
Description: strings.TrimSpace(description),
|
||||
Remark: strings.TrimSpace(remark),
|
||||
}, nil
|
||||
}
|
||||
81
internal/services/agent_tool_invocation_service.go
Normal file
81
internal/services/agent_tool_invocation_service.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
const (
|
||||
agentToolInvocationStatusRunning = "running"
|
||||
agentToolInvocationStatusCompleted = "completed"
|
||||
agentToolInvocationStatusFailed = "failed"
|
||||
)
|
||||
|
||||
var AgentToolInvocationService = newAgentToolInvocationService()
|
||||
|
||||
type AgentToolInvocationClaim struct {
|
||||
Item *models.AgentToolInvocation
|
||||
Completed bool
|
||||
Acquired bool
|
||||
}
|
||||
|
||||
type agentToolInvocationService struct{}
|
||||
|
||||
func newAgentToolInvocationService() *agentToolInvocationService {
|
||||
return &agentToolInvocationService{}
|
||||
}
|
||||
|
||||
// Claim obtains the persistent idempotency boundary. A completed invocation
|
||||
// can be returned to callers; an in-flight invocation is never executed again.
|
||||
func (s *agentToolInvocationService) Claim(conversationID, aiAgentID int64, toolCode, idempotencyKey string) (*AgentToolInvocationClaim, error) {
|
||||
toolCode = strings.TrimSpace(toolCode)
|
||||
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
if conversationID <= 0 || toolCode == "" || idempotencyKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey); item != nil {
|
||||
if item.Status == agentToolInvocationStatusCompleted {
|
||||
return &AgentToolInvocationClaim{Item: item, Completed: true}, nil
|
||||
}
|
||||
if item.Status == agentToolInvocationStatusRunning {
|
||||
return &AgentToolInvocationClaim{Item: item}, nil
|
||||
}
|
||||
if err := repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusRunning, "error_message": "", "updated_at": time.Now()}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Status, item.ErrorMessage = agentToolInvocationStatusRunning, ""
|
||||
return &AgentToolInvocationClaim{Item: item, Acquired: true}, nil
|
||||
}
|
||||
item := &models.AgentToolInvocation{ConversationID: conversationID, AIAgentID: aiAgentID, ToolCode: toolCode, IdempotencyKey: idempotencyKey, Status: agentToolInvocationStatusRunning}
|
||||
if err := repositories.AgentToolInvocationRepository.Create(sqls.DB(), item); err != nil {
|
||||
// A concurrent caller may have created the unique invocation first.
|
||||
if existing := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey); existing != nil {
|
||||
return &AgentToolInvocationClaim{Item: existing, Completed: existing.Status == agentToolInvocationStatusCompleted}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &AgentToolInvocationClaim{Item: item, Acquired: true}, nil
|
||||
}
|
||||
|
||||
func (s *agentToolInvocationService) Complete(item *models.AgentToolInvocation, resultData string) error {
|
||||
if item == nil || item.ID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusCompleted, "result_data": resultData, "error_message": "", "updated_at": time.Now()})
|
||||
}
|
||||
|
||||
func (s *agentToolInvocationService) Fail(item *models.AgentToolInvocation, cause error) error {
|
||||
if item == nil || item.ID <= 0 {
|
||||
return nil
|
||||
}
|
||||
message := ""
|
||||
if cause != nil {
|
||||
message = cause.Error()
|
||||
}
|
||||
return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusFailed, "error_message": message, "updated_at": time.Now()})
|
||||
}
|
||||
65
internal/services/agent_tool_invocation_service_test.go
Normal file
65
internal/services/agent_tool_invocation_service_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestAgentToolInvocationServiceReusesCompletedInvocation(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
first, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create")
|
||||
if err != nil || first == nil || first.Item == nil || first.Completed {
|
||||
t.Fatalf("first claim = %#v, err=%v", first, err)
|
||||
}
|
||||
if err := AgentToolInvocationService.Complete(first.Item, `{"ticketId":40}`); err != nil {
|
||||
t.Fatalf("complete invocation: %v", err)
|
||||
}
|
||||
second, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create")
|
||||
if err != nil || second == nil || !second.Completed || second.Item.ResultData != `{"ticketId":40}` {
|
||||
t.Fatalf("second claim = %#v, err=%v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentToolInvocationServiceAllowsFailedInvocationRetry(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
first, err := AgentToolInvocationService.Claim(11, 21, "graph/handoff_to_human", "message:31:node:handoff")
|
||||
if err != nil {
|
||||
t.Fatalf("first claim: %v", err)
|
||||
}
|
||||
if err := AgentToolInvocationService.Fail(first.Item, errTestToolInvocation); err != nil {
|
||||
t.Fatalf("fail invocation: %v", err)
|
||||
}
|
||||
second, err := AgentToolInvocationService.Claim(11, 21, "graph/handoff_to_human", "message:31:node:handoff")
|
||||
if err != nil || second == nil || second.Completed || second.Item.Status != agentToolInvocationStatusRunning || second.Item.ErrorMessage != "" {
|
||||
t.Fatalf("retry claim = %#v, err=%v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
var errTestToolInvocation = &toolInvocationTestError{}
|
||||
|
||||
type toolInvocationTestError struct{}
|
||||
|
||||
func (e *toolInvocationTestError) Error() string { return "tool failed" }
|
||||
39
internal/services/ai_agent_mcp_policy_test.go
Normal file
39
internal/services/ai_agent_mcp_policy_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
)
|
||||
|
||||
func TestValidateMCPToolRiskPolicyRejectsTrustedToolOverride(t *testing.T) {
|
||||
_, err := validateMCPToolRiskPolicy(request.AIAgentMCPToolRequest{
|
||||
ToolCode: "system/server_time",
|
||||
RiskLevel: "write",
|
||||
RequireConfirmation: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected trusted system tool policy override to be rejected")
|
||||
}
|
||||
|
||||
item, err := validateMCPToolRiskPolicy(request.AIAgentMCPToolRequest{
|
||||
ToolCode: "system/server_time",
|
||||
RiskLevel: "read",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validate trusted system tool policy: %v", err)
|
||||
}
|
||||
if item.Title != "获取当前时间" || item.RiskLevel != "read" || item.RequireConfirmation {
|
||||
t.Fatalf("unexpected normalized trusted policy: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMCPToolRiskPolicyRequiresWriteConfirmation(t *testing.T) {
|
||||
_, err := validateMCPToolRiskPolicy(request.AIAgentMCPToolRequest{
|
||||
ToolCode: "crm/update_customer",
|
||||
RiskLevel: "write",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected write tool without confirmation to be rejected")
|
||||
}
|
||||
}
|
||||
592
internal/services/ai_agent_service.go
Normal file
592
internal/services/ai_agent_service.go
Normal file
@@ -0,0 +1,592 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var AIAgentService = newAIAgentService()
|
||||
|
||||
const defaultNewAgentRolloutPercent = 5
|
||||
|
||||
func newAIAgentService() *aIAgentService {
|
||||
return &aIAgentService{}
|
||||
}
|
||||
|
||||
type aIAgentService struct {
|
||||
}
|
||||
|
||||
func (s *aIAgentService) Get(id int64) *models.AIAgent {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AIAgentRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) Take(where ...interface{}) *models.AIAgent {
|
||||
return repositories.AIAgentRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) Find(cnd *sqls.Cnd) []models.AIAgent {
|
||||
return repositories.AIAgentRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) FindOne(cnd *sqls.Cnd) *models.AIAgent {
|
||||
return repositories.AIAgentRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) FindPageByParams(params *params.QueryParams) (list []models.AIAgent, paging *sqls.Paging) {
|
||||
return repositories.AIAgentRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AIAgent, paging *sqls.Paging) {
|
||||
return repositories.AIAgentRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.AIAgentRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) FindByIds(ids []int64) []models.AIAgent {
|
||||
return repositories.AIAgentRepository.FindByIds(sqls.DB(), ids)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operator *dto.AuthPrincipal) (*models.AIAgent, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
item, err := s.buildAIAgentModel(0, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Status = enums.StatusOk
|
||||
item.SortNo = 0
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
current := s.Get(req.ID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
item, err := s.buildAIAgentModel(req.ID, req.CreateAIAgentRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
columns := map[string]any{
|
||||
"name": item.Name,
|
||||
"description": item.Description,
|
||||
"ai_config_id": item.AIConfigID,
|
||||
"max_steps": item.MaxSteps,
|
||||
"context_window": item.ContextWindow,
|
||||
"tool_policy": item.ToolPolicy,
|
||||
"knowledge_policy": item.KnowledgePolicy,
|
||||
"service_mode": item.ServiceMode,
|
||||
"system_prompt": item.SystemPrompt,
|
||||
"welcome_message": item.WelcomeMessage,
|
||||
"reply_timeout_seconds": item.ReplyTimeoutSeconds,
|
||||
"rollout_percent": item.RolloutPercent,
|
||||
"team_ids": item.TeamIDs,
|
||||
"handoff_mode": item.HandoffMode,
|
||||
"fallback_mode": item.FallbackMode,
|
||||
"fallback_message": item.FallbackMessage,
|
||||
"knowledge_ids": item.KnowledgeIDs,
|
||||
"skill_ids": item.SkillIDs,
|
||||
"allowed_mcp_tools": item.AllowedMCPTools,
|
||||
"auto_handoff_on_failure": item.AutoHandoffOnFailure,
|
||||
"handoff_message": item.HandoffMessage,
|
||||
"off_hours_message": item.OffHoursMessage,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if item.RolloutPercent != current.RolloutPercent {
|
||||
columns["previous_rollout_percent"] = current.RolloutPercent
|
||||
}
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := repositories.AIAgentRepository.Updates(ctx.Tx, req.ID, columns); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.replaceWorkflowBindings(ctx.Tx, req.ID, req.WorkflowBindings, operator)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error {
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
if ChannelService.Take("ai_agent_id = ?", id) != nil {
|
||||
return errorsx.ForbiddenI18n("error.e0185")
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// PublishAIAgent snapshots the complete Agent capability set before it can
|
||||
// receive traffic.
|
||||
func (s *aIAgentService) PublishAIAgent(id int64, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
var revision *models.AgentRevision
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
|
||||
if agent == nil || agent.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
if err := s.validatePublishableAgent(ctx.Tx, agent); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, map[string]any{
|
||||
"published_revision_id": revision.ID,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) validatePublishableAgent(db *gorm.DB, agent *models.AIAgent) error {
|
||||
if agent == nil || agent.AIConfigID <= 0 {
|
||||
return errorsx.InvalidParam("ai agent model configuration is required before publishing")
|
||||
}
|
||||
config := repositories.AIConfigRepository.Get(db, agent.AIConfigID)
|
||||
if config == nil || config.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("ai agent model configuration is unavailable")
|
||||
}
|
||||
if _, err := s.normalizeToolPolicy(agent.ToolPolicy); err != nil {
|
||||
return err
|
||||
}
|
||||
var mcpTools []request.AIAgentMCPToolRequest
|
||||
if raw := strings.TrimSpace(agent.AllowedMCPTools); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &mcpTools); err != nil {
|
||||
return errorsx.InvalidParam("ai agent MCP tools are invalid")
|
||||
}
|
||||
}
|
||||
for _, id := range utils.SplitInt64s(agent.SkillIDs) {
|
||||
skill := repositories.SkillDefinitionRepository.Get(db, id)
|
||||
if skill == nil || skill.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("bound Skill is unavailable")
|
||||
}
|
||||
}
|
||||
for _, item := range mcpTools {
|
||||
definition, err := aitooling.DefaultRegistry.Resolve(item.ToolCode)
|
||||
if err != nil || definition.InputSchema == nil {
|
||||
return errorsx.InvalidParam("ai agent MCP tool definition is unavailable")
|
||||
}
|
||||
if _, err := validateMCPToolRiskPolicy(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, binding := range s.ListEnabledWorkflowBindings(db, agent.ID) {
|
||||
if binding.Version == nil || binding.Version.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("bound workflow version is unavailable")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackAIAgent switches an Agent back to a previously published immutable
|
||||
// revision. It never rewrites the historical snapshot itself.
|
||||
func (s *aIAgentService) RollbackAIAgent(id, revisionID int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if id <= 0 || revisionID <= 0 {
|
||||
return errorsx.InvalidParam("agent id and revision id are required")
|
||||
}
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
|
||||
if agent == nil || agent.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
revision := repositories.AgentRevisionRepository.Get(ctx.Tx, revisionID)
|
||||
if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("agent revision does not exist")
|
||||
}
|
||||
updates := map[string]any{
|
||||
"published_revision_id": revision.ID,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, updates)
|
||||
})
|
||||
}
|
||||
|
||||
// RollbackAIAgentRollout restores the prior Agent rollout percentage and
|
||||
// swaps it into history, allowing operators to undo and redo one rollout
|
||||
// change without rewriting an immutable AgentRevision.
|
||||
func (s *aIAgentService) RollbackAIAgentRollout(id int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if id <= 0 {
|
||||
return errorsx.InvalidParam("agent id is required")
|
||||
}
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
|
||||
if agent == nil || agent.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
if agent.PreviousRolloutPercent < 1 || agent.PreviousRolloutPercent > 100 {
|
||||
return errorsx.InvalidParam("agent rollout has no previous value to restore")
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, map[string]any{
|
||||
"rollout_percent": agent.PreviousRolloutPercent,
|
||||
"previous_rollout_percent": agent.RolloutPercent,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRequest) (*models.AIAgent, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0005")
|
||||
}
|
||||
if exists := s.Take("name = ? AND id <> ?", name, id); exists != nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0006")
|
||||
}
|
||||
if req.AIConfigID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0010")
|
||||
}
|
||||
aiConfig := AIConfigService.Get(req.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0009")
|
||||
}
|
||||
if aiConfig.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0011")
|
||||
}
|
||||
if req.MaxSteps == 0 {
|
||||
req.MaxSteps = 6
|
||||
}
|
||||
if req.MaxSteps < 1 || req.MaxSteps > 8 {
|
||||
return nil, errorsx.InvalidParam("ai agent max steps must be between 1 and 8")
|
||||
}
|
||||
if req.ContextWindow < 0 {
|
||||
return nil, errorsx.InvalidParam("ai agent context window must not be negative")
|
||||
}
|
||||
toolPolicy, err := s.normalizeToolPolicy(req.ToolPolicy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !slices.Contains(enums.IMConversationServiceModeValues, req.ServiceMode) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0230")
|
||||
}
|
||||
teamIDs, err := s.normalizeTeamIDs(req.TeamIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !slices.Contains(enums.AIAgentHandoffModeValues, enums.AIAgentHandoffMode(req.HandoffMode)) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0336")
|
||||
}
|
||||
if req.FallbackMode == 0 {
|
||||
req.FallbackMode = enums.AIAgentFallbackModeNoAnswer
|
||||
}
|
||||
if !slices.Contains(enums.AIAgentFallbackModeValues, enums.AIAgentFallbackMode(req.FallbackMode)) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0123")
|
||||
}
|
||||
if enums.AIAgentHandoffMode(req.HandoffMode) == enums.AIAgentHandoffModeDefaultTeamPool && len(teamIDs) == 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0347")
|
||||
}
|
||||
if req.ReplyTimeoutSeconds < 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0144")
|
||||
}
|
||||
if req.RolloutPercent == 0 {
|
||||
req.RolloutPercent = defaultNewAgentRolloutPercent
|
||||
}
|
||||
if req.RolloutPercent < 1 || req.RolloutPercent > 100 {
|
||||
return nil, errorsx.InvalidParam("ai agent rollout percent must be between 1 and 100")
|
||||
}
|
||||
|
||||
skillIDs, err := s.normalizeSkillIDs(req.SkillIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
knowledgeBaseIDs, err := s.normalizeKnowledgeBaseIDs(req.KnowledgeBaseIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mcpTools, err := s.normalizeMCPTools(req.MCPTools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mcpToolsJSON := ""
|
||||
if len(mcpTools) > 0 {
|
||||
buf, marshalErr := json.Marshal(mcpTools)
|
||||
if marshalErr != nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0021")
|
||||
}
|
||||
mcpToolsJSON = string(buf)
|
||||
}
|
||||
autoHandoffOnFailure := true
|
||||
if req.AutoHandoffOnFailure != nil {
|
||||
autoHandoffOnFailure = *req.AutoHandoffOnFailure
|
||||
}
|
||||
return &models.AIAgent{
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
AIConfigID: req.AIConfigID,
|
||||
MaxSteps: req.MaxSteps,
|
||||
ContextWindow: req.ContextWindow,
|
||||
ToolPolicy: toolPolicy,
|
||||
KnowledgePolicy: strings.TrimSpace(req.KnowledgePolicy),
|
||||
ServiceMode: req.ServiceMode,
|
||||
SystemPrompt: strings.TrimSpace(req.SystemPrompt),
|
||||
WelcomeMessage: strings.TrimSpace(req.WelcomeMessage),
|
||||
ReplyTimeoutSeconds: req.ReplyTimeoutSeconds,
|
||||
RolloutPercent: req.RolloutPercent,
|
||||
TeamIDs: utils.JoinInt64s(teamIDs),
|
||||
HandoffMode: req.HandoffMode,
|
||||
FallbackMode: req.FallbackMode,
|
||||
FallbackMessage: strings.TrimSpace(req.FallbackMessage),
|
||||
KnowledgeIDs: utils.JoinInt64s(knowledgeBaseIDs),
|
||||
SkillIDs: utils.JoinInt64s(skillIDs),
|
||||
AllowedMCPTools: mcpToolsJSON,
|
||||
AutoHandoffOnFailure: autoHandoffOnFailure,
|
||||
HandoffMessage: strings.TrimSpace(req.HandoffMessage),
|
||||
OffHoursMessage: strings.TrimSpace(req.OffHoursMessage),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type normalizedAIAgentToolPolicy struct {
|
||||
MaxTotalCalls int `json:"maxTotalCalls,omitempty"`
|
||||
MaxArgumentBytes int `json:"maxArgumentBytes,omitempty"`
|
||||
AllowedRiskLevels []string `json:"allowedRiskLevels,omitempty"`
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeToolPolicy(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
policy := normalizedAIAgentToolPolicy{}
|
||||
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy must be valid JSON")
|
||||
}
|
||||
if policy.MaxTotalCalls < 0 || policy.MaxTotalCalls > 8 {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy maxTotalCalls must be between 1 and 8")
|
||||
}
|
||||
if policy.MaxArgumentBytes < 0 || policy.MaxArgumentBytes > 64*1024 {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy maxArgumentBytes must be between 1 and 65536")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(policy.AllowedRiskLevels))
|
||||
riskLevels := make([]string, 0, len(policy.AllowedRiskLevels))
|
||||
for _, level := range policy.AllowedRiskLevels {
|
||||
level = strings.ToLower(strings.TrimSpace(level))
|
||||
if level == "" {
|
||||
continue
|
||||
}
|
||||
if level != "read" && level != "write" {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy contains an invalid risk level")
|
||||
}
|
||||
if _, exists := seen[level]; exists {
|
||||
continue
|
||||
}
|
||||
seen[level] = struct{}{}
|
||||
riskLevels = append(riskLevels, level)
|
||||
}
|
||||
policy.AllowedRiskLevels = riskLevels
|
||||
data, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy is invalid")
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeKnowledgeBaseIDs(input []int64) ([]int64, error) {
|
||||
ret := make([]int64, 0, len(input))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range input {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
knowledgeBase := KnowledgeBaseService.Get(id)
|
||||
if knowledgeBase == nil || knowledgeBase.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("knowledge base is not available")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ret = append(ret, id)
|
||||
}
|
||||
slices.Sort(ret)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeTeamIDs(input []int64) ([]int64, error) {
|
||||
ret := make([]int64, 0, len(input))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range input {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
team := AgentTeamService.Get(id)
|
||||
if team == nil || team.Status == enums.StatusDeleted {
|
||||
continue
|
||||
}
|
||||
// if team.Status != enums.StatusOk {
|
||||
// return nil, errorsx.InvalidParamI18n("error.e0173")
|
||||
// }
|
||||
seen[id] = struct{}{}
|
||||
ret = append(ret, id)
|
||||
}
|
||||
slices.Sort(ret)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeSkillIDs(input []int64) ([]int64, error) {
|
||||
ret := make([]int64, 0, len(input))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range input {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
skill := SkillDefinitionService.Get(id)
|
||||
if skill == nil || skill.Status == enums.StatusDeleted {
|
||||
continue
|
||||
}
|
||||
if skill.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0056")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ret = append(ret, id)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeMCPTools(input []request.AIAgentMCPToolRequest) ([]request.AIAgentMCPToolRequest, error) {
|
||||
if len(input) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ret := make([]request.AIAgentMCPToolRequest, 0, len(input))
|
||||
seen := make(map[string]struct{})
|
||||
for _, item := range input {
|
||||
normalized, err := toolx.NormalizeMCPToolRequest(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if toolx.ResolveToolSourceType(normalized.ToolCode) != enums.ToolSourceTypeMCP {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0020")
|
||||
}
|
||||
if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized.RiskLevel = strings.ToLower(strings.TrimSpace(item.RiskLevel))
|
||||
normalized.RequireConfirmation = item.RequireConfirmation
|
||||
normalized, err = validateMCPToolRiskPolicy(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := strings.TrimSpace(normalized.ToolCode)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
ret = append(ret, normalized)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func validateMCPToolRiskPolicy(item request.AIAgentMCPToolRequest) (request.AIAgentMCPToolRequest, error) {
|
||||
if policy, ok := toolx.GetTrustedMCPToolPolicy(item.ToolCode); ok {
|
||||
if item.RiskLevel != policy.RiskLevel || item.RequireConfirmation != policy.RequireConfirmation {
|
||||
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("system MCP tool risk policy cannot be changed")
|
||||
}
|
||||
return toolx.ApplyTrustedMCPToolPolicy(item), nil
|
||||
}
|
||||
if item.RiskLevel != aitooling.RiskLevelRead && item.RiskLevel != aitooling.RiskLevelWrite {
|
||||
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("MCP tool risk level must be read or write")
|
||||
}
|
||||
if item.RiskLevel == aitooling.RiskLevelWrite && !item.RequireConfirmation {
|
||||
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("write MCP tools must require confirmation")
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) UpdateSort(ids []int64) error {
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
for i, id := range ids {
|
||||
if err := repositories.AIAgentRepository.UpdateColumn(ctx.Tx, id, "sort_no", i+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIAgentService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) {
|
||||
return errorsx.InvalidParamI18n("error.e0254")
|
||||
}
|
||||
|
||||
return repositories.AIAgentRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": status,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
80
internal/services/ai_agent_service_test.go
Normal file
80
internal/services/ai_agent_service_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestUpdateAIAgentKeepsPublishedRevisionActive(t *testing.T) {
|
||||
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, dbErr := db.DB()
|
||||
if dbErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.AIConfig{}, &models.AIAgent{}, &models.AIAgentWorkflowBinding{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
config := &models.AIConfig{
|
||||
Name: "test", Status: enums.StatusOk, Provider: enums.AIProviderOpenAI,
|
||||
ModelType: enums.AIModelTypeLLM, ModelName: "test-model",
|
||||
}
|
||||
if err := db.Create(config).Error; err != nil {
|
||||
t.Fatalf("create ai config: %v", err)
|
||||
}
|
||||
agent := &models.AIAgent{
|
||||
Name: "published agent", Status: enums.StatusOk, AIConfigID: config.ID,
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst, HandoffMode: enums.AIAgentHandoffModeWaitPool,
|
||||
FallbackMode: enums.AIAgentFallbackModeNoAnswer, RolloutPercent: 100, PublishedRevisionID: 18,
|
||||
}
|
||||
if err := db.Create(agent).Error; err != nil {
|
||||
t.Fatalf("create ai agent: %v", err)
|
||||
}
|
||||
|
||||
err = AIAgentService.UpdateAIAgent(request.UpdateAIAgentRequest{
|
||||
ID: agent.ID,
|
||||
CreateAIAgentRequest: request.CreateAIAgentRequest{
|
||||
Name: "updated draft", AIConfigID: config.ID,
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
HandoffMode: enums.AIAgentHandoffModeWaitPool,
|
||||
FallbackMode: enums.AIAgentFallbackModeNoAnswer,
|
||||
RolloutPercent: 100,
|
||||
},
|
||||
}, &dto.AuthPrincipal{UserID: 1, Username: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAIAgent: %v", err)
|
||||
}
|
||||
|
||||
var updated models.AIAgent
|
||||
if err := db.First(&updated, agent.ID).Error; err != nil {
|
||||
t.Fatalf("get updated ai agent: %v", err)
|
||||
}
|
||||
if updated.PublishedRevisionID != agent.PublishedRevisionID {
|
||||
t.Fatalf("published revision id = %d, want %d", updated.PublishedRevisionID, agent.PublishedRevisionID)
|
||||
}
|
||||
if updated.Name != "updated draft" {
|
||||
t.Fatalf("draft name = %q, want updated draft", updated.Name)
|
||||
}
|
||||
}
|
||||
70
internal/services/ai_agent_workflow_binding_service.go
Normal file
70
internal/services/ai_agent_workflow_binding_service.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AIAgentWorkflowBindingContext struct {
|
||||
Binding models.AIAgentWorkflowBinding
|
||||
Workflow *models.AIWorkflow
|
||||
Version *models.AIWorkflowVersion
|
||||
}
|
||||
|
||||
func (s *aIAgentService) ListWorkflowBindings(agentID int64) []AIAgentWorkflowBindingContext {
|
||||
bindings := repositories.AIAgentWorkflowBindingRepository.FindByAgentID(sqls.DB(), agentID)
|
||||
return s.buildWorkflowBindingContexts(sqls.DB(), bindings)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) ListEnabledWorkflowBindings(db *gorm.DB, agentID int64) []AIAgentWorkflowBindingContext {
|
||||
return s.buildWorkflowBindingContexts(db, repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agentID))
|
||||
}
|
||||
|
||||
func (s *aIAgentService) buildWorkflowBindingContexts(db *gorm.DB, bindings []models.AIAgentWorkflowBinding) []AIAgentWorkflowBindingContext {
|
||||
ret := make([]AIAgentWorkflowBindingContext, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
ret = append(ret, AIAgentWorkflowBindingContext{Binding: binding, Workflow: repositories.AIWorkflowRepository.Get(db, binding.WorkflowID), Version: repositories.AIWorkflowVersionRepository.Get(db, binding.WorkflowVersionID)})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *aIAgentService) replaceWorkflowBindings(db *gorm.DB, agentID int64, input []request.AIAgentWorkflowBindingRequest, operator *dto.AuthPrincipal) ([]models.AIAgentWorkflowBinding, error) {
|
||||
seen := make(map[int64]struct{}, len(input))
|
||||
items := make([]models.AIAgentWorkflowBinding, 0, len(input))
|
||||
for index, item := range input {
|
||||
if item.WorkflowVersionID <= 0 {
|
||||
return nil, errorsx.InvalidParam("workflow version is required")
|
||||
}
|
||||
if _, exists := seen[item.WorkflowVersionID]; exists {
|
||||
return nil, errorsx.InvalidParam("workflow version must not be bound more than once")
|
||||
}
|
||||
seen[item.WorkflowVersionID] = struct{}{}
|
||||
version := repositories.AIWorkflowVersionRepository.Get(db, item.WorkflowVersionID)
|
||||
if version == nil || version.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("workflow version is not published")
|
||||
}
|
||||
workflow := repositories.AIWorkflowRepository.Get(db, version.WorkflowID)
|
||||
if workflow == nil || workflow.Status == enums.StatusDeleted {
|
||||
return nil, errorsx.InvalidParam("workflow does not exist")
|
||||
}
|
||||
priority := item.Priority
|
||||
if priority == 0 {
|
||||
priority = index + 1
|
||||
}
|
||||
items = append(items, models.AIAgentWorkflowBinding{AIAgentID: agentID, WorkflowID: version.WorkflowID, WorkflowVersionID: version.ID, ToolName: strings.TrimSpace(item.ToolName), TriggerInstruction: strings.TrimSpace(item.TriggerInstruction), Priority: priority, Enabled: item.Enabled, AuditFields: utils.BuildAuditFields(operator)})
|
||||
}
|
||||
if err := repositories.AIAgentWorkflowBindingRepository.ReplaceByAgentID(db, agentID, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
268
internal/services/ai_config_service.go
Normal file
268
internal/services/ai_config_service.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var AIConfigService = newAIConfigService()
|
||||
|
||||
func newAIConfigService() *aIConfigService {
|
||||
return &aIConfigService{}
|
||||
}
|
||||
|
||||
type aIConfigService struct {
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Get(id int64) *models.AIConfig {
|
||||
return repositories.AIConfigRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Take(where ...interface{}) *models.AIConfig {
|
||||
return repositories.AIConfigRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Find(cnd *sqls.Cnd) []models.AIConfig {
|
||||
return repositories.AIConfigRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) FindOne(cnd *sqls.Cnd) *models.AIConfig {
|
||||
return repositories.AIConfigRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) FindPageByParams(params *params.QueryParams) (list []models.AIConfig, paging *sqls.Paging) {
|
||||
return repositories.AIConfigRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AIConfig, paging *sqls.Paging) {
|
||||
return repositories.AIConfigRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.AIConfigRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Create(t *models.AIConfig) error {
|
||||
return repositories.AIConfigRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Update(t *models.AIConfig) error {
|
||||
return repositories.AIConfigRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.AIConfigRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.AIConfigRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Delete(id int64) {
|
||||
repositories.AIConfigRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) CreateAIConfig(req request.CreateAIConfigRequest, operator *dto.AuthPrincipal) (*models.AIConfig, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
item, err := s.buildAIConfigModel(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
item.Status = enums.StatusDisabled
|
||||
item.SortNo = s.nextSortNo()
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := ctx.Tx.Create(item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *aIConfigService) UpdateAIConfig(req request.UpdateAIConfigRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
current := s.Get(req.ID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0012")
|
||||
}
|
||||
item, err := s.buildAIConfigModel(req.CreateAIConfigRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
columns := map[string]any{
|
||||
"name": item.Name,
|
||||
"provider": item.Provider,
|
||||
"base_url": item.BaseURL,
|
||||
"model_type": item.ModelType,
|
||||
"model_name": item.ModelName,
|
||||
"dimension": item.Dimension,
|
||||
"max_context_tokens": item.MaxContextTokens,
|
||||
"max_output_tokens": item.MaxOutputTokens,
|
||||
"timeout_ms": item.TimeoutMS,
|
||||
"max_retry_count": item.MaxRetryCount,
|
||||
"rpm_limit": item.RPMLimit,
|
||||
"tpm_limit": item.TPMLimit,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if item.APIKey != "" {
|
||||
columns["api_key"] = item.APIKey
|
||||
}
|
||||
return repositories.AIConfigRepository.Updates(sqls.DB(), req.ID, columns)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) DeleteAIConfig(id int64, operator *dto.AuthPrincipal) error {
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
if current.Status == enums.StatusOk {
|
||||
return errorsx.ForbiddenI18n("error.e0143")
|
||||
}
|
||||
return repositories.AIConfigRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIConfigService) UpdateStatus(id int64, status enums.Status, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0012")
|
||||
}
|
||||
if status != enums.StatusOk && status != enums.StatusDisabled {
|
||||
return errorsx.InvalidParamI18n("error.e0254")
|
||||
}
|
||||
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if status == enums.StatusOk {
|
||||
if err := s.disableOthersByModelType(ctx, current.ModelType, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return repositories.AIConfigRepository.Updates(ctx.Tx, id, map[string]any{
|
||||
"status": status,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIConfigService) disableOthersByModelType(ctx *sqls.TxContext, modelType enums.AIModelType, excludeID int64) error {
|
||||
query := ctx.Tx.Model(&models.AIConfig{}).Where("model_type = ?", modelType)
|
||||
if excludeID > 0 {
|
||||
query = query.Where("id <> ?", excludeID)
|
||||
}
|
||||
return query.Updates(map[string]any{
|
||||
"status": int(enums.StatusDisabled),
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *aIConfigService) UpdateSort(ids []int64) error {
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
for i, id := range ids {
|
||||
if err := repositories.AIConfigRepository.UpdateColumn(ctx.Tx, id, "sort_no", i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIConfigService) buildAIConfigModel(req request.CreateAIConfigRequest) (*models.AIConfig, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
baseURL := strings.TrimSpace(req.BaseURL)
|
||||
modelName := strings.TrimSpace(req.ModelName)
|
||||
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0339")
|
||||
}
|
||||
if strs.IsBlank(string(req.Provider)) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0122")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0147")
|
||||
}
|
||||
if strs.IsBlank(string(req.ModelType)) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0243")
|
||||
}
|
||||
if modelName == "" {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0242")
|
||||
}
|
||||
if req.Dimension < 0 {
|
||||
req.Dimension = 0
|
||||
}
|
||||
if req.MaxContextTokens < 0 {
|
||||
req.MaxContextTokens = 0
|
||||
}
|
||||
if req.MaxOutputTokens < 0 {
|
||||
req.MaxOutputTokens = 0
|
||||
}
|
||||
if req.TimeoutMS <= 0 {
|
||||
req.TimeoutMS = 30000
|
||||
}
|
||||
if req.MaxRetryCount < 0 {
|
||||
req.MaxRetryCount = 0
|
||||
}
|
||||
if req.RPMLimit < 0 {
|
||||
req.RPMLimit = 0
|
||||
}
|
||||
if req.TPMLimit < 0 {
|
||||
req.TPMLimit = 0
|
||||
}
|
||||
|
||||
return &models.AIConfig{
|
||||
Name: name,
|
||||
Provider: req.Provider,
|
||||
BaseURL: baseURL,
|
||||
APIKey: strings.TrimSpace(req.APIKey),
|
||||
ModelType: req.ModelType,
|
||||
ModelName: modelName,
|
||||
Dimension: req.Dimension,
|
||||
MaxContextTokens: req.MaxContextTokens,
|
||||
MaxOutputTokens: req.MaxOutputTokens,
|
||||
TimeoutMS: req.TimeoutMS,
|
||||
MaxRetryCount: req.MaxRetryCount,
|
||||
RPMLimit: req.RPMLimit,
|
||||
TPMLimit: req.TPMLimit,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *aIConfigService) nextSortNo() int {
|
||||
if latest := s.FindOne(sqls.NewCnd().Desc("sort_no").Desc("id")); latest != nil {
|
||||
return latest.SortNo + 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
82
internal/services/ai_config_service_test.go
Normal file
82
internal/services/ai_config_service_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestUpdateAIConfigKeepsAPIKeyWhenRequestAPIKeyBlank(t *testing.T) {
|
||||
db := setupAIConfigServiceTestDB(t)
|
||||
item := &models.AIConfig{
|
||||
Name: "old",
|
||||
Provider: enums.AIProviderOpenAI,
|
||||
BaseURL: "https://old.example.com",
|
||||
APIKey: "sk-existing",
|
||||
ModelType: enums.AIModelTypeLLM,
|
||||
ModelName: "old-model",
|
||||
TimeoutMS: 30000,
|
||||
AuditFields: models.AuditFields{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
}
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatalf("create ai config error = %v", err)
|
||||
}
|
||||
|
||||
err := AIConfigService.UpdateAIConfig(request.UpdateAIConfigRequest{
|
||||
ID: item.ID,
|
||||
CreateAIConfigRequest: request.CreateAIConfigRequest{
|
||||
Name: "new",
|
||||
Provider: enums.AIProviderOpenAI,
|
||||
BaseURL: "https://new.example.com",
|
||||
APIKey: " ",
|
||||
ModelType: enums.AIModelTypeLLM,
|
||||
ModelName: "new-model",
|
||||
TimeoutMS: 120000,
|
||||
},
|
||||
}, &dto.AuthPrincipal{UserID: 1, Username: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAIConfig() error = %v", err)
|
||||
}
|
||||
|
||||
var updated models.AIConfig
|
||||
if err := db.First(&updated, item.ID).Error; err != nil {
|
||||
t.Fatalf("get updated ai config error = %v", err)
|
||||
}
|
||||
if updated.APIKey != "sk-existing" {
|
||||
t.Fatalf("expected api key to be preserved, got %q", updated.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAIConfigServiceTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.AIConfig{}); err != nil {
|
||||
t.Fatalf("auto migrate error = %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
return db
|
||||
}
|
||||
210
internal/services/ai_copilot_service.go
Normal file
210
internal/services/ai_copilot_service.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var AICopilotService = newAICopilotService()
|
||||
|
||||
func newAICopilotService() *aiCopilotService {
|
||||
return &aiCopilotService{
|
||||
chatFn: ai.LLM.Chat,
|
||||
}
|
||||
}
|
||||
|
||||
type aiCopilotService struct {
|
||||
chatFn func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error)
|
||||
}
|
||||
|
||||
// SetChatFnForTest allows tests outside package services to override LLM chat logic.
|
||||
func (s *aiCopilotService) SetChatFnForTest(fn func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error)) func() {
|
||||
prev := s.chatFn
|
||||
s.chatFn = fn
|
||||
return func() {
|
||||
s.chatFn = prev
|
||||
}
|
||||
}
|
||||
|
||||
// SuggestReply analyzes recent conversation messages and produces a suggested draft reply for human operators.
|
||||
func (s *aiCopilotService) SuggestReply(ctx context.Context, conversationID int64, locale string) (string, error) {
|
||||
if conversationID <= 0 {
|
||||
return "", errorsx.InvalidParamI18n("error.e0064")
|
||||
}
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return "", errorsx.NotFoundI18n("error.e0116")
|
||||
}
|
||||
|
||||
messages := repositories.MessageRepository.Find(
|
||||
sqls.DB(),
|
||||
sqls.NewCnd().Eq("conversation_id", conversationID).Desc("id").Limit(12),
|
||||
)
|
||||
if len(messages) == 0 {
|
||||
return "", errorsx.InvalidParamI18n("error.e0245")
|
||||
}
|
||||
slices.Reverse(messages)
|
||||
|
||||
transcript := s.buildConversationTranscript(messages)
|
||||
if strings.TrimSpace(transcript) == "" {
|
||||
return "", errorsx.InvalidParamI18n("error.e0245")
|
||||
}
|
||||
|
||||
systemPrompt, userPrompt := s.buildSuggestReplyPrompts(transcript, locale)
|
||||
result, err := s.chat(ctx, systemPrompt, userPrompt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result == nil || strings.TrimSpace(result.Content) == "" {
|
||||
return "", fmt.Errorf("empty reply suggestion from model")
|
||||
}
|
||||
|
||||
return result.Content, nil
|
||||
}
|
||||
|
||||
// SummarizeConversation produces a structured summary of the conversation for handoffs or resolution records.
|
||||
func (s *aiCopilotService) SummarizeConversation(ctx context.Context, conversationID int64, locale string) (string, error) {
|
||||
if conversationID <= 0 {
|
||||
return "", errorsx.InvalidParamI18n("error.e0064")
|
||||
}
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return "", errorsx.NotFoundI18n("error.e0116")
|
||||
}
|
||||
|
||||
messages := repositories.MessageRepository.Find(
|
||||
sqls.DB(),
|
||||
sqls.NewCnd().Eq("conversation_id", conversationID).Desc("id").Limit(20),
|
||||
)
|
||||
if len(messages) == 0 {
|
||||
return "", errorsx.InvalidParamI18n("error.e0245")
|
||||
}
|
||||
slices.Reverse(messages)
|
||||
|
||||
transcript := s.buildConversationTranscript(messages)
|
||||
if strings.TrimSpace(transcript) == "" {
|
||||
return "", errorsx.InvalidParamI18n("error.e0245")
|
||||
}
|
||||
|
||||
systemPrompt, userPrompt := s.buildSummarizePrompts(transcript, locale)
|
||||
result, err := s.chat(ctx, systemPrompt, userPrompt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result == nil || strings.TrimSpace(result.Content) == "" {
|
||||
return "", fmt.Errorf("empty summary from model")
|
||||
}
|
||||
|
||||
return result.Content, nil
|
||||
}
|
||||
|
||||
func (s *aiCopilotService) chat(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
|
||||
if s.chatFn != nil {
|
||||
return s.chatFn(ctx, systemPrompt, userPrompt)
|
||||
}
|
||||
return ai.LLM.Chat(ctx, systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (s *aiCopilotService) buildConversationTranscript(messages []models.Message) string {
|
||||
var lines []string
|
||||
for _, msg := range messages {
|
||||
if msg.RecalledAt != nil || msg.SendStatus == enums.IMMessageStatusRecalled {
|
||||
continue
|
||||
}
|
||||
text := s.extractMessageText(msg)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
switch msg.SenderType {
|
||||
case enums.IMSenderTypeCustomer:
|
||||
lines = append(lines, "Customer: "+text)
|
||||
case enums.IMSenderTypeAgent, enums.IMSenderTypeAI:
|
||||
lines = append(lines, "Agent: "+text)
|
||||
case enums.IMSenderTypeSystem:
|
||||
// System notifications are intentionally excluded from operator transcript
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (s *aiCopilotService) extractMessageText(msg models.Message) string {
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
var text string
|
||||
switch msg.MessageType {
|
||||
case enums.IMMessageTypeHTML:
|
||||
summary := utils.BuildHTMLSummary(content)
|
||||
if summary != "" {
|
||||
text = summary
|
||||
} else {
|
||||
text = content
|
||||
}
|
||||
case enums.IMMessageTypeImage:
|
||||
if content != "" {
|
||||
text = fmt.Sprintf("[Image: %s]", content)
|
||||
} else {
|
||||
text = "[Image]"
|
||||
}
|
||||
case enums.IMMessageTypeAttachment:
|
||||
if content != "" {
|
||||
text = fmt.Sprintf("[Attachment: %s]", content)
|
||||
} else {
|
||||
text = "[Attachment]"
|
||||
}
|
||||
default:
|
||||
text = content
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) > 2000 {
|
||||
return string(runes[:2000])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (s *aiCopilotService) buildSuggestReplyPrompts(transcript, locale string) (string, string) {
|
||||
normalized := i18nx.NormalizeLocale(locale)
|
||||
switch normalized {
|
||||
case i18nx.LocaleZhCN:
|
||||
systemPrompt := "你是一位专业的智能客服助手。请根据对话历史,为客服人员草拟一条针对客户最新诉求的专业、礼貌、准确且简洁的回复建议。回复语言必须与客户在对话中使用的语言保持一致。"
|
||||
userPrompt := "对话记录:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\n请草拟一条回复建议:"
|
||||
return systemPrompt, userPrompt
|
||||
case i18nx.LocaleRuRU:
|
||||
systemPrompt := "Вы — эксперт и ассистент оператора службы поддержки клиентов. На основе истории диалога составьте полезный, профессиональный, вежливый и краткий вариант ответа на последний запрос клиента. Ответ должен быть на том языке, на котором пишет клиент."
|
||||
userPrompt := "История диалога:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nПредложите вариант ответа:"
|
||||
return systemPrompt, userPrompt
|
||||
default:
|
||||
systemPrompt := "You are an expert customer support co-pilot. Based on the conversation history, draft a helpful, professional, polite, and concise reply to the customer's latest inquiry. Reply in the language used by the customer in the conversation."
|
||||
userPrompt := "Conversation history:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nDraft a suggested reply:"
|
||||
return systemPrompt, userPrompt
|
||||
}
|
||||
}
|
||||
|
||||
func (s *aiCopilotService) buildSummarizePrompts(transcript, locale string) (string, string) {
|
||||
normalized := i18nx.NormalizeLocale(locale)
|
||||
switch normalized {
|
||||
case i18nx.LocaleZhCN:
|
||||
systemPrompt := "你是一位专业的客服对话总结助手。请提供此客服对话的简明扼要总结,包含:1. 核心问题/诉求,2. 已采取的措施/已提供的解答,3. 当前状态/后续跟进事项。"
|
||||
userPrompt := "对话记录:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\n请总结此对话:"
|
||||
return systemPrompt, userPrompt
|
||||
case i18nx.LocaleRuRU:
|
||||
systemPrompt := "Вы — ассистент по подведению итогов диалогов службы поддержки. Предоставьте краткую сводку диалога, включая: 1. Основная проблема / вопрос, 2. Принятые меры / предоставленные ответы, 3. Текущий статус / следующие шаги."
|
||||
userPrompt := "История диалога:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nПодведите итог диалога:"
|
||||
return systemPrompt, userPrompt
|
||||
default:
|
||||
systemPrompt := "You are a customer support summarizer. Provide a concise summary of this customer support conversation including: 1. Main Issue / Inquiry, 2. Actions taken / Answers provided, 3. Current Status / Next Steps."
|
||||
userPrompt := "Conversation history:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nSummarize this conversation:"
|
||||
return systemPrompt, userPrompt
|
||||
}
|
||||
}
|
||||
414
internal/services/ai_copilot_service_test.go
Normal file
414
internal/services/ai_copilot_service_test.go
Normal file
@@ -0,0 +1,414 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/ai"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func setupAICopilotTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.Conversation{}, &models.Message{}); err != nil {
|
||||
t.Fatalf("auto migrate error = %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAICopilotService_BuildConversationTranscript(t *testing.T) {
|
||||
recalledTime := time.Now()
|
||||
messages := []models.Message{
|
||||
{
|
||||
ID: 1,
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "Hello, I need help with my account",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
SenderType: enums.IMSenderTypeAgent,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "Hi there! What issue are you experiencing?",
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeHTML,
|
||||
Content: "<p>I see an <b>error code 500</b> on checkout.</p>",
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeImage,
|
||||
Content: "screenshot.png",
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeAttachment,
|
||||
Content: "log.txt",
|
||||
},
|
||||
{
|
||||
ID: 6,
|
||||
SenderType: enums.IMSenderTypeAgent,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "This was recalled",
|
||||
RecalledAt: &recalledTime,
|
||||
SendStatus: enums.IMMessageStatusRecalled,
|
||||
},
|
||||
{
|
||||
ID: 7,
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: " ", // empty content should be ignored
|
||||
},
|
||||
{
|
||||
ID: 8,
|
||||
SenderType: enums.IMSenderTypeAI,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "Our system is checking your account now.",
|
||||
},
|
||||
}
|
||||
|
||||
svc := newAICopilotService()
|
||||
transcript := svc.buildConversationTranscript(messages)
|
||||
|
||||
expectedLines := []string{
|
||||
"Customer: Hello, I need help with my account",
|
||||
"Agent: Hi there! What issue are you experiencing?",
|
||||
"Customer: I see an error code 500 on checkout.",
|
||||
"Customer: [Image: screenshot.png]",
|
||||
"Customer: [Attachment: log.txt]",
|
||||
"Agent: Our system is checking your account now.",
|
||||
}
|
||||
|
||||
for _, line := range expectedLines {
|
||||
if !strings.Contains(transcript, line) {
|
||||
t.Errorf("transcript missing expected line: %q\nFull transcript:\n%s", line, transcript)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(transcript, "This was recalled") {
|
||||
t.Errorf("recalled message should not appear in transcript: %s", transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAICopilotService_BuildSuggestReplyPrompts_Locales(t *testing.T) {
|
||||
svc := newAICopilotService()
|
||||
transcript := "Customer: How do I change password?"
|
||||
|
||||
locales := []struct {
|
||||
locale string
|
||||
wantSystemContent string
|
||||
wantUserPrefix string
|
||||
}{
|
||||
{
|
||||
locale: i18nx.LocaleEnUS,
|
||||
wantSystemContent: "expert customer support co-pilot",
|
||||
wantUserPrefix: "Conversation history:",
|
||||
},
|
||||
{
|
||||
locale: i18nx.LocaleZhCN,
|
||||
wantSystemContent: "智能客服助手",
|
||||
wantUserPrefix: "对话记录:",
|
||||
},
|
||||
{
|
||||
locale: i18nx.LocaleRuRU,
|
||||
wantSystemContent: "ассистент оператора службы поддержки",
|
||||
wantUserPrefix: "История диалога:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range locales {
|
||||
t.Run(tc.locale, func(t *testing.T) {
|
||||
sysPrompt, usrPrompt := svc.buildSuggestReplyPrompts(transcript, tc.locale)
|
||||
if !strings.Contains(sysPrompt, tc.wantSystemContent) {
|
||||
t.Errorf("sysPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantSystemContent, sysPrompt)
|
||||
}
|
||||
if !strings.Contains(usrPrompt, tc.wantUserPrefix) {
|
||||
t.Errorf("usrPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantUserPrefix, usrPrompt)
|
||||
}
|
||||
if !strings.Contains(usrPrompt, "<conversation_history>\n"+transcript+"\n</conversation_history>") {
|
||||
t.Errorf("usrPrompt for locale %s did not contain tagged transcript, got: %s", tc.locale, usrPrompt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAICopilotService_BuildSummarizePrompts_Locales(t *testing.T) {
|
||||
svc := newAICopilotService()
|
||||
transcript := "Customer: My account is locked.\nAgent: Let me unlock it."
|
||||
|
||||
locales := []struct {
|
||||
locale string
|
||||
wantSystemContent string
|
||||
wantUserPrefix string
|
||||
}{
|
||||
{
|
||||
locale: i18nx.LocaleEnUS,
|
||||
wantSystemContent: "customer support summarizer",
|
||||
wantUserPrefix: "Conversation history:",
|
||||
},
|
||||
{
|
||||
locale: i18nx.LocaleZhCN,
|
||||
wantSystemContent: "客服对话总结助手",
|
||||
wantUserPrefix: "对话记录:",
|
||||
},
|
||||
{
|
||||
locale: i18nx.LocaleRuRU,
|
||||
wantSystemContent: "ассистент по подведению итогов",
|
||||
wantUserPrefix: "История диалога:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range locales {
|
||||
t.Run(tc.locale, func(t *testing.T) {
|
||||
sysPrompt, usrPrompt := svc.buildSummarizePrompts(transcript, tc.locale)
|
||||
if !strings.Contains(sysPrompt, tc.wantSystemContent) {
|
||||
t.Errorf("sysPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantSystemContent, sysPrompt)
|
||||
}
|
||||
if !strings.Contains(usrPrompt, tc.wantUserPrefix) {
|
||||
t.Errorf("usrPrompt for locale %s did not contain %q, got: %s", tc.locale, tc.wantUserPrefix, usrPrompt)
|
||||
}
|
||||
if !strings.Contains(usrPrompt, "<conversation_history>\n"+transcript+"\n</conversation_history>") {
|
||||
t.Errorf("usrPrompt for locale %s did not contain tagged transcript, got: %s", tc.locale, usrPrompt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAICopilotService_ExtractMessageText_RuneLimit(t *testing.T) {
|
||||
svc := newAICopilotService()
|
||||
longText := strings.Repeat("a", 2500)
|
||||
msg := models.Message{
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: longText,
|
||||
}
|
||||
extracted := svc.extractMessageText(msg)
|
||||
if len([]rune(extracted)) != 2000 {
|
||||
t.Fatalf("expected extracted length to be 2000 runes, got %d", len([]rune(extracted)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAICopilotService_SuggestReply_ValidationAndErrors(t *testing.T) {
|
||||
db := setupAICopilotTestDB(t)
|
||||
ctx := context.Background()
|
||||
svc := newAICopilotService()
|
||||
|
||||
// 1. Invalid conversation ID <= 0
|
||||
_, err := svc.SuggestReply(ctx, 0, "en-US")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for conversationID <= 0, got nil")
|
||||
}
|
||||
|
||||
// 2. Conversation not found
|
||||
_, err = svc.SuggestReply(ctx, 99999, "en-US")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent conversation, got nil")
|
||||
}
|
||||
if i18nErr, ok := err.(*errorsx.I18nError); !ok || i18nErr.Key != "error.e0116" {
|
||||
t.Fatalf("expected error.e0116 for not found conversation, got: %v", err)
|
||||
}
|
||||
|
||||
// 3. Conversation exists but has no messages
|
||||
conv := &models.Conversation{
|
||||
ID: 101,
|
||||
Subject: "Empty conversation",
|
||||
}
|
||||
if err := db.Create(conv).Error; err != nil {
|
||||
t.Fatalf("failed to create conv: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.SuggestReply(ctx, conv.ID, "en-US")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when conversation has no messages, got nil")
|
||||
}
|
||||
|
||||
// 4. Conversation has only recalled messages
|
||||
recalledTime := time.Now()
|
||||
msg := &models.Message{
|
||||
ID: 1,
|
||||
ConversationID: conv.ID,
|
||||
ClientMsgID: "msg_1",
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "recalled text",
|
||||
RecalledAt: &recalledTime,
|
||||
SendStatus: enums.IMMessageStatusRecalled,
|
||||
}
|
||||
if err := db.Create(msg).Error; err != nil {
|
||||
t.Fatalf("failed to create msg: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.SuggestReply(ctx, conv.ID, "en-US")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when conversation has only recalled messages, got nil")
|
||||
}
|
||||
|
||||
// 5. LLM chat failure
|
||||
validMsg := &models.Message{
|
||||
ID: 2,
|
||||
ConversationID: conv.ID,
|
||||
ClientMsgID: "msg_2",
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "Can you help me reset my password?",
|
||||
}
|
||||
if err := db.Create(validMsg).Error; err != nil {
|
||||
t.Fatalf("failed to create valid msg: %v", err)
|
||||
}
|
||||
|
||||
svc.chatFn = func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
|
||||
return nil, errors.New("upstream llm timeout")
|
||||
}
|
||||
|
||||
_, err = svc.SuggestReply(ctx, conv.ID, "en-US")
|
||||
if err == nil || !strings.Contains(err.Error(), "upstream llm timeout") {
|
||||
t.Fatalf("expected llm error to propagate, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAICopilotService_SuggestReply_Success(t *testing.T) {
|
||||
db := setupAICopilotTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
conv := &models.Conversation{
|
||||
ID: 201,
|
||||
Subject: "Password Reset",
|
||||
}
|
||||
if err := db.Create(conv).Error; err != nil {
|
||||
t.Fatalf("create conversation failed: %v", err)
|
||||
}
|
||||
|
||||
msg := &models.Message{
|
||||
ID: 10,
|
||||
ConversationID: conv.ID,
|
||||
ClientMsgID: "msg_10",
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "I forgot my password, can you help?",
|
||||
}
|
||||
if err := db.Create(msg).Error; err != nil {
|
||||
t.Fatalf("create message failed: %v", err)
|
||||
}
|
||||
|
||||
var capturedSys, capturedUser string
|
||||
svc := newAICopilotService()
|
||||
svc.chatFn = func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
|
||||
capturedSys = systemPrompt
|
||||
capturedUser = userPrompt
|
||||
return &ai.ChatCompletionResult{
|
||||
Content: "To reset your password, please click the 'Forgot Password' link on the login page.",
|
||||
ModelName: "gpt-4o",
|
||||
}, nil
|
||||
}
|
||||
|
||||
suggestion, err := svc.SuggestReply(ctx, conv.ID, "en-US")
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestReply failed: %v", err)
|
||||
}
|
||||
|
||||
expectedSuggestion := "To reset your password, please click the 'Forgot Password' link on the login page."
|
||||
if suggestion != expectedSuggestion {
|
||||
t.Fatalf("expected suggestion %q, got %q", expectedSuggestion, suggestion)
|
||||
}
|
||||
if !strings.Contains(capturedSys, "expert customer support co-pilot") {
|
||||
t.Errorf("captured system prompt missing expected instructions: %s", capturedSys)
|
||||
}
|
||||
if !strings.Contains(capturedUser, "Customer: I forgot my password, can you help?") {
|
||||
t.Errorf("captured user prompt missing message transcript: %s", capturedUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAICopilotService_SummarizeConversation_Success(t *testing.T) {
|
||||
db := setupAICopilotTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
conv := &models.Conversation{
|
||||
ID: 301,
|
||||
Subject: "Billing Inquiry",
|
||||
}
|
||||
if err := db.Create(conv).Error; err != nil {
|
||||
t.Fatalf("create conversation failed: %v", err)
|
||||
}
|
||||
|
||||
messages := []models.Message{
|
||||
{
|
||||
ID: 30,
|
||||
ConversationID: conv.ID,
|
||||
ClientMsgID: "msg_30",
|
||||
SenderType: enums.IMSenderTypeCustomer,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "Why was I charged twice for subscription?",
|
||||
},
|
||||
{
|
||||
ID: 31,
|
||||
ConversationID: conv.ID,
|
||||
ClientMsgID: "msg_31",
|
||||
SenderType: enums.IMSenderTypeAgent,
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "I see the duplicate transaction and have initiated a full refund for the second charge.",
|
||||
},
|
||||
}
|
||||
for _, m := range messages {
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
t.Fatalf("create message failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var capturedSys, capturedUser string
|
||||
svc := newAICopilotService()
|
||||
svc.chatFn = func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
|
||||
capturedSys = systemPrompt
|
||||
capturedUser = userPrompt
|
||||
return &ai.ChatCompletionResult{
|
||||
Content: "1. Main Issue: Duplicate subscription charge.\n2. Actions taken: Initiated full refund for duplicate charge.\n3. Current Status: Resolved.",
|
||||
ModelName: "gpt-4o",
|
||||
}, nil
|
||||
}
|
||||
|
||||
summary, err := svc.SummarizeConversation(ctx, conv.ID, "en-US")
|
||||
if err != nil {
|
||||
t.Fatalf("SummarizeConversation failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(summary, "Duplicate subscription charge") {
|
||||
t.Fatalf("unexpected summary: %s", summary)
|
||||
}
|
||||
if !strings.Contains(capturedSys, "customer support summarizer") {
|
||||
t.Errorf("captured system prompt missing summarizer instructions: %s", capturedSys)
|
||||
}
|
||||
if !strings.Contains(capturedUser, "Customer: Why was I charged twice for subscription?") {
|
||||
t.Errorf("captured user prompt missing transcript: %s", capturedUser)
|
||||
}
|
||||
}
|
||||
5
internal/services/ai_reply_hook.go
Normal file
5
internal/services/ai_reply_hook.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package services
|
||||
|
||||
import "agent-desk/internal/models"
|
||||
|
||||
var TriggerAIReplyAsyncHook func(conversation models.Conversation, message models.Message)
|
||||
649
internal/services/ai_workflow_service.go
Normal file
649
internal/services/ai_workflow_service.go
Normal file
@@ -0,0 +1,649 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
workflowvalidator "agent-desk/internal/ai/workflow/validator"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var AIWorkflowService = newAIWorkflowService()
|
||||
|
||||
func newAIWorkflowService() *aiWorkflowService {
|
||||
return &aiWorkflowService{
|
||||
registry: workflowregistry.DefaultRegistry(),
|
||||
}
|
||||
}
|
||||
|
||||
type aiWorkflowService struct {
|
||||
registry *workflowregistry.Registry
|
||||
}
|
||||
|
||||
type AIWorkflowRunAuditItem struct {
|
||||
Run models.AIWorkflowRun
|
||||
Workflow *models.AIWorkflow
|
||||
Version *models.AIWorkflowVersion
|
||||
Agent *models.AIAgent
|
||||
}
|
||||
|
||||
type AIWorkflowTemplate struct {
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
Definition dsl.Definition
|
||||
}
|
||||
|
||||
type AIWorkflowUsageItem struct {
|
||||
Binding models.AIAgentWorkflowBinding
|
||||
Agent *models.AIAgent
|
||||
Version *models.AIWorkflowVersion
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) Get(id int64) *models.AIWorkflow {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AIWorkflowRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) GetVersion(id int64) *models.AIWorkflowVersion {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AIWorkflowVersionRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AIWorkflow, paging *sqls.Paging) {
|
||||
return repositories.AIWorkflowRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) FindVersionPageByParams(params *params.QueryParams) (list []models.AIWorkflowVersion, paging *sqls.Paging) {
|
||||
return repositories.AIWorkflowVersionRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) FindRunPageByCnd(cnd *sqls.Cnd) (list []models.AIWorkflowRun, paging *sqls.Paging) {
|
||||
return repositories.AIWorkflowRunRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) BuildRunAuditItems(list []models.AIWorkflowRun) []AIWorkflowRunAuditItem {
|
||||
ret := make([]AIWorkflowRunAuditItem, 0, len(list))
|
||||
if len(list) == 0 {
|
||||
return ret
|
||||
}
|
||||
workflowIDs := make([]int64, 0, len(list))
|
||||
versionIDs := make([]int64, 0, len(list))
|
||||
agentIDs := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
workflowIDs = appendNonZeroInt64(workflowIDs, item.WorkflowID)
|
||||
versionIDs = appendNonZeroInt64(versionIDs, item.WorkflowVersionID)
|
||||
agentIDs = appendNonZeroInt64(agentIDs, item.AIAgentID)
|
||||
}
|
||||
var workflows []models.AIWorkflow
|
||||
if len(workflowIDs) > 0 {
|
||||
workflows = repositories.AIWorkflowRepository.Find(sqls.DB(), sqls.NewCnd().In("id", workflowIDs))
|
||||
}
|
||||
var versions []models.AIWorkflowVersion
|
||||
if len(versionIDs) > 0 {
|
||||
versions = repositories.AIWorkflowVersionRepository.Find(sqls.DB(), sqls.NewCnd().In("id", versionIDs))
|
||||
}
|
||||
var agents []models.AIAgent
|
||||
if len(agentIDs) > 0 {
|
||||
agents = repositories.AIAgentRepository.Find(sqls.DB(), sqls.NewCnd().In("id", agentIDs))
|
||||
}
|
||||
workflowByID := make(map[int64]*models.AIWorkflow, len(workflows))
|
||||
for i := range workflows {
|
||||
item := workflows[i]
|
||||
workflowByID[item.ID] = &item
|
||||
}
|
||||
versionByID := make(map[int64]*models.AIWorkflowVersion, len(versions))
|
||||
for i := range versions {
|
||||
item := versions[i]
|
||||
versionByID[item.ID] = &item
|
||||
}
|
||||
agentByID := make(map[int64]*models.AIAgent, len(agents))
|
||||
for i := range agents {
|
||||
item := agents[i]
|
||||
agentByID[item.ID] = &item
|
||||
}
|
||||
for _, run := range list {
|
||||
ret = append(ret, AIWorkflowRunAuditItem{
|
||||
Run: run,
|
||||
Workflow: workflowByID[run.WorkflowID],
|
||||
Version: versionByID[run.WorkflowVersionID],
|
||||
Agent: agentByID[run.AIAgentID],
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) GetRunDetail(id int64) (*models.AIWorkflowRun, []models.AIWorkflowNodeRun) {
|
||||
if id <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
run := repositories.AIWorkflowRunRepository.Get(sqls.DB(), id)
|
||||
if run == nil {
|
||||
return nil, nil
|
||||
}
|
||||
nodes := repositories.AIWorkflowNodeRunRepository.Find(sqls.DB(), sqls.NewCnd().Eq("workflow_run_id", id).Asc("id"))
|
||||
return run, nodes
|
||||
}
|
||||
|
||||
func appendNonZeroInt64(list []int64, value int64) []int64 {
|
||||
if value <= 0 {
|
||||
return list
|
||||
}
|
||||
for _, item := range list {
|
||||
if item == value {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(list, value)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) ListNodeSpecs() []workflowregistry.NodeSpec {
|
||||
return s.registry.List()
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) DefaultAgentWorkflowDefinition() dsl.Definition {
|
||||
return defaultAgentWorkflowDefinition()
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) ListWorkflowTemplates() []AIWorkflowTemplate {
|
||||
return []AIWorkflowTemplate{
|
||||
{Code: "ticket-with-confirmation", Name: "创建工单", Description: "整理工单草稿,经客户确认后创建工单。", Definition: ticketWithConfirmationWorkflowDefinition()},
|
||||
{Code: "identity-confirmation", Name: "身份确认", Description: "在执行后续业务前收集客户的明确确认。", Definition: identityConfirmationWorkflowDefinition()},
|
||||
{Code: "complaint-escalation", Name: "投诉升级", Description: "投诉场景经客户确认后转入人工客服处理。", Definition: complaintEscalationWorkflowDefinition()},
|
||||
{Code: "refund-request-preparation", Name: "退款申请准备", Description: "整理退款诉求,确认后转人工继续核验和处理。", Definition: refundRequestPreparationWorkflowDefinition()},
|
||||
}
|
||||
}
|
||||
|
||||
func ticketWithConfirmationWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
|
||||
workflowNode("draft_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 600, 180, workflowInputs("issue", "start_1", "userMessage"), nil),
|
||||
workflowNode("ready_route_1", workflowregistry.NodeTypeCondition, "草稿分流", 1020, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("ready", "草稿完整", "prompt_1", "draft_1", "ready", "is_true", nil),
|
||||
{ID: "default", Name: "补充信息", TargetNodeID: "followup_1", Default: true},
|
||||
}}),
|
||||
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认", 1440, 100, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "ticketTitle": dsl.RefValue("draft_1", "title"), "ticketDescription": dsl.RefValue("draft_1", "description")}, map[string]any{"staticReply": "我已整理工单草稿:{{ticketTitle}}。请确认是否创建。"}),
|
||||
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 1860, 100, workflowInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
workflowNode("confirm_route_1", workflowregistry.NodeTypeCondition, "确认分流", 2280, 100, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "create_1", "confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消", TargetNodeID: "cancel_1", Default: true},
|
||||
}}),
|
||||
workflowNode("create_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 2700, 20, map[string]dsl.Value{"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil),
|
||||
workflowNode("followup_1", workflowregistry.NodeTypeLLMReply, "补充信息", 1440, 330, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "followUpQuestions": dsl.RefValue("draft_1", "followUpQuestions")}, map[string]any{"staticReply": "创建工单前还需要补充:{{followUpQuestions}}"}),
|
||||
workflowNode("cancel_1", workflowregistry.NodeTypeLLMReply, "取消提示", 2700, 200, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。"}),
|
||||
workflowNode("send_result_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 3120, 20, workflowInputs("replyText", "create_1", "message"), nil),
|
||||
workflowNode("send_followup_1", workflowregistry.NodeTypeSendReply, "发送补充提示", 1860, 330, workflowInputs("replyText", "followup_1", "replyText"), nil),
|
||||
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3120, 200, workflowInputs("replyText", "cancel_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 3540, 180, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
workflowEdge("start_1", "draft_1"), workflowEdge("draft_1", "ready_route_1"), workflowPortEdge("ready_route_1", "prompt_1", "ready"), workflowPortEdge("ready_route_1", "followup_1", "default"),
|
||||
workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "confirm_route_1"), workflowPortEdge("confirm_route_1", "create_1", "confirmed"), workflowPortEdge("confirm_route_1", "cancel_1", "default"),
|
||||
workflowEdge("create_1", "send_result_1"), workflowEdge("send_result_1", "end_1"), workflowEdge("followup_1", "send_followup_1"), workflowEdge("send_followup_1", "end_1"), workflowEdge("cancel_1", "send_cancel_1"), workflowEdge("send_cancel_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) ValidateDefinition(def dsl.Definition) workflowvalidator.Result {
|
||||
return workflowvalidator.ValidateDefinition(def, s.registry)
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) CreateWorkflow(req request.CreateAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflow, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("workflow name is required")
|
||||
}
|
||||
definition, err := marshalDefinition(req.Definition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := &models.AIWorkflow{Name: name, Description: strings.TrimSpace(req.Description), Status: enums.StatusOk, DraftDefinition: definition, AuditFields: utils.BuildAuditFields(operator)}
|
||||
if err := repositories.AIWorkflowRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) UpdateWorkflow(req request.UpdateAIWorkflowRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if s.Get(req.ID) == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return errorsx.InvalidParam("workflow name is required")
|
||||
}
|
||||
definition, err := marshalDefinition(req.Definition)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repositories.AIWorkflowRepository.Updates(sqls.DB(), req.ID, map[string]interface{}{
|
||||
"name": name,
|
||||
"description": strings.TrimSpace(req.Description),
|
||||
"draft_definition": definition,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) DeleteWorkflow(id int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if s.Get(id) == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
if repositories.AIAgentWorkflowBindingRepository.CountByWorkflowID(sqls.DB(), id) > 0 {
|
||||
return errorsx.InvalidParam("workflow is still associated with an agent")
|
||||
}
|
||||
return repositories.AIWorkflowRepository.Updates(sqls.DB(), id, map[string]interface{}{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) RestoreVersion(req request.RestoreAIWorkflowVersionRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
workflow := s.Get(req.WorkflowID)
|
||||
version := s.GetVersion(req.WorkflowVersionID)
|
||||
if workflow == nil || version == nil || version.WorkflowID != workflow.ID {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
return repositories.AIWorkflowRepository.Updates(sqls.DB(), workflow.ID, map[string]any{"draft_definition": version.Definition, "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now()})
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) ListUsage(workflowID int64) []AIWorkflowUsageItem {
|
||||
bindings := repositories.AIAgentWorkflowBindingRepository.FindByWorkflowID(sqls.DB(), workflowID)
|
||||
ret := make([]AIWorkflowUsageItem, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
ret = append(ret, AIWorkflowUsageItem{Binding: binding, Agent: repositories.AIAgentRepository.Get(sqls.DB(), binding.AIAgentID), Version: repositories.AIWorkflowVersionRepository.Get(sqls.DB(), binding.WorkflowVersionID)})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) PublishWorkflow(req request.PublishAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflowVersion, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
workflow := s.Get(req.WorkflowID)
|
||||
if workflow == nil || workflow.Status == enums.StatusDeleted {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
result := s.ValidateDefinition(req.Definition)
|
||||
if !result.Valid {
|
||||
return nil, errorsx.InvalidParam("workflow definition is invalid")
|
||||
}
|
||||
definition, err := marshalDefinition(req.Definition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
var version *models.AIWorkflowVersion
|
||||
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
nextVersion := repositories.AIWorkflowVersionRepository.MaxVersionByWorkflowID(ctx.Tx, req.WorkflowID) + 1
|
||||
version = &models.AIWorkflowVersion{
|
||||
WorkflowID: req.WorkflowID,
|
||||
Version: nextVersion,
|
||||
Status: enums.StatusOk,
|
||||
Definition: definition,
|
||||
DefinitionHash: hashDefinition(definition),
|
||||
PublishedAt: &now,
|
||||
PublishedByID: operator.UserID,
|
||||
PublishedByName: operator.Username,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := repositories.AIWorkflowVersionRepository.Create(ctx.Tx, version); err != nil {
|
||||
return err
|
||||
}
|
||||
return repositories.AIWorkflowRepository.Updates(ctx.Tx, req.WorkflowID, map[string]interface{}{
|
||||
"draft_definition": definition,
|
||||
"published_version_id": version.ID,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func defaultAgentWorkflowDefinition() dsl.Definition {
|
||||
return officialDefaultAgentWorkflowDefinition()
|
||||
}
|
||||
|
||||
func officialDefaultAgentWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
Nodes: []dsl.Node{
|
||||
{
|
||||
ID: "start_0",
|
||||
Type: workflowregistry.NodeTypeStart,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: 180, Y: 300}},
|
||||
Data: dsl.NodeData{
|
||||
Title: "Start",
|
||||
Outputs: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","default":"Hello Flow."}}}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "llm_0",
|
||||
Type: workflowregistry.NodeTypeLLM,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: 640, Y: 220}},
|
||||
Data: dsl.NodeData{
|
||||
Title: "LLM",
|
||||
InputsValues: map[string]dsl.Value{
|
||||
"modelName": dsl.ConstantValue("gpt-3.5-turbo"),
|
||||
"apiKey": dsl.ConstantValue("sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"),
|
||||
"apiHost": dsl.ConstantValue("https://mock-ai-url/api/v3"),
|
||||
"temperature": dsl.ConstantValue(0.5),
|
||||
"systemPrompt": dsl.TemplateValue(
|
||||
"# Role\nYou are an AI assistant.\n",
|
||||
),
|
||||
"prompt": dsl.TemplateValue(""),
|
||||
},
|
||||
Inputs: json.RawMessage(`{"type":"object","required":["modelName","apiKey","apiHost","temperature","prompt"],"properties":{"modelName":{"type":"string"},"apiKey":{"type":"string"},"apiHost":{"type":"string"},"temperature":{"type":"number"},"systemPrompt":{"type":"string","extra":{"formComponent":"prompt-editor"}},"prompt":{"type":"string","extra":{"formComponent":"prompt-editor"}}}}`),
|
||||
Outputs: json.RawMessage(`{"type":"object","properties":{"result":{"type":"string"}}}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "end_0",
|
||||
Type: workflowregistry.NodeTypeEnd,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: 1100, Y: 300}},
|
||||
Data: dsl.NodeData{
|
||||
Title: "End",
|
||||
InputsValues: map[string]dsl.Value{
|
||||
"result": dsl.RefValue("llm_0", "result"),
|
||||
},
|
||||
Inputs: json.RawMessage(`{"type":"object","properties":{"result":{"type":"string"}}}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
{SourceNodeID: "start_0", TargetNodeID: "llm_0"},
|
||||
{SourceNodeID: "llm_0", TargetNodeID: "end_0"},
|
||||
},
|
||||
GlobalVariable: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||
}
|
||||
}
|
||||
|
||||
func legacyDefaultAgentWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{
|
||||
SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 285.5, nil, nil),
|
||||
workflowNode("understanding_1", workflowregistry.NodeTypeConversationUnderstanding, "会话理解", 640, 285.5, workflowInputs("userMessage", "start_1", "userMessage"), nil),
|
||||
workflowNode("policy_1", workflowregistry.NodeTypeReplyPolicy, "回复策略", 1100, 285.5, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"messageIntent": dsl.RefValue("understanding_1", "messageIntent"),
|
||||
"answerScope": dsl.RefValue("understanding_1", "answerScope"),
|
||||
"riskSignals": dsl.RefValue("understanding_1", "riskSignals"),
|
||||
}, nil),
|
||||
workflowNode("policy_route_1", workflowregistry.NodeTypeCondition, "策略分流", 1560, 125.5, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("handoff", "转人工", "handoff_confirm_prompt_1", "policy_1", "action", "eq", "handoff_to_human"),
|
||||
workflowConditionBranch("direct", "直接回复", "policy_reply_1", "policy_1", "action", "eq", "direct_reply"),
|
||||
workflowConditionBranch("clarify", "追问澄清", "policy_reply_1", "policy_1", "action", "eq", "clarify"),
|
||||
workflowConditionBranch("end_conversation", "结束语", "policy_reply_1", "policy_1", "action", "eq", "end_conversation"),
|
||||
workflowConditionBranch("ticket", "创建工单", "draft_ticket_1", "policy_1", "action", "eq", "prepare_ticket"),
|
||||
workflowConditionBranch("knowledge", "知识库回复", "retrieve_1", "policy_1", "action", "eq", "retrieve_knowledge"),
|
||||
{ID: "default", Name: "策略兜底", TargetNodeID: "policy_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("handoff_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "转人工确认文案", 2020, 0, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "我可以为你转接人工客服处理。请回复“确认”继续转人工,或回复“取消”继续由 AI 协助。"}),
|
||||
workflowNode("handoff_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认转人工", 2480, 0, workflowInputs("prompt", "handoff_confirm_prompt_1", "replyText"), nil),
|
||||
workflowNode("handoff_confirm_route_1", workflowregistry.NodeTypeCondition, "转人工确认分流", 2940, 0, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "handoff_1", "handoff_confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消或未确认", TargetNodeID: "handoff_cancel_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工", 3400, 0, map[string]dsl.Value{
|
||||
"reason": dsl.RefValue("start_1", "userMessage"),
|
||||
"confirmed": dsl.RefValue("handoff_confirm_1", "confirmed"),
|
||||
}, nil),
|
||||
workflowNode("handoff_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消转人工提示", 3400, 480, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消转人工。你可以继续补充问题,我会继续协助。"}),
|
||||
workflowNode("send_handoff_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3860, 480, workflowInputs("replyText", "handoff_cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("policy_reply_1", workflowregistry.NodeTypeSendReply, "发送策略回复", 4320, 98.5, workflowInputs("replyText", "policy_1", "replyText"), nil),
|
||||
workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 3860, 0, nil, nil),
|
||||
workflowNode("draft_ticket_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 2020, 379, workflowInputs("issue", "start_1", "userMessage"), nil),
|
||||
workflowNode("ticket_draft_route_1", workflowregistry.NodeTypeCondition, "草稿就绪分流", 2480, 329, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("ready", "草稿完整", "ticket_confirm_prompt_1", "draft_ticket_1", "ready", "is_true", nil),
|
||||
{ID: "default", Name: "补充信息", TargetNodeID: "ticket_followup_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("ticket_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认文案", 2940, 285.5, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"ticketTitle": dsl.RefValue("draft_ticket_1", "title"),
|
||||
"ticketDescription": dsl.RefValue("draft_ticket_1", "description"),
|
||||
}, map[string]any{"staticReply": "我已整理工单草稿,请确认是否创建:\n标题:{{ticketTitle}}\n描述:{{ticketDescription}}\n请回复“确认”创建工单,或回复“取消”放弃。"}),
|
||||
workflowNode("ticket_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 3400, 285.5, workflowInputs("prompt", "ticket_confirm_prompt_1", "replyText"), nil),
|
||||
workflowNode("ticket_confirm_route_1", workflowregistry.NodeTypeCondition, "建单确认分流", 3860, 235.5, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "create_ticket_1", "ticket_confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消或未确认", TargetNodeID: "ticket_cancel_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("create_ticket_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 4780, 192, map[string]dsl.Value{
|
||||
"ticketDraft": dsl.RefValue("draft_ticket_1", "ticketDraft"),
|
||||
"confirmed": dsl.RefValue("ticket_confirm_1", "confirmed"),
|
||||
}, nil),
|
||||
workflowNode("ticket_result_reply_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 5240, 192, workflowInputs("replyText", "create_ticket_1", "message"), nil),
|
||||
workflowNode("ticket_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消建单提示", 4320, 379, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。你可以继续补充问题,我会继续帮你处理。"}),
|
||||
workflowNode("send_ticket_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 4780, 379, workflowInputs("replyText", "ticket_cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("ticket_followup_reply_1", workflowregistry.NodeTypeLLMReply, "追问工单信息", 3860, 1033.5, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"followUpQuestions": dsl.RefValue("draft_ticket_1", "followUpQuestions"),
|
||||
}, map[string]any{"staticReply": "为了创建工单,还需要补充以下信息:\n{{followUpQuestions}}"}),
|
||||
workflowNode("send_ticket_followup_1", workflowregistry.NodeTypeSendReply, "发送工单追问", 4780, 1033.5, workflowInputs("replyText", "ticket_followup_reply_1", "replyText"), nil),
|
||||
workflowNode("retrieve_1", workflowregistry.NodeTypeKnowledgeRetrieve, "知识检索", 2480, 753, workflowInputs("query", "start_1", "userMessage"), map[string]any{"knowledgeBaseIds": []int64{}}),
|
||||
workflowNode("answerability_1", workflowregistry.NodeTypeAnswerabilityGate, "可回答判断", 2940, 753, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
|
||||
}, nil),
|
||||
workflowNode("answerability_route_1", workflowregistry.NodeTypeCondition, "可回答分流", 3400, 703, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("answerable", "可以回答", "reply_1", "answerability_1", "answerability", "eq", "answerable"),
|
||||
{ID: "default", Name: "兜底追问", TargetNodeID: "fallback_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("reply_1", workflowregistry.NodeTypeLLMReply, "AI 回复", 3860, 659.5, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
|
||||
}, nil),
|
||||
workflowNode("send_1", workflowregistry.NodeTypeSendReply, "发送回复", 4320, 659.5, workflowInputs("replyText", "reply_1", "replyText"), nil),
|
||||
workflowNode("fallback_reply_1", workflowregistry.NodeTypeLLMReply, "兜底追问", 3860, 846.5, map[string]dsl.Value{
|
||||
"userMessage": dsl.RefValue("start_1", "userMessage"),
|
||||
"knowledgeItems": dsl.RefValue("retrieve_1", "items"),
|
||||
}, nil),
|
||||
workflowNode("send_fallback_1", workflowregistry.NodeTypeSendReply, "发送兜底", 4320, 846.5, workflowInputs("replyText", "fallback_reply_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 5700, 472.5, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
workflowEdge("start_1", "understanding_1"),
|
||||
workflowEdge("understanding_1", "policy_1"),
|
||||
workflowEdge("policy_1", "policy_route_1"),
|
||||
workflowPortEdge("policy_route_1", "handoff_confirm_prompt_1", "handoff"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "direct"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "clarify"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "end_conversation"),
|
||||
workflowPortEdge("policy_route_1", "draft_ticket_1", "ticket"),
|
||||
workflowPortEdge("policy_route_1", "retrieve_1", "knowledge"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "default"),
|
||||
workflowEdge("policy_reply_1", "end_1"),
|
||||
workflowEdge("handoff_confirm_prompt_1", "handoff_confirm_1"),
|
||||
workflowEdge("handoff_confirm_1", "handoff_confirm_route_1"),
|
||||
workflowPortEdge("handoff_confirm_route_1", "handoff_1", "confirmed"),
|
||||
workflowPortEdge("handoff_confirm_route_1", "handoff_cancel_reply_1", "default"),
|
||||
workflowEdge("handoff_1", "handoff_end_1"),
|
||||
workflowEdge("handoff_cancel_reply_1", "send_handoff_cancel_1"),
|
||||
workflowEdge("send_handoff_cancel_1", "end_1"),
|
||||
workflowEdge("draft_ticket_1", "ticket_draft_route_1"),
|
||||
workflowPortEdge("ticket_draft_route_1", "ticket_confirm_prompt_1", "ready"),
|
||||
workflowPortEdge("ticket_draft_route_1", "ticket_followup_reply_1", "default"),
|
||||
workflowEdge("ticket_confirm_prompt_1", "ticket_confirm_1"),
|
||||
workflowEdge("ticket_confirm_1", "ticket_confirm_route_1"),
|
||||
workflowPortEdge("ticket_confirm_route_1", "create_ticket_1", "confirmed"),
|
||||
workflowPortEdge("ticket_confirm_route_1", "ticket_cancel_reply_1", "default"),
|
||||
workflowEdge("create_ticket_1", "ticket_result_reply_1"),
|
||||
workflowEdge("ticket_result_reply_1", "end_1"),
|
||||
workflowEdge("ticket_cancel_reply_1", "send_ticket_cancel_1"),
|
||||
workflowEdge("send_ticket_cancel_1", "end_1"),
|
||||
workflowEdge("ticket_followup_reply_1", "send_ticket_followup_1"),
|
||||
workflowEdge("send_ticket_followup_1", "end_1"),
|
||||
workflowEdge("retrieve_1", "answerability_1"),
|
||||
workflowEdge("answerability_1", "answerability_route_1"),
|
||||
workflowPortEdge("answerability_route_1", "reply_1", "answerable"),
|
||||
workflowPortEdge("answerability_route_1", "fallback_reply_1", "default"),
|
||||
workflowEdge("reply_1", "send_1"),
|
||||
workflowEdge("send_1", "end_1"),
|
||||
workflowEdge("fallback_reply_1", "send_fallback_1"),
|
||||
workflowEdge("send_fallback_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func identityConfirmationWorkflowDefinition() dsl.Definition {
|
||||
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
|
||||
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "身份确认提示", 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "为保护你的账户信息,请确认是否继续身份核验。"}),
|
||||
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认身份核验", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "confirmed_reply_1", "confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("confirmed_reply_1", workflowregistry.NodeTypeLLMReply, "确认结果", 1860, 100, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已收到确认,人工客服将继续为你核验身份。"}),
|
||||
workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消身份核验。"}),
|
||||
workflowNode("send_confirmed_1", workflowregistry.NodeTypeSendReply, "发送确认结果", 2280, 100, workflowInputs("replyText", "confirmed_reply_1", "replyText"), nil),
|
||||
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"),
|
||||
workflowPortEdge("route_1", "confirmed_reply_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"),
|
||||
workflowEdge("confirmed_reply_1", "send_confirmed_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_confirmed_1", "end_1"), workflowEdge("send_cancel_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func complaintEscalationWorkflowDefinition() dsl.Definition {
|
||||
return confirmationHandoffWorkflowDefinition("投诉升级确认", "我们将把本次投诉升级给人工客服处理。请确认是否继续。", "已为你升级投诉,人工客服会尽快跟进。", "投诉升级已取消。")
|
||||
}
|
||||
|
||||
func confirmationHandoffWorkflowDefinition(title, prompt, confirmedReply, cancelledReply string) dsl.Definition {
|
||||
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
|
||||
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, title, 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": prompt}),
|
||||
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认升级", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "handoff_1", "confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工处理", 1860, 100, map[string]dsl.Value{"reason": dsl.RefValue("start_1", "userMessage"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil),
|
||||
workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": cancelledReply}),
|
||||
workflowNode("send_handoff_1", workflowregistry.NodeTypeSendReply, "发送升级结果", 2280, 100, workflowInputs("replyText", "handoff_1", "message"), nil),
|
||||
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"),
|
||||
workflowPortEdge("route_1", "handoff_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"),
|
||||
workflowEdge("handoff_1", "send_handoff_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_handoff_1", "end_1"), workflowEdge("send_cancel_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func refundRequestPreparationWorkflowDefinition() dsl.Definition {
|
||||
return confirmationHandoffWorkflowDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。")
|
||||
}
|
||||
|
||||
func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node {
|
||||
return dsl.Node{
|
||||
ID: id,
|
||||
Type: nodeType,
|
||||
Meta: dsl.NodeMeta{Position: dsl.Position{X: x, Y: y}},
|
||||
Data: dsl.NodeData{
|
||||
Title: title,
|
||||
Config: mustMarshalWorkflowConfig(config),
|
||||
InputsValues: inputs,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func workflowInputs(name string, nodeID string, field string) map[string]dsl.Value {
|
||||
return map[string]dsl.Value{name: dsl.RefValue(nodeID, field)}
|
||||
}
|
||||
|
||||
func workflowConditionBranch(id string, name string, targetNodeID string, nodeID string, field string, operator string, right any) dsl.ConditionBranch {
|
||||
return dsl.ConditionBranch{
|
||||
ID: id,
|
||||
Name: name,
|
||||
TargetNodeID: targetNodeID,
|
||||
Condition: &dsl.Condition{
|
||||
Left: &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{nodeID, field}},
|
||||
Operator: operator,
|
||||
Right: right,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func workflowEdge(source string, target string) dsl.Edge {
|
||||
return dsl.Edge{SourceNodeID: source, TargetNodeID: target}
|
||||
}
|
||||
|
||||
func workflowPortEdge(source string, target string, sourcePortID string) dsl.Edge {
|
||||
return dsl.Edge{SourceNodeID: source, TargetNodeID: target, SourcePortID: sourcePortID}
|
||||
}
|
||||
|
||||
func mustMarshalWorkflowConfig(value any) json.RawMessage {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func defaultAgentWorkflowName(agentName string) string {
|
||||
agentName = strings.TrimSpace(agentName)
|
||||
if agentName == "" {
|
||||
return "会话流程"
|
||||
}
|
||||
return agentName + " 会话流程"
|
||||
}
|
||||
|
||||
func marshalDefinition(def dsl.Definition) (string, error) {
|
||||
buf, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
return "", errorsx.InvalidParam("invalid workflow definition")
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func hashDefinition(definition string) string {
|
||||
sum := sha256.Sum256([]byte(definition))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
Reference in New Issue
Block a user