feat(captain): add advanced inactivity policy backend (4/5) (#15306)
Captain can now use each assistant's saved setting when a customer stops replying. Captain can review the conversation and resolve or hand it off, resolve it after the selected time without review, or leave it pending until the customer replies. The job checks the conversation again while holding a database lock before it changes the status. A new customer reply or another worker cannot cause an outdated resolve or handoff. ## Closes [AI-163](https://linear.app/chatwoot/issue/AI-163) ## What changed - Added assistant modes for review, always resolve, and wait for the customer. - Kept the account setting as the fallback for assistants that do not have a saved mode. - Skipped scheduling when resolution is disabled on the assistant or through the older account setting. - Rechecked the conversation status and activity time before each resolve or handoff. - Recorded events only after a status change succeeds. - Kept out of office messages out of campaign conversations. ## How to test 1. Set an assistant to review conversations. Run the inactivity job with complete and incomplete decisions. Confirm the first conversation is resolved and the second is handed off. 2. Set the assistant to always resolve. Confirm an eligible pending conversation is resolved after the selected time. 3. Set the assistant to wait for the customer. Confirm the scheduler does not enqueue the inactivity job and the conversation remains pending. 4. Add a customer reply while the review is running. Confirm the job does not resolve or hand off the updated conversation. 5. Run two workers for the same conversation. Confirm only one status change and one event are recorded. --------- Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON = 'no outstanding questions'.freeze
|
||||
CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON = 'pending clarification from customer'.freeze
|
||||
|
||||
queue_as :low
|
||||
|
||||
def perform(inbox)
|
||||
captain_assistant = inbox.captain_assistant
|
||||
@captain_assistant = inbox.captain_assistant
|
||||
return if captain_assistant.blank? || captain_assistant.inactive_conversation_resolution_disabled?
|
||||
|
||||
@inactivity_cutoff_time = Time.now.utc - captain_assistant.inactivity_threshold_minutes.minutes
|
||||
if evaluate_conversation_completion?(captain_assistant, inbox.account)
|
||||
@inactivity_cutoff_time = Time.current - captain_assistant.inactivity_threshold_minutes.minutes
|
||||
|
||||
if evaluate_conversation_completion?(inbox.account)
|
||||
perform_with_evaluation(inbox)
|
||||
else
|
||||
perform_time_based(inbox)
|
||||
@@ -20,29 +20,22 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
attr_reader :inactivity_cutoff_time
|
||||
attr_reader :captain_assistant, :inactivity_cutoff_time
|
||||
|
||||
def evaluate_conversation_completion?(assistant, account)
|
||||
account.feature_enabled?('captain_tasks') && assistant.evaluate_inactive_conversations_before_resolving?
|
||||
def evaluate_conversation_completion?(account)
|
||||
account.feature_enabled?('captain_tasks') && captain_assistant.evaluate_inactive_conversations_before_resolving?
|
||||
end
|
||||
|
||||
def perform_time_based(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
Current.executed_by = captain_assistant
|
||||
|
||||
resolvable_pending_conversations(inbox).each do |conversation|
|
||||
create_resolution_message(conversation, inbox)
|
||||
conversation.resolved!
|
||||
Captain::ConversationEvents.resolved(
|
||||
conversation: conversation,
|
||||
assistant: inbox.captain_assistant,
|
||||
source: Captain::ConversationEvents::Sources::TIME_BASED,
|
||||
at: Time.current
|
||||
)
|
||||
resolve_time_based_conversation(conversation, inbox)
|
||||
end
|
||||
end
|
||||
|
||||
def perform_with_evaluation(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
Current.executed_by = captain_assistant
|
||||
|
||||
resolvable_pending_conversations(inbox).each do |conversation|
|
||||
evaluation = evaluate_conversation(conversation, inbox)
|
||||
@@ -51,7 +44,7 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
if evaluation[:complete]
|
||||
resolve_conversation(conversation, inbox, evaluation[:reason])
|
||||
else
|
||||
handoff_conversation(conversation, inbox, evaluation[:reason])
|
||||
handoff_conversation(conversation, evaluation[:reason])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -69,58 +62,105 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
.limit(Limits::BULK_ACTIONS_LIMIT)
|
||||
end
|
||||
|
||||
def inactive_for_initial_action?(conversation) = conversation.last_activity_at < inactivity_cutoff_time
|
||||
|
||||
def still_resolvable_after_evaluation?(conversation)
|
||||
conversation.reload
|
||||
conversation.pending? && conversation.last_activity_at < inactivity_cutoff_time
|
||||
conversation.pending? && inactive_for_initial_action?(conversation)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
false
|
||||
end
|
||||
|
||||
def resolve_conversation(conversation, inbox, reason)
|
||||
create_private_note(conversation, inbox, "Auto-resolved: #{reason}")
|
||||
create_resolution_message(conversation, inbox)
|
||||
conversation.with_captain_activity_context(
|
||||
reason: CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON,
|
||||
reason_type: :inference
|
||||
) { conversation.resolved! }
|
||||
def resolve_time_based_conversation(conversation, inbox)
|
||||
resolved = false
|
||||
conversation.with_lock do
|
||||
conversation.reload
|
||||
next unless conversation.pending? && inactive_for_initial_action?(conversation)
|
||||
|
||||
create_resolution_message(conversation, inbox)
|
||||
conversation.resolved!
|
||||
resolved = true
|
||||
end
|
||||
return unless resolved
|
||||
|
||||
Captain::ConversationEvents.resolved(
|
||||
conversation: conversation,
|
||||
assistant: inbox.captain_assistant,
|
||||
assistant: captain_assistant,
|
||||
source: Captain::ConversationEvents::Sources::TIME_BASED,
|
||||
at: Time.current
|
||||
)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def resolve_conversation(conversation, inbox, reason)
|
||||
resolved = with_inference_activity_context(conversation, CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON) do
|
||||
perform_locked_transition(conversation) do
|
||||
conversation.resolved!
|
||||
create_private_note(conversation, "Auto-resolved: #{reason}")
|
||||
create_resolution_message(conversation, inbox)
|
||||
end
|
||||
end
|
||||
record_inference_resolution(conversation) if resolved
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def record_inference_resolution(conversation)
|
||||
Captain::ConversationEvents.resolved(
|
||||
conversation: conversation,
|
||||
assistant: captain_assistant,
|
||||
source: Captain::ConversationEvents::Sources::INFERENCE,
|
||||
at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def handoff_conversation(conversation, inbox, reason)
|
||||
create_private_note(conversation, inbox, "Auto-handoff: #{reason}")
|
||||
create_handoff_message(conversation, inbox)
|
||||
conversation.with_captain_activity_context(
|
||||
reason: CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON,
|
||||
reason_type: :inference
|
||||
) { conversation.bot_handoff! }
|
||||
def handoff_conversation(conversation, reason)
|
||||
handed_off = with_inference_activity_context(conversation, CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON) do
|
||||
perform_locked_transition(conversation) do
|
||||
conversation.bot_handoff!(dispatch_event: false)
|
||||
create_private_note(conversation, "Auto-handoff: #{reason}")
|
||||
create_handoff_message(conversation)
|
||||
end
|
||||
end
|
||||
return unless handed_off
|
||||
|
||||
conversation.dispatch_bot_handoff_event
|
||||
Captain::ConversationEvents.handed_off(
|
||||
conversation: conversation,
|
||||
assistant: inbox.captain_assistant,
|
||||
assistant: captain_assistant,
|
||||
source: Captain::ConversationEvents::Sources::INFERENCE,
|
||||
reason_category: :pending_clarification,
|
||||
at: Time.current
|
||||
)
|
||||
send_out_of_office_message_if_applicable(conversation.reload)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def perform_locked_transition(conversation)
|
||||
conversation.with_lock do
|
||||
conversation.reload
|
||||
next false unless conversation.pending? && inactive_for_initial_action?(conversation)
|
||||
|
||||
yield
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def with_inference_activity_context(conversation, reason, &)
|
||||
conversation.with_captain_activity_context(reason: reason, reason_type: :inference, &)
|
||||
end
|
||||
|
||||
def send_out_of_office_message_if_applicable(conversation)
|
||||
# Campaign conversations should never receive OOO templates — the campaign itself
|
||||
# serves as the initial outreach, and OOO would be confusing in that context.
|
||||
return if conversation.campaign.present?
|
||||
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation) if conversation.campaign.blank?
|
||||
end
|
||||
|
||||
def create_private_note(conversation, inbox, content)
|
||||
def create_private_note(conversation, content)
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: inbox.captain_assistant,
|
||||
sender: captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: content
|
||||
@@ -128,27 +168,27 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
end
|
||||
|
||||
def create_resolution_message(conversation, inbox)
|
||||
return unless inbox.captain_assistant.send_inactivity_resolution_message?
|
||||
return unless captain_assistant.send_inactivity_resolution_message?
|
||||
|
||||
I18n.with_locale(inbox.account.locale) do
|
||||
resolution_message = inbox.captain_assistant.config['resolution_message']
|
||||
resolution_message = captain_assistant.config['resolution_message']
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
|
||||
sender: inbox.captain_assistant
|
||||
sender: captain_assistant
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_handoff_message(conversation, inbox)
|
||||
handoff_message = inbox.captain_assistant.config['handoff_message']
|
||||
def create_handoff_message(conversation)
|
||||
handoff_message = captain_assistant.config['handoff_message']
|
||||
return if handoff_message.blank?
|
||||
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
sender: inbox.captain_assistant,
|
||||
sender: captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: handoff_message,
|
||||
|
||||
Reference in New Issue
Block a user