feat: add assignment service

This commit is contained in:
Tanmay Sharma
2025-08-28 17:03:11 +07:00
parent 472493b9af
commit eacfa2e19a
23 changed files with 1153 additions and 10 deletions

View File

@@ -0,0 +1,28 @@
class AutoAssignment::AssignmentJob < ApplicationJob
queue_as :default
def perform(inbox_id:)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
service = AutoAssignment::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment(limit: bulk_assignment_limit)
success_message = I18n.t('jobs.auto_assignment.assignment_job.bulk_assignment_success',
assigned_count: assigned_count,
inbox_id: inbox.id)
Rails.logger.info success_message
rescue StandardError => e
error_message = I18n.t('jobs.auto_assignment.assignment_job.bulk_assignment_failed',
inbox_id: inbox.id,
error_message: e.message)
Rails.logger.error error_message
raise e if Rails.env.test?
end
private
def bulk_assignment_limit
ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i
end
end

View File

@@ -0,0 +1,15 @@
class AutoAssignment::PeriodicAssignmentJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Account.find_each do |account|
next unless account.feature_enabled?('assignment_v2')
account.inboxes.joins(:assignment_policy).find_each do |inbox|
next unless inbox.assignment_v2_enabled?
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
end
end
end
end

View File

@@ -14,7 +14,13 @@ module AutoAssignmentHandler
return unless conversation_status_changed_to_open?
return unless should_run_auto_assignment?
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
if inbox.auto_assignment_enabled?
# Use new assignment system
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
else
# Use legacy assignment system
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
end
def should_run_auto_assignment?

View File

@@ -0,0 +1,28 @@
module InboxAgentAvailability
extend ActiveSupport::Concern
def available_agents
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def member_ids_with_assignment_capacity
member_ids
end
private
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
end
InboxAgentAvailability.prepend_mod_with('InboxAgentAvailability')

View File

@@ -44,6 +44,7 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
include InboxAgentAvailability
# Not allowing characters:
validates :name, presence: true
@@ -190,6 +191,10 @@ class Inbox < ApplicationRecord
members.ids
end
def auto_assignment_enabled?
account.feature_enabled?('assignment_v2') && assignment_policy.present? && assignment_policy.enabled?
end
private
def default_name_for_blank_name

View File

@@ -0,0 +1,99 @@
class AutoAssignment::AssignmentService
pattr_initialize [:inbox!]
def perform_for_conversation(conversation)
return false unless assignable?(conversation)
agent = find_available_agent
return false unless agent
assign_conversation(conversation, agent)
end
def perform_bulk_assignment(limit: 100)
return 0 unless inbox.enable_auto_assignment?
assigned_count = 0
unassigned_conversations(limit).find_each do |conversation|
assigned_count += 1 if perform_for_conversation(conversation)
end
assigned_count
end
private
def assignable?(conversation)
inbox.enable_auto_assignment? &&
conversation.status == 'open' &&
conversation.assignee_id.nil?
end
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Apply conversation priority from config
scope = apply_conversation_priority(scope)
scope.limit(limit)
end
def apply_conversation_priority(scope)
case assignment_config['conversation_priority']
when 'longest_waiting'
scope.order(last_activity_at: :asc, created_at: :asc)
else
scope.order(created_at: :asc)
end
end
def find_available_agent
agents = filter_agents_by_rate_limit(inbox.available_agents)
return nil if agents.empty?
round_robin_selector.select_agent(agents)
end
def filter_agents_by_rate_limit(agents)
agents.select do |agent_member|
rate_limiter = build_rate_limiter(agent_member.user)
rate_limiter.within_limit?
end
end
def assign_conversation(conversation, agent)
conversation.update!(assignee: agent)
rate_limiter = build_rate_limiter(agent)
rate_limiter.track_assignment(conversation)
dispatch_assignment_event(conversation, agent)
true
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "AutoAssignment failed for conversation #{conversation.id}: #{e.message}"
false
end
def dispatch_assignment_event(conversation, agent)
Rails.configuration.dispatcher.dispatch(
Events::Types::ASSIGNEE_CHANGED,
Time.zone.now,
conversation: conversation,
user: agent
)
end
def build_rate_limiter(agent)
AutoAssignment::RateLimiter.new(inbox: inbox, agent: agent)
end
def round_robin_selector
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
end
def assignment_config
@assignment_config ||= inbox.auto_assignment_config || {}
end
end
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')

View File

@@ -0,0 +1,49 @@
class AutoAssignment::RateLimiter
pattr_initialize [:inbox!, :agent!]
def within_limit?
return true unless enabled?
current_count < limit
end
def track_assignment(conversation)
return unless enabled?
assignment_key = build_assignment_key(conversation.id)
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
end
def current_count
return 0 unless enabled?
pattern = assignment_key_pattern
Redis::Alfred.keys_count(pattern)
end
private
def enabled?
limit.present? && limit.positive?
end
def limit
config['fair_distribution_limit']&.to_i
end
def window
config['fair_distribution_window']&.to_i || 3600
end
def config
@config ||= inbox.auto_assignment_config || {}
end
def assignment_key_pattern
"assignment:#{inbox.id}:agent:#{agent.id}:*"
end
def build_assignment_key(conversation_id)
"assignment:#{inbox.id}:agent:#{agent.id}:conversation:#{conversation_id}"
end
end

View File

@@ -0,0 +1,16 @@
class AutoAssignment::RoundRobinSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
end
private
def round_robin_service
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
end
end