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
@@ -6,5 +6,6 @@ module Enterprise::Concerns::Inbox
|
||||
has_one :captain_assistant,
|
||||
through: :captain_inbox,
|
||||
class_name: 'Captain::Assistant'
|
||||
has_many :inbox_capacity_limits, dependent: :destroy
|
||||
end
|
||||
end
|
||||
|
||||
31
enterprise/app/models/enterprise/inbox_agent_availability.rb
Normal file
31
enterprise/app/models/enterprise/inbox_agent_availability.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
module Enterprise::InboxAgentAvailability
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def member_ids_with_assignment_capacity
|
||||
return member_ids unless capacity_filtering_enabled?
|
||||
|
||||
# Get online agents with capacity
|
||||
agents = available_agents
|
||||
agents = filter_by_capacity(agents)
|
||||
agents.map(&:user_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_by_capacity(inbox_members_scope)
|
||||
return inbox_members_scope unless capacity_filtering_enabled?
|
||||
|
||||
inbox_members_scope.select do |inbox_member|
|
||||
capacity_service.agent_has_capacity?(inbox_member.user, self)
|
||||
end
|
||||
end
|
||||
|
||||
def capacity_filtering_enabled?
|
||||
account.feature_enabled?('assignment_v2') &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def capacity_service
|
||||
@capacity_service ||= Enterprise::AutoAssignment::CapacityService.new
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,94 @@
|
||||
module Enterprise::AutoAssignment::AssignmentService
|
||||
private
|
||||
|
||||
# Override assignment config to use policy if available
|
||||
def assignment_config
|
||||
return super unless policy
|
||||
|
||||
{
|
||||
'conversation_priority' => policy.conversation_priority,
|
||||
'fair_distribution_limit' => policy.fair_distribution_limit,
|
||||
'fair_distribution_window' => policy.fair_distribution_window,
|
||||
'balanced' => policy.balanced?
|
||||
}.compact
|
||||
end
|
||||
|
||||
# Extend agent finding to add capacity checks
|
||||
def find_available_agent
|
||||
agents = filter_agents_by_rate_limit(inbox.available_agents)
|
||||
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
|
||||
return nil if agents.empty?
|
||||
|
||||
selector = policy&.balanced? ? balanced_selector : round_robin_selector
|
||||
selector.select_agent(agents)
|
||||
end
|
||||
|
||||
def filter_agents_by_capacity(agents)
|
||||
return agents unless capacity_filtering_enabled?
|
||||
|
||||
capacity_service = Enterprise::AutoAssignment::CapacityService.new
|
||||
agents.select { |agent_member| capacity_service.agent_has_capacity?(agent_member.user, inbox) }
|
||||
end
|
||||
|
||||
def capacity_filtering_enabled?
|
||||
account.feature_enabled?('assignment_v2') &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
def round_robin_selector
|
||||
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
|
||||
end
|
||||
|
||||
def balanced_selector
|
||||
@balanced_selector ||= Enterprise::AutoAssignment::BalancedSelector.new(inbox: inbox)
|
||||
end
|
||||
|
||||
def policy
|
||||
@policy ||= inbox.assignment_policy
|
||||
end
|
||||
|
||||
def account
|
||||
inbox.account
|
||||
end
|
||||
|
||||
# Override to apply exclusion rules
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations.unassigned.open
|
||||
|
||||
# Apply exclusion rules from capacity policy or assignment policy
|
||||
scope = apply_exclusion_rules(scope)
|
||||
|
||||
# Apply conversation priority using enum methods if policy exists
|
||||
scope = if policy&.longest_waiting?
|
||||
scope.reorder(last_activity_at: :asc, created_at: :asc)
|
||||
else
|
||||
scope.reorder(created_at: :asc)
|
||||
end
|
||||
|
||||
scope.limit(limit)
|
||||
end
|
||||
|
||||
def apply_exclusion_rules(scope)
|
||||
capacity_policy = inbox.inbox_capacity_limits.first&.agent_capacity_policy
|
||||
return scope unless capacity_policy
|
||||
|
||||
exclusion_rules = capacity_policy.exclusion_rules || {}
|
||||
scope = apply_label_exclusions(scope, exclusion_rules['excluded_labels'])
|
||||
apply_age_exclusions(scope, exclusion_rules['exclude_older_than_hours'])
|
||||
end
|
||||
|
||||
def apply_label_exclusions(scope, excluded_labels)
|
||||
return scope if excluded_labels.blank?
|
||||
|
||||
scope.tagged_with(excluded_labels, exclude: true, on: :labels)
|
||||
end
|
||||
|
||||
def apply_age_exclusions(scope, hours_threshold)
|
||||
return scope if hours_threshold.blank?
|
||||
|
||||
hours = hours_threshold.to_i
|
||||
return scope unless hours.positive?
|
||||
|
||||
scope.where('conversations.created_at >= ?', hours.hours.ago)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
class Enterprise::AutoAssignment::BalancedSelector
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def select_agent(available_agents)
|
||||
return nil if available_agents.empty?
|
||||
|
||||
agent_users = available_agents.map(&:user)
|
||||
assignment_counts = fetch_assignment_counts(agent_users)
|
||||
|
||||
agent_users.min_by { |user| assignment_counts[user.id] || 0 }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_assignment_counts(users)
|
||||
user_ids = users.map(&:id)
|
||||
|
||||
counts = inbox.conversations
|
||||
.open
|
||||
.where(assignee_id: user_ids)
|
||||
.group(:assignee_id)
|
||||
.count
|
||||
|
||||
Hash.new(0).merge(counts)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
class Enterprise::AutoAssignment::CapacityService
|
||||
def agent_has_capacity?(user, inbox)
|
||||
# Get the account_user for this specific account
|
||||
account_user = user.account_users.find_by(account: inbox.account)
|
||||
|
||||
# If no account_user or no capacity policy, agent has unlimited capacity
|
||||
return true unless account_user&.agent_capacity_policy
|
||||
|
||||
policy = account_user.agent_capacity_policy
|
||||
|
||||
# Check if there's a specific limit for this inbox
|
||||
inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox)
|
||||
|
||||
# If no specific limit for this inbox, agent has unlimited capacity for this inbox
|
||||
return true unless inbox_limit
|
||||
|
||||
# Count current open conversations for this agent in this inbox
|
||||
current_count = user.assigned_conversations
|
||||
.where(inbox: inbox, status: :open)
|
||||
.count
|
||||
|
||||
# Agent has capacity if current count is below the limit
|
||||
current_count < inbox_limit.conversation_limit
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user