Files
gochat/internal/autoassignment/lowest_load.go
T
2026-06-04 15:44:48 +08:00

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
}