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 }