Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user