Files
agent-desk-ng/internal/services/conversation_dispatch_service.go
mono 2f7a491666 feat: harden support, localize knowledge and add operator availability [skip ci]
Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
2026-10-16 15:21:42 +00:00

408 lines
12 KiB
Go

// Modified in AgentDeskNext: authenticated support availability and calendars.
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package services
import (
"context"
"errors"
"log/slog"
"math"
"slices"
"strings"
"sync/atomic"
"time"
"agent-desk/internal/events"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/eventbus"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
var ConversationDispatchService = newConversationDispatchService()
func newConversationDispatchService() *conversationDispatchService {
return &conversationDispatchService{}
}
type conversationDispatchService struct{}
type dispatchCandidate struct {
profile models.AgentProfile
activeCount int
loadRate float64
}
type agentActiveConversationCount struct {
CurrentAssigneeID int64 `gorm:"column:current_assignee_id"`
ActiveCount int `gorm:"column:active_count"`
}
type dispatchPoolReport struct {
RequestedTeamIDs []int64
ActiveScheduleTeams []int64
MatchedProfiles int
EligibleProfiles int
CandidateCount int
Reason string
}
var errConversationDispatchConflict = errors.New("conversation dispatch conflict")
const pendingDispatchBatchLimit = 50
var pendingDispatchRunning atomic.Bool
func (s *conversationDispatchService) DispatchConversation(conversationID int64) (*models.Conversation, error) {
if conversationID <= 0 {
return nil, nil
}
conversation := ConversationService.Get(conversationID)
if conversation == nil {
return nil, nil
}
if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
return nil, nil
}
aiAgent := AIAgentService.Get(conversation.AIAgentID)
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
return nil, nil
}
return s.DispatchPendingConversation(conversation, aiAgent)
}
func (s *conversationDispatchService) DispatchPendingConversation(conversation *models.Conversation, aiAgent *models.AIAgent) (*models.Conversation, error) {
if conversation == nil || aiAgent == nil {
return nil, nil
}
if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
return nil, nil
}
teamIDs := resolveDispatchTeamIDs(aiAgent.TeamIDs)
if len(teamIDs) == 0 {
slog.Debug("skip auto dispatch due to empty ai agent team ids",
"conversation_id", conversation.ID,
"ai_agent_id", aiAgent.ID,
)
return nil, nil
}
candidates, report, err := s.pickDispatchCandidates(teamIDs, time.Now())
if err != nil {
return nil, err
}
if len(candidates) == 0 {
slog.Debug("no dispatch candidate available",
"conversation_id", conversation.ID,
"ai_agent_id", aiAgent.ID,
"requested_team_ids", report.RequestedTeamIDs,
"active_schedule_team_ids", report.ActiveScheduleTeams,
"matched_profiles", report.MatchedProfiles,
"eligible_profiles", report.EligibleProfiles,
"reason", report.Reason,
)
return nil, nil
}
for _, candidate := range candidates {
dispatched, err := s.tryAssignConversation(conversation.ID, candidate.profile, "自动分配")
if err != nil {
if errors.Is(err, errConversationDispatchConflict) {
return nil, nil
}
return nil, err
}
if dispatched != nil {
slog.Info("conversation auto dispatched",
"conversation_id", dispatched.ID,
"ai_agent_id", aiAgent.ID,
"assignee_id", dispatched.CurrentAssigneeID,
"team_id", dispatched.CurrentTeamID,
"candidate_count", report.CandidateCount,
"requested_team_ids", report.RequestedTeamIDs,
)
WsService.PublishConversationChanged(dispatched, enums.IMRealtimeEventConversationAssigned)
eventbus.PublishAsync(context.Background(), events.ConversationAssignedEvent{
ConversationID: dispatched.ID,
ToUserID: dispatched.CurrentAssigneeID,
OperatorID: systemDispatchPrincipal().UserID,
Reason: "自动分配",
AssignType: events.ConversationAssignTypeAutoAssign,
})
return dispatched, nil
}
}
slog.Debug("auto dispatch candidate list exhausted without assignment",
"conversation_id", conversation.ID,
"ai_agent_id", aiAgent.ID,
"candidate_count", report.CandidateCount,
)
return nil, nil
}
func (s *conversationDispatchService) DispatchPendingConversations(limit int) (int, error) {
if !pendingDispatchRunning.CompareAndSwap(false, true) {
return 0, nil
}
defer pendingDispatchRunning.Store(false)
if limit <= 0 {
limit = pendingDispatchBatchLimit
}
// Preserve the existing newest-first batch/periodic retry policy, but
// bound the SQL read and distinguish failure from an empty queue.
conversations, err := repositories.ConversationRepository.FindPendingDispatchBatch(sqls.DB(), limit)
if err != nil {
return 0, err
}
if len(conversations) == 0 {
return 0, nil
}
dispatchedCount := 0
scannedCount := 0
for i, conversation := range conversations {
if i >= limit {
break
}
scannedCount++
dispatched, err := s.DispatchConversation(conversation.ID)
if err != nil {
return dispatchedCount, err
}
if dispatched != nil {
dispatchedCount++
}
}
if scannedCount > 0 {
slog.Info("pending conversation dispatch scan completed",
"scanned_count", scannedCount,
"dispatched_count", dispatchedCount,
"limit", limit,
)
}
return dispatchedCount, nil
}
func (s *conversationDispatchService) RunPendingDispatchLoop(interval time.Duration) {
if interval <= 0 {
interval = 30 * time.Second
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
slog.Info("pending conversation dispatch loop started",
"interval_seconds", int(interval/time.Second),
)
for {
_ = repositories.SupportAvailabilityRepository.PrunePresence(sqls.DB(), time.Now())
if _, err := s.DispatchPendingConversations(0); err != nil {
slog.Warn("dispatch pending conversations loop failed", "error", err)
}
<-ticker.C
}
}()
}
// pickDispatchCandidates returns the eligible dispatch candidates for the given teamIDs at the given time, along with a report for debugging and analysis.
func (s *conversationDispatchService) pickDispatchCandidates(teamIDs []int64, now time.Time) ([]dispatchCandidate, dispatchPoolReport, error) {
report := dispatchPoolReport{
RequestedTeamIDs: append([]int64(nil), teamIDs...),
}
profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teamIDs)
if err != nil {
return nil, report, err
}
report.MatchedProfiles = len(profiles)
report.ActiveScheduleTeams = s.findActiveScheduleTeamIDs(teamIDs, now)
candidates := make([]dispatchCandidate, 0, len(profiles))
report.Reason = "no_eligible_operator"
for _, profile := range profiles {
eligibility, err := SupportAvailabilityService.Evaluate(sqls.DB(), profile, true, now, 0)
if err != nil {
return nil, report, err
}
if !eligibility.Eligible {
report.Reason = eligibility.Reason
continue
}
report.EligibleProfiles++
loadRate := float64(eligibility.ActiveCount) / math.Max(float64(profile.MaxConcurrentCount), 1)
candidates = append(candidates, dispatchCandidate{profile: profile, activeCount: eligibility.ActiveCount, loadRate: loadRate})
}
report.CandidateCount = len(candidates)
if len(candidates) == 0 {
return nil, report, nil
}
slices.SortFunc(candidates, func(a, b dispatchCandidate) int {
switch {
case a.loadRate < b.loadRate:
return -1
case a.loadRate > b.loadRate:
return 1
}
switch {
case a.activeCount < b.activeCount:
return -1
case a.activeCount > b.activeCount:
return 1
}
switch {
case a.profile.PriorityLevel > b.profile.PriorityLevel:
return -1
case a.profile.PriorityLevel < b.profile.PriorityLevel:
return 1
}
aLastStatusAt := zeroTime(a.profile.LastStatusAt)
bLastStatusAt := zeroTime(b.profile.LastStatusAt)
switch {
case aLastStatusAt.Before(bLastStatusAt):
return -1
case aLastStatusAt.After(bLastStatusAt):
return 1
}
switch {
case a.profile.UserID < b.profile.UserID:
return -1
case a.profile.UserID > b.profile.UserID:
return 1
default:
return 0
}
})
report.Reason = "ok"
return candidates, report, nil
}
// findActiveScheduleTeamIDs returns the subset of teamIDs that have active schedule at the given time.
func (s *conversationDispatchService) findActiveScheduleTeamIDs(teamIDs []int64, now time.Time) []int64 {
result := make([]int64, 0, len(teamIDs))
for _, teamID := range uniquePositiveInt64s(teamIDs) {
team, err := repositories.SupportAvailabilityRepository.Team(sqls.DB(), teamID)
if err != nil || team == nil || team.Status != enums.StatusOk {
continue
}
calendar, err := SupportCalendarService.Evaluate(sqls.DB(), teamID, now)
if err == nil && calendar.Open {
result = append(result, teamID)
}
}
return result
}
func (s *conversationDispatchService) findActiveConversationCountMap(userIDs []int64) (map[int64]int, error) {
ret := make(map[int64]int, len(userIDs))
if len(userIDs) == 0 {
return ret, nil
}
rows := make([]agentActiveConversationCount, 0)
if err := sqls.DB().
Model(&models.Conversation{}).
Select("current_assignee_id, COUNT(1) AS active_count").
Where("status = ? AND current_assignee_id IN ?", enums.IMConversationStatusActive, userIDs).
Group("current_assignee_id").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.CurrentAssigneeID <= 0 {
continue
}
ret[row.CurrentAssigneeID] = row.ActiveCount
}
return ret, nil
}
func (s *conversationDispatchService) tryAssignConversation(conversationID int64, candidate models.AgentProfile, reason string) (*models.Conversation, error) {
now := time.Now()
operator := systemDispatchPrincipal()
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if _, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, candidate.UserID); err != nil {
return err
}
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
if conversation == nil {
return errConversationDispatchConflict
}
if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
return errConversationDispatchConflict
}
guarded, guardErr := SupportAvailabilityService.GuardAssignment(ctx.Tx, candidate.UserID, true, conversation, time.Now())
if guardErr != nil {
return guardErr
}
candidate = *guarded
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
return err
}
if err := ConversationAssignmentService.CreateAssignment(ctx, conversationID, conversation.CurrentAssigneeID, candidate.UserID, enums.IMAssignmentTypeAssign, reason, operator, now); err != nil {
return err
}
result := ctx.Tx.Model(&models.Conversation{}).
Where("id = ? AND status = ? AND current_assignee_id = ?", conversationID, enums.IMConversationStatusPending, 0).
Updates(map[string]any{
"current_assignee_id": candidate.UserID,
"current_team_id": candidate.TeamID,
"status": enums.IMConversationStatusActive,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": now,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errConversationDispatchConflict
}
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeAssign, enums.IMSenderTypeSystem, operator.UserID, "会话已自动分配", buildDispatchEventPayload(conversation.CurrentAssigneeID, candidate.UserID, candidate.TeamID, reason))
})
if errors.Is(err, errSupportAssignmentIneligible) {
return nil, nil
}
if err != nil {
return nil, err
}
return ConversationService.Get(conversationID), nil
}
func buildDispatchEventPayload(fromAssigneeID, toAssigneeID, toTeamID int64, reason string) string {
return ConversationService.buildEventPayload(map[string]any{
"fromStatus": enums.IMConversationStatusPending,
"toStatus": enums.IMConversationStatusActive,
"fromAssigneeId": fromAssigneeID,
"toAssigneeId": toAssigneeID,
"toTeamId": toTeamID,
"reason": strings.TrimSpace(reason),
})
}
func systemDispatchPrincipal() *dto.AuthPrincipal {
return &dto.AuthPrincipal{
UserID: 0,
Username: "system",
Nickname: "system",
}
}
func zeroTime(value *time.Time) time.Time {
if value == nil {
return time.Time{}
}
return *value
}