105 lines
3.5 KiB
Go
105 lines
3.5 KiB
Go
package autoassignment
|
|
|
|
// LowestLoadSelector picks the agent with the fewest open conversations.
|
|
//
|
|
// Reference: Chatwoot "least_busy" concept
|
|
// - Agent with lowest workload (fewest open conversations) gets the next assignment.
|
|
// - This ensures fair distribution based on actual current load, not just rotation order.
|
|
//
|
|
// Implementation:
|
|
// - Queries the conversations table for open conversations in the given inbox,
|
|
// grouped by assignee_id to count load per agent.
|
|
// - Agents not present in the result set have zero load.
|
|
// - Picks the agent with the lowest load among the candidates.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"gorm.io/gorm"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// ConversationQueryModel is a minimal GORM model for querying conversation
|
|
// counts from the conversations table. It only includes the fields needed
|
|
// for the lowest-load selection query: AssigneeID, InboxID, and Status.
|
|
type ConversationQueryModel struct {
|
|
AssigneeID uint `gorm:"column:assignee_id"`
|
|
InboxID uint `gorm:"column:inbox_id"`
|
|
Status string `gorm:"column:status"`
|
|
}
|
|
|
|
// TableName returns the GORM table name for ConversationQueryModel.
|
|
func (ConversationQueryModel) TableName() string {
|
|
return "conversations"
|
|
}
|
|
|
|
// LowestLoadSelector selects the agent with the fewest open conversations
|
|
// in a given inbox.
|
|
type LowestLoadSelector struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewLowestLoadSelector creates a new LowestLoadSelector.
|
|
func NewLowestLoadSelector(db *gorm.DB) *LowestLoadSelector {
|
|
return &LowestLoadSelector{db: db}
|
|
}
|
|
|
|
// Select picks the agent with the lowest load (fewest open conversations)
|
|
// among the given candidate agentIDs for the specified inbox.
|
|
//
|
|
// Steps:
|
|
// 1. Query conversations table for open conversations in the inbox
|
|
// that are assigned to any of the candidate agents, grouped by
|
|
// assignee_id, to get the count (load) per agent.
|
|
// 2. For agents not in the result, assume load = 0.
|
|
// 3. Return the agentID with the lowest load.
|
|
// 4. If there are ties, the first agent in the agentIDs list with
|
|
// that load wins (deterministic tie-break based on caller ordering).
|
|
func (ll *LowestLoadSelector) Select(ctx context.Context, inboxID uint, agentIDs []uint) (uint, error) {
|
|
if len(agentIDs) == 0 {
|
|
return 0, fmt.Errorf("no candidate agents provided")
|
|
}
|
|
|
|
// Build a map of agent load from the database.
|
|
// Query: SELECT assignee_id, COUNT(*) as load
|
|
// FROM conversations
|
|
// WHERE inbox_id = ? AND status = 'open' AND assignee_id IN (?)
|
|
// GROUP BY assignee_id
|
|
type loadRow struct {
|
|
AssigneeID uint
|
|
Load int
|
|
}
|
|
|
|
var rows []loadRow
|
|
err := ll.db.WithContext(ctx).
|
|
Model(ConversationQueryModel{}).
|
|
Select("assignee_id, COUNT(*) as load").
|
|
Where("inbox_id = ? AND status = ? AND assignee_id IN ?", inboxID, "open", agentIDs).
|
|
Group("assignee_id").
|
|
Find(&rows).Error
|
|
if err != nil {
|
|
return 0, fmt.Errorf("query agent loads: %w", err)
|
|
}
|
|
|
|
// Build load map: agentID -> load count (default 0 for agents not in result)
|
|
loadMap := make(map[uint]int, len(agentIDs))
|
|
for _, row := range rows {
|
|
loadMap[row.AssigneeID] = row.Load
|
|
}
|
|
|
|
// Find agent with lowest load, using agentIDs order as tie-breaker
|
|
bestAgentID := agentIDs[0]
|
|
bestLoad := loadMap[bestAgentID]
|
|
|
|
for _, agentID := range agentIDs[1:] {
|
|
agentLoad := loadMap[agentID]
|
|
if agentLoad < bestLoad {
|
|
bestLoad = agentLoad
|
|
bestAgentID = agentID
|
|
}
|
|
}
|
|
|
|
applogger.L().Debugf("lowest_load: selected agent %d with load %d for inbox %d", bestAgentID, bestLoad, inboxID)
|
|
return bestAgentID, nil
|
|
} |