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:
Aakash Bakhle
2026-08-12 18:33:48 +05:30
committed by GitHub
parent 121b743f88
commit a47eb375ad
13 changed files with 657 additions and 186 deletions

View File

@@ -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,