feat: Assignment service (v2) (#12320)
## Linear Link ## Description This PR introduces a new robust auto-assignment system for conversations in Chatwoot. The system replaces the existing round-robin assignment with a more sophisticated service-based architecture that supports multiple assignment strategies, rate limiting, and Enterprise features like capacity-based assignment and balanced distribution. ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Unit test cases - Test conversations getting assigned on status change to open - Test the job directly via rails console ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a new service-based auto-assignment system with scheduled jobs, rate limiting, enterprise capacity/balanced selection, and wiring via inbox/handler; includes Redis helpers and comprehensive tests. > > - **Auto-assignment v2 (core services)**: > - Add `AutoAssignment::AssignmentService` with bulk assignment, configurable conversation priority, RR selection, and per-agent rate limiting via `AutoAssignment::RateLimiter`. > - Add `AutoAssignment::RoundRobinSelector` for agent selection. > - **Jobs & scheduling**: > - Add `AutoAssignment::AssignmentJob` (per-inbox bulk assign; env-based limit) and `AutoAssignment::PeriodicAssignmentJob` (batch over accounts/inboxes). > - Schedule periodic run in `config/schedule.yml` (`periodic_assignment_job`). > - **Model/concerns wiring**: > - Include `InboxAgentAvailability` in `Inbox`; add `Inbox#auto_assignment_v2_enabled?`. > - Update `AutoAssignmentHandler` to trigger v2 job when `auto_assignment_v2_enabled?`, else fallback to legacy. > - **Enterprise extensions**: > - Add `Enterprise::InboxAgentAvailability` (capacity-aware filtering) and `Enterprise::Concerns::Inbox` association `inbox_capacity_limits`. > - Extend service via `Enterprise::AutoAssignment::AssignmentService` (policy-driven config, capacity filtering, exclusion rules) and add selectors/services: `BalancedSelector`, `CapacityService`. > - **Infrastructure**: > - Enhance `Redis::Alfred` with `expire`, key scan/count, and extended ZSET helpers (`zadd`, `zcount`, `zcard`, `zrangebyscore`). > - **Tests**: > - Add specs for jobs, core service, rate limiter, RR selector, and enterprise features (capacity, balanced selection, exclusions). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0ebe187c8aea73765b0122a44b18d6f465c2477f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
committed by
GitHub
parent
2c4e65d68e
commit
c9823d9409
@@ -0,0 +1,185 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
|
||||
let(:account) { create(:account) }
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:agent1) { create(:user, account: account, name: 'Agent 1') }
|
||||
let(:agent2) { create(:user, account: account, name: 'Agent 2') }
|
||||
let(:assignment_service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
|
||||
|
||||
before do
|
||||
# Create inbox members
|
||||
create(:inbox_member, inbox: inbox, user: agent1)
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
|
||||
# Link inbox to assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
|
||||
# Set agents as online
|
||||
OnlineStatusTracker.update_presence(account.id, 'User', agent1.id)
|
||||
OnlineStatusTracker.set_status(account.id, agent1.id, 'online')
|
||||
OnlineStatusTracker.update_presence(account.id, 'User', agent2.id)
|
||||
OnlineStatusTracker.set_status(account.id, agent2.id, 'online')
|
||||
end
|
||||
|
||||
describe 'exclusion rules' do
|
||||
let(:capacity_policy) { create(:agent_capacity_policy, account: account) }
|
||||
let(:label1) { create(:label, account: account, title: 'high-priority') }
|
||||
let(:label2) { create(:label, account: account, title: 'vip') }
|
||||
|
||||
before do
|
||||
create(:inbox_capacity_limit, inbox: inbox, agent_capacity_policy: capacity_policy, conversation_limit: 10)
|
||||
inbox.enable_auto_assignment = true
|
||||
inbox.save!
|
||||
end
|
||||
|
||||
context 'when excluding conversations by label' do
|
||||
let!(:conversation_with_label) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
let!(:conversation_without_label) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
conversation_with_label.update_labels([label1.title])
|
||||
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'excluded_labels' => [label1.title]
|
||||
})
|
||||
end
|
||||
|
||||
it 'excludes conversations with specified labels' do
|
||||
# First check conversations are unassigned
|
||||
expect(conversation_with_label.assignee).to be_nil
|
||||
expect(conversation_without_label.assignee).to be_nil
|
||||
|
||||
# Run bulk assignment
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only the conversation without label should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation_with_label.reload.assignee).to be_nil
|
||||
expect(conversation_without_label.reload.assignee).to be_present
|
||||
end
|
||||
|
||||
it 'handles bulk assignment correctly' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only 1 conversation should be assigned (the one without label)
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation_with_label.reload.assignee).to be_nil
|
||||
expect(conversation_without_label.reload.assignee).to be_present
|
||||
end
|
||||
|
||||
it 'excludes conversations with multiple labels' do
|
||||
conversation_without_label.update_labels([label2.title])
|
||||
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'excluded_labels' => [label1.title, label2.title]
|
||||
})
|
||||
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Both conversations should be excluded
|
||||
expect(assigned_count).to eq(0)
|
||||
expect(conversation_with_label.reload.assignee).to be_nil
|
||||
expect(conversation_without_label.reload.assignee).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when excluding conversations by age' do
|
||||
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago) }
|
||||
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) }
|
||||
|
||||
before do
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'exclude_older_than_hours' => 24
|
||||
})
|
||||
end
|
||||
|
||||
it 'excludes conversations older than specified hours' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only recent conversation should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(old_conversation.reload.assignee).to be_nil
|
||||
expect(recent_conversation.reload.assignee).to be_present
|
||||
end
|
||||
|
||||
it 'handles different time thresholds' do
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'exclude_older_than_hours' => 2
|
||||
})
|
||||
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only conversation created within 2 hours should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(recent_conversation.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when combining exclusion rules' do
|
||||
it 'applies both exclusion rules' do
|
||||
# Create conversations
|
||||
old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
|
||||
old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
|
||||
recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
|
||||
recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
|
||||
|
||||
# Add labels
|
||||
old_conversation_with_label.update_labels([label1.title])
|
||||
recent_conversation_with_label.update_labels([label1.title])
|
||||
|
||||
capacity_policy.update!(exclusion_rules: {
|
||||
'excluded_labels' => [label1.title],
|
||||
'exclude_older_than_hours' => 24
|
||||
})
|
||||
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
# Only recent conversation without label should be assigned
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(old_conversation_with_label.reload.assignee).to be_nil
|
||||
expect(old_conversation_without_label.reload.assignee).to be_nil
|
||||
expect(recent_conversation_with_label.reload.assignee).to be_nil
|
||||
expect(recent_conversation_without_label.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exclusion rules are empty' do
|
||||
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
capacity_policy.update!(exclusion_rules: {})
|
||||
end
|
||||
|
||||
it 'assigns all eligible conversations' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(2)
|
||||
expect(conversation1.reload.assignee).to be_present
|
||||
expect(conversation2.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no capacity policy exists' do
|
||||
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
|
||||
|
||||
before do
|
||||
InboxCapacityLimit.destroy_all
|
||||
end
|
||||
|
||||
it 'assigns all eligible conversations without exclusions' do
|
||||
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(2)
|
||||
expect(conversation1.reload.assignee).to be_present
|
||||
expect(conversation2.reload.assignee).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::AutoAssignment::BalancedSelector do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:selector) { described_class.new(inbox: inbox) }
|
||||
let(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent3) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:member1) { create(:inbox_member, inbox: inbox, user: agent1) }
|
||||
let(:member2) { create(:inbox_member, inbox: inbox, user: agent2) }
|
||||
let(:member3) { create(:inbox_member, inbox: inbox, user: agent3) }
|
||||
|
||||
describe '#select_agent' do
|
||||
context 'when selecting based on workload' do
|
||||
let(:available_agents) { [member1, member2, member3] }
|
||||
|
||||
it 'selects the agent with least open conversations' do
|
||||
# Agent1 has 3 open conversations
|
||||
3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'open') }
|
||||
|
||||
# Agent2 has 1 open conversation
|
||||
create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
|
||||
|
||||
# Agent3 has 2 open conversations
|
||||
2.times { create(:conversation, inbox: inbox, assignee: agent3, status: 'open') }
|
||||
|
||||
selected_agent = selector.select_agent(available_agents)
|
||||
|
||||
# Should select agent2 as they have the least conversations
|
||||
expect(selected_agent).to eq(agent2)
|
||||
end
|
||||
|
||||
it 'considers only open conversations' do
|
||||
# Agent1 has 1 open and 3 resolved conversations
|
||||
create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
|
||||
3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'resolved') }
|
||||
|
||||
# Agent2 has 2 open conversations
|
||||
2.times { create(:conversation, inbox: inbox, assignee: agent2, status: 'open') }
|
||||
|
||||
selected_agent = selector.select_agent([member1, member2])
|
||||
|
||||
# Should select agent1 as they have fewer open conversations
|
||||
expect(selected_agent).to eq(agent1)
|
||||
end
|
||||
|
||||
it 'selects any agent when agents have equal workload' do
|
||||
# All agents have same number of conversations
|
||||
[member1, member2, member3].each do |member|
|
||||
create(:conversation, inbox: inbox, assignee: member.user, status: 'open')
|
||||
end
|
||||
|
||||
selected_agent = selector.select_agent(available_agents)
|
||||
|
||||
# Should select one of the agents (when equal, min_by returns the first one it finds)
|
||||
expect([agent1, agent2, agent3]).to include(selected_agent)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no agents are available' do
|
||||
it 'returns nil' do
|
||||
selected_agent = selector.select_agent([])
|
||||
expect(selected_agent).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when one agent is available' do
|
||||
it 'returns that agent' do
|
||||
selected_agent = selector.select_agent([member1])
|
||||
expect(selected_agent).to eq(agent1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with new agents (no conversations)' do
|
||||
it 'prioritizes agents with no conversations' do
|
||||
# Agent1 and 2 have conversations
|
||||
create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
|
||||
create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
|
||||
|
||||
# Agent3 is new with no conversations
|
||||
selected_agent = selector.select_agent([member1, member2, member3])
|
||||
|
||||
expect(selected_agent).to eq(agent3)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
|
||||
|
||||
# Assignment policy with rate limiting
|
||||
let(:assignment_policy) do
|
||||
create(:assignment_policy,
|
||||
account: account,
|
||||
enabled: true,
|
||||
fair_distribution_limit: 5,
|
||||
fair_distribution_window: 3600)
|
||||
end
|
||||
|
||||
# Agent capacity policy
|
||||
let(:agent_capacity_policy) do
|
||||
create(:agent_capacity_policy, account: account, name: 'Limited Capacity')
|
||||
end
|
||||
|
||||
# Agents with different capacity settings
|
||||
let(:agent_with_capacity) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent_without_capacity) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
let(:agent_at_capacity) { create(:user, account: account, role: :agent, availability: :online) }
|
||||
|
||||
before do
|
||||
# Create inbox assignment policy
|
||||
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
|
||||
|
||||
# Set inbox capacity limit
|
||||
create(:inbox_capacity_limit,
|
||||
agent_capacity_policy: agent_capacity_policy,
|
||||
inbox: inbox,
|
||||
conversation_limit: 3)
|
||||
|
||||
# Assign capacity policy to specific agents
|
||||
agent_with_capacity.account_users.find_by(account: account)
|
||||
.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
|
||||
agent_at_capacity.account_users.find_by(account: account)
|
||||
.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
|
||||
# Create inbox members
|
||||
create(:inbox_member, inbox: inbox, user: agent_with_capacity)
|
||||
create(:inbox_member, inbox: inbox, user: agent_without_capacity)
|
||||
create(:inbox_member, inbox: inbox, user: agent_at_capacity)
|
||||
|
||||
# Mock online status
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
|
||||
agent_with_capacity.id.to_s => 'online',
|
||||
agent_without_capacity.id.to_s => 'online',
|
||||
agent_at_capacity.id.to_s => 'online'
|
||||
})
|
||||
|
||||
# Enable assignment_v2 feature
|
||||
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
|
||||
|
||||
# Create existing assignments for agent_at_capacity (at limit)
|
||||
3.times do
|
||||
create(:conversation, inbox: inbox, assignee: agent_at_capacity, status: :open)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'capacity filtering' do
|
||||
it 'excludes agents at capacity' do
|
||||
# Get available agents respecting capacity
|
||||
capacity_service = described_class.new
|
||||
online_agents = inbox.available_agents
|
||||
filtered_agents = online_agents.select do |inbox_member|
|
||||
capacity_service.agent_has_capacity?(inbox_member.user, inbox)
|
||||
end
|
||||
available_users = filtered_agents.map(&:user)
|
||||
|
||||
expect(available_users).to include(agent_with_capacity)
|
||||
expect(available_users).to include(agent_without_capacity) # No capacity policy = unlimited
|
||||
expect(available_users).not_to include(agent_at_capacity) # At capacity limit
|
||||
end
|
||||
|
||||
it 'respects inbox-specific capacity limits' do
|
||||
capacity_service = described_class.new
|
||||
|
||||
expect(capacity_service.agent_has_capacity?(agent_with_capacity, inbox)).to be true
|
||||
expect(capacity_service.agent_has_capacity?(agent_without_capacity, inbox)).to be true
|
||||
expect(capacity_service.agent_has_capacity?(agent_at_capacity, inbox)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'assignment with capacity' do
|
||||
let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
|
||||
|
||||
it 'assigns to agents with available capacity' do
|
||||
# Create conversation before assignment
|
||||
conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open)
|
||||
|
||||
# Mock the selector to prefer agent_at_capacity (but should skip due to capacity)
|
||||
selector = instance_double(AutoAssignment::RoundRobinSelector)
|
||||
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(selector)
|
||||
allow(selector).to receive(:select_agent) do |agents|
|
||||
agents.map(&:user).find { |u| [agent_with_capacity, agent_without_capacity].include?(u) }
|
||||
end
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 1)
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation.reload.assignee).to be_in([agent_with_capacity, agent_without_capacity])
|
||||
expect(conversation.reload.assignee).not_to eq(agent_at_capacity)
|
||||
end
|
||||
|
||||
it 'returns false when all agents are at capacity' do
|
||||
# Fill up remaining agents
|
||||
3.times { create(:conversation, inbox: inbox, assignee: agent_with_capacity, status: :open) }
|
||||
|
||||
# agent_without_capacity has no limit, so should still be available
|
||||
conversation2 = create(:conversation, inbox: inbox, assignee: nil, status: :open)
|
||||
assigned_count = service.perform_bulk_assignment(limit: 1)
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(conversation2.reload.assignee).to eq(agent_without_capacity)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user