feat: captain decides if conversation should be resolved or kept open (#13336)
# Pull Request Template ## Description captain decides if conversation should be resolved or open Fixes https://linear.app/chatwoot/issue/AI-91/make-captain-resolution-time-configurable Update: Added 2 entries in reporting events: `conversation_captain_handoff` and `conversation_captain_resolved` ## Type of change Please delete options that are not relevant. - [x] New feature (non-breaking change which adds functionality) - [x] This change requires a documentation update ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. LLM call decides that conversation is resolved, drops a private note <img width="1228" height="438" alt="image" src="https://github.com/user-attachments/assets/fb2cf1e9-4b2b-458b-a1e2-45c53d6a0158" /> LLM call decides conversation is still open as query was not resolved <img width="1215" height="573" alt="image" src="https://github.com/user-attachments/assets/2d1d5322-f567-487e-954e-11ab0798d11c" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
@@ -3,19 +3,20 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
def conversation_resolved(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
|
||||
event_end_time = event.timestamp
|
||||
time_to_resolve = event_end_time.to_i - conversation.created_at.to_i
|
||||
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_resolved',
|
||||
value: time_to_resolve,
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
|
||||
conversation.updated_at),
|
||||
event_end_time),
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
|
||||
create_bot_resolved_event(conversation, reporting_event)
|
||||
@@ -69,41 +70,51 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
def conversation_bot_handoff(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
event_end_time = event.timestamp
|
||||
|
||||
# check if a conversation_bot_handoff event exists for this conversation
|
||||
bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
|
||||
return if bot_handoff_event.present?
|
||||
|
||||
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
|
||||
time_to_handoff = event_end_time.to_i - conversation.created_at.to_i
|
||||
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_bot_handoff',
|
||||
value: time_to_handoff,
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, event_end_time),
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
reporting_event.save!
|
||||
end
|
||||
|
||||
def conversation_captain_inference_resolved(event)
|
||||
create_captain_inference_event(event, 'conversation_captain_inference_resolved')
|
||||
end
|
||||
|
||||
def conversation_captain_inference_handoff(event)
|
||||
create_captain_inference_event(event, 'conversation_captain_inference_handoff')
|
||||
end
|
||||
|
||||
def conversation_opened(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
event_end_time = event.timestamp
|
||||
|
||||
# Find the most recent resolved event for this conversation
|
||||
last_resolved_event = ReportingEvent.where(
|
||||
conversation_id: conversation.id,
|
||||
name: 'conversation_resolved'
|
||||
).order(event_end_time: :desc).first
|
||||
).where('event_end_time <= ?', event_end_time).order(event_end_time: :desc).first
|
||||
|
||||
# For first-time openings, value is 0
|
||||
# For reopenings, calculate time since resolution
|
||||
if last_resolved_event
|
||||
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
|
||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
|
||||
time_since_resolved = event_end_time.to_i - last_resolved_event.event_end_time.to_i
|
||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time)
|
||||
start_time = last_resolved_event.event_end_time
|
||||
else
|
||||
time_since_resolved = 0
|
||||
@@ -111,12 +122,12 @@ class ReportingEventListener < BaseListener
|
||||
start_time = conversation.created_at
|
||||
end
|
||||
|
||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_opened',
|
||||
value: time_since_resolved,
|
||||
@@ -126,11 +137,27 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: start_time,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
reporting_event.save!
|
||||
end
|
||||
|
||||
def create_captain_inference_event(event, event_name)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
time_to_event = event.timestamp.to_i - conversation.created_at.to_i
|
||||
|
||||
ReportingEvent.create!(
|
||||
name: event_name,
|
||||
value: time_to_event,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event.timestamp
|
||||
)
|
||||
end
|
||||
|
||||
def create_bot_resolved_event(conversation, reporting_event)
|
||||
return unless conversation.inbox.active_bot?
|
||||
# We don't want to create a bot_resolved event if there is user interaction on the conversation
|
||||
|
||||
@@ -81,6 +81,8 @@ class Message < ApplicationRecord
|
||||
|
||||
# when you have a temperory id in your frontend and want it echoed back via action cable
|
||||
attr_accessor :echo_id
|
||||
# Transient flag used to skip waiting_since clearing for specific bot/system messages.
|
||||
attr_accessor :preserve_waiting_since
|
||||
|
||||
enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
|
||||
enum content_type: {
|
||||
@@ -323,20 +325,24 @@ class Message < ApplicationRecord
|
||||
end
|
||||
|
||||
def update_waiting_since
|
||||
waiting_present = conversation.waiting_since.present?
|
||||
clear_waiting_since_on_outgoing_response if conversation.waiting_since.present? && !private
|
||||
set_waiting_since_on_incoming_message
|
||||
end
|
||||
|
||||
if waiting_present && !private
|
||||
if human_response?
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
|
||||
)
|
||||
conversation.update(waiting_since: nil)
|
||||
elsif bot_response?
|
||||
# Bot responses also clear waiting_since (simpler than checking on next customer message)
|
||||
conversation.update(waiting_since: nil)
|
||||
end
|
||||
def clear_waiting_since_on_outgoing_response
|
||||
if human_response?
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
|
||||
)
|
||||
conversation.update(waiting_since: nil)
|
||||
return
|
||||
end
|
||||
|
||||
# Bot responses also clear waiting_since (simpler than checking on next customer message)
|
||||
conversation.update(waiting_since: nil) if bot_response? && !preserve_waiting_since
|
||||
end
|
||||
|
||||
def set_waiting_since_on_incoming_message
|
||||
# Set waiting_since when customer sends a message (if currently blank)
|
||||
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
|
||||
end
|
||||
|
||||
@@ -235,8 +235,10 @@ en:
|
||||
activity:
|
||||
captain:
|
||||
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
|
||||
resolved_with_reason: 'Conversation was marked resolved by %{user_name} (%{reason})'
|
||||
resolved_by_tool: 'Conversation was marked resolved by %{user_name}: %{reason}'
|
||||
open: 'Conversation was marked open by %{user_name}'
|
||||
open_with_reason: 'Conversation was marked open by %{user_name} (%{reason})'
|
||||
auto_opened_after_agent_reply: 'Conversation was marked open automatically after an agent reply'
|
||||
agent_bot:
|
||||
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
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)
|
||||
return if inbox.account.captain_disable_auto_resolve
|
||||
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
|
||||
resolvable_conversations.each do |conversation|
|
||||
create_outgoing_message(conversation, inbox)
|
||||
conversation.resolved!
|
||||
if inbox.account.feature_enabled?('captain_tasks')
|
||||
perform_with_evaluation(inbox)
|
||||
else
|
||||
perform_time_based(inbox)
|
||||
end
|
||||
ensure
|
||||
Current.reset
|
||||
@@ -17,18 +18,118 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def create_outgoing_message(conversation, inbox)
|
||||
def perform_time_based(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_pending_conversations(inbox).each do |conversation|
|
||||
create_resolution_message(conversation, inbox)
|
||||
conversation.resolved!
|
||||
end
|
||||
end
|
||||
|
||||
def perform_with_evaluation(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_pending_conversations(inbox).each do |conversation|
|
||||
evaluation = evaluate_conversation(conversation, inbox)
|
||||
next unless still_resolvable_after_evaluation?(conversation)
|
||||
|
||||
if evaluation[:complete]
|
||||
resolve_conversation(conversation, inbox, evaluation[:reason])
|
||||
else
|
||||
handoff_conversation(conversation, inbox, evaluation[:reason])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def evaluate_conversation(conversation, inbox)
|
||||
Captain::ConversationCompletionService.new(
|
||||
account: inbox.account,
|
||||
conversation_display_id: conversation.display_id
|
||||
).perform
|
||||
end
|
||||
|
||||
def resolvable_pending_conversations(inbox)
|
||||
inbox.conversations.pending
|
||||
.where('last_activity_at < ?', auto_resolve_cutoff_time)
|
||||
.limit(Limits::BULK_ACTIONS_LIMIT)
|
||||
end
|
||||
|
||||
def still_resolvable_after_evaluation?(conversation)
|
||||
conversation.reload
|
||||
conversation.pending? && conversation.last_activity_at < auto_resolve_cutoff_time
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
false
|
||||
end
|
||||
|
||||
def auto_resolve_cutoff_time
|
||||
Time.now.utc - 1.hour
|
||||
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! }
|
||||
conversation.dispatch_captain_inference_resolved_event
|
||||
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! }
|
||||
conversation.dispatch_captain_inference_handoff_event
|
||||
send_out_of_office_message_if_applicable(conversation.reload)
|
||||
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)
|
||||
end
|
||||
|
||||
def create_private_note(conversation, inbox, content)
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: content
|
||||
)
|
||||
end
|
||||
|
||||
def create_resolution_message(conversation, inbox)
|
||||
I18n.with_locale(inbox.account.locale) do
|
||||
resolution_message = inbox.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
|
||||
}
|
||||
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
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_handoff_message(conversation, inbox)
|
||||
handoff_message = inbox.captain_assistant.config['handoff_message']
|
||||
return if handoff_message.blank?
|
||||
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: handoff_message,
|
||||
preserve_waiting_since: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,18 +6,30 @@ module Enterprise::ActivityMessageHandler
|
||||
key = captain_activity_key
|
||||
return unless key
|
||||
|
||||
I18n.t(key, user_name: Current.executed_by.name, reason: Current.captain_resolve_reason, locale: locale)
|
||||
I18n.t(key, user_name: Current.executed_by.name, reason: captain_status_reason, locale: locale)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_status_reason
|
||||
captain_activity_reason.presence
|
||||
end
|
||||
|
||||
def captain_activity_key
|
||||
if resolved? && Current.captain_resolve_reason.present?
|
||||
'conversations.activity.captain.resolved_by_tool'
|
||||
elsif resolved?
|
||||
'conversations.activity.captain.resolved'
|
||||
elsif open?
|
||||
'conversations.activity.captain.open'
|
||||
end
|
||||
return captain_resolved_activity_key if resolved?
|
||||
return captain_open_activity_key if open?
|
||||
end
|
||||
|
||||
def captain_resolved_activity_key
|
||||
return 'conversations.activity.captain.resolved_by_tool' if captain_activity_reason_type == :tool && captain_status_reason.present?
|
||||
return 'conversations.activity.captain.resolved_with_reason' if captain_status_reason.present?
|
||||
|
||||
'conversations.activity.captain.resolved'
|
||||
end
|
||||
|
||||
def captain_open_activity_key
|
||||
return 'conversations.activity.captain.open_with_reason' if captain_status_reason.present?
|
||||
|
||||
'conversations.activity.captain.open'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
module Enterprise::Conversation
|
||||
attr_accessor :captain_activity_reason, :captain_activity_reason_type
|
||||
|
||||
def dispatch_captain_inference_resolved_event
|
||||
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_RESOLVED)
|
||||
end
|
||||
|
||||
def dispatch_captain_inference_handoff_event
|
||||
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_HANDOFF)
|
||||
end
|
||||
|
||||
def list_of_keys
|
||||
super + %w[sla_policy_id]
|
||||
end
|
||||
|
||||
def with_captain_activity_context(reason:, reason_type:)
|
||||
previous_reason = captain_activity_reason
|
||||
previous_reason_type = captain_activity_reason_type
|
||||
|
||||
self.captain_activity_reason = reason
|
||||
self.captain_activity_reason_type = reason_type
|
||||
yield
|
||||
ensure
|
||||
self.captain_activity_reason = previous_reason
|
||||
self.captain_activity_reason_type = previous_reason_type
|
||||
end
|
||||
|
||||
# Include select additional_attributes keys (call related) for update events
|
||||
def allowed_keys?
|
||||
return true if super
|
||||
@@ -13,4 +35,10 @@ module Enterprise::Conversation
|
||||
changed_attr_keys = attrs_change[1].keys
|
||||
changed_attr_keys.intersect?(%w[call_status])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
dispatcher_dispatch(event_name)
|
||||
end
|
||||
end
|
||||
|
||||
4
enterprise/lib/captain/conversation_completion_schema.rb
Normal file
4
enterprise/lib/captain/conversation_completion_schema.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class Captain::ConversationCompletionSchema < RubyLLM::Schema
|
||||
boolean :complete, description: 'Whether the conversation is complete and can be closed'
|
||||
string :reason, description: 'Brief explanation of why the conversation is complete or incomplete'
|
||||
end
|
||||
68
enterprise/lib/captain/conversation_completion_service.rb
Normal file
68
enterprise/lib/captain/conversation_completion_service.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
# Evaluates whether a conversation is complete and can be auto-resolved.
|
||||
# Used by InboxPendingConversationsResolutionJob to determine if inactive
|
||||
# conversations should be resolved or handed off to human agents.
|
||||
#
|
||||
# NOTE: This service intentionally does NOT count toward Captain usage limits.
|
||||
# The response excludes the :message key that Enterprise::Captain::BaseTaskService
|
||||
# checks for usage tracking. This is an internal operational evaluation,
|
||||
# not a customer-facing value-add, so we don't charge for it.
|
||||
class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::ConversationCompletionSchema
|
||||
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
content = format_messages_as_string
|
||||
return default_incomplete_response('No messages found') if content.blank?
|
||||
|
||||
response = make_api_call(
|
||||
model: InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('conversation_completion') },
|
||||
{ role: 'user', content: content }
|
||||
],
|
||||
schema: RESPONSE_SCHEMA
|
||||
)
|
||||
|
||||
return default_incomplete_response(response[:error]) if response[:error].present?
|
||||
|
||||
parse_response(response[:message])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prompt_from_file(file_name)
|
||||
Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read
|
||||
end
|
||||
|
||||
def format_messages_as_string
|
||||
messages = conversation_messages(start_from: 0)
|
||||
messages.map do |msg|
|
||||
sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant'
|
||||
"#{sender_type}: #{msg[:content]}"
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def parse_response(message)
|
||||
return default_incomplete_response('Invalid response format') unless message.is_a?(Hash)
|
||||
|
||||
{
|
||||
complete: message['complete'] == true,
|
||||
reason: message['reason'] || 'No reason provided'
|
||||
}
|
||||
end
|
||||
|
||||
def default_incomplete_response(reason)
|
||||
{ complete: false, reason: reason }
|
||||
end
|
||||
|
||||
def event_name
|
||||
'captain.conversation_completion'
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
Captain::ConversationCompletionService.prepend_mod_with('Captain::ConversationCompletionService')
|
||||
@@ -0,0 +1,21 @@
|
||||
You are evaluating whether a customer support conversation is complete and can be safely closed. When in doubt, keep the conversation OPEN. It is far better to hand off to a human agent unnecessarily than to close a conversation where the customer still needs help.
|
||||
|
||||
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
|
||||
|
||||
A conversation is INCOMPLETE (keep open) if ANY of these apply:
|
||||
- The assistant suggested the customer try something or take an action — they may still be attempting it
|
||||
- The assistant directed the customer to an external resource, link, or contact — they may still be following up
|
||||
- The assistant asked a question or requested information that the customer hasn't provided
|
||||
- The customer asked a question that wasn't fully answered
|
||||
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
|
||||
- The customer raised multiple questions or issues and not all were addressed
|
||||
|
||||
A conversation is COMPLETE only if ALL of these are true:
|
||||
- The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer
|
||||
- There are no unanswered questions, unmet requests, or outstanding follow-ups from either side
|
||||
- Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation.
|
||||
|
||||
Analyze the conversation and respond with ONLY a JSON object (no other text):
|
||||
{"complete": true, "reason": "brief explanation"}
|
||||
or
|
||||
{"complete": false, "reason": "brief explanation"}
|
||||
@@ -10,12 +10,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
|
||||
|
||||
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
|
||||
|
||||
Current.captain_resolve_reason = reason
|
||||
begin
|
||||
conversation.resolved!
|
||||
ensure
|
||||
Current.captain_resolve_reason = nil
|
||||
end
|
||||
conversation.with_captain_activity_context(reason: reason, reason_type: :tool) { conversation.resolved! }
|
||||
|
||||
"Conversation ##{conversation.display_id} resolved#{" (Reason: #{reason})" if reason}"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Overrides the quota check from Enterprise::Captain::BaseTaskService
|
||||
# so that conversation completion evaluation always runs regardless of
|
||||
# the customer's Captain usage quota. This is an internal operational
|
||||
# check, not a customer-facing feature — it should never be blocked
|
||||
# by quota exhaustion.
|
||||
module Enterprise::Captain::ConversationCompletionService
|
||||
private
|
||||
|
||||
def responses_available?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -36,7 +36,7 @@ class Captain::BaseTaskService
|
||||
"#{endpoint}/v1"
|
||||
end
|
||||
|
||||
def make_api_call(model:, messages:, tools: [])
|
||||
def make_api_call(model:, messages:, schema: nil, tools: [])
|
||||
# Community edition prerequisite checks
|
||||
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
|
||||
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
|
||||
@@ -46,7 +46,7 @@ class Captain::BaseTaskService
|
||||
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
|
||||
|
||||
response = send(instrumentation_method, instrumentation_params) do
|
||||
execute_ruby_llm_request(model: model, messages: messages, tools: tools)
|
||||
execute_ruby_llm_request(model: model, messages: messages, schema: schema, tools: tools)
|
||||
end
|
||||
|
||||
return response unless build_follow_up_context? && response[:message].present?
|
||||
@@ -54,9 +54,9 @@ class Captain::BaseTaskService
|
||||
response.merge(follow_up_context: build_follow_up_context(messages, response))
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(model:, messages:, tools: [])
|
||||
def execute_ruby_llm_request(model:, messages:, schema: nil, tools: [])
|
||||
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
|
||||
chat = build_chat(context, model: model, messages: messages, tools: tools)
|
||||
chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools)
|
||||
|
||||
conversation_messages = messages.reject { |m| m[:role] == 'system' }
|
||||
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
|
||||
@@ -69,10 +69,11 @@ class Captain::BaseTaskService
|
||||
{ error: e.message, request_messages: messages }
|
||||
end
|
||||
|
||||
def build_chat(context, model:, messages:, tools: [])
|
||||
def build_chat(context, model:, messages:, schema: nil, tools: [])
|
||||
chat = context.chat(model: model)
|
||||
system_msg = messages.find { |m| m[:role] == 'system' }
|
||||
chat.with_instructions(system_msg[:content]) if system_msg
|
||||
chat.with_schema(schema) if schema
|
||||
|
||||
if tools.any?
|
||||
tools.each { |tool| chat = chat.with_tool(tool) }
|
||||
@@ -131,7 +132,8 @@ class Captain::BaseTaskService
|
||||
.reorder('id desc')
|
||||
.each do |message|
|
||||
content = message.content_for_llm
|
||||
break unless content.present? && character_count + content.length <= TOKEN_LIMIT
|
||||
next if content.blank?
|
||||
break if character_count + content.length > TOKEN_LIMIT
|
||||
|
||||
messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
|
||||
character_count += content.length
|
||||
|
||||
@@ -4,7 +4,6 @@ module Current
|
||||
thread_mattr_accessor :account_user
|
||||
thread_mattr_accessor :executed_by
|
||||
thread_mattr_accessor :contact
|
||||
thread_mattr_accessor :captain_resolve_reason
|
||||
|
||||
def self.reset
|
||||
Current.user = nil
|
||||
@@ -12,6 +11,5 @@ module Current
|
||||
Current.account_user = nil
|
||||
Current.executed_by = nil
|
||||
Current.contact = nil
|
||||
Current.captain_resolve_reason = nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,6 +21,8 @@ module Events::Types
|
||||
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
|
||||
CONVERSATION_OPENED = 'conversation.opened'
|
||||
CONVERSATION_RESOLVED = 'conversation.resolved'
|
||||
CONVERSATION_CAPTAIN_INFERENCE_RESOLVED = 'conversation.captain_inference_resolved'
|
||||
CONVERSATION_CAPTAIN_INFERENCE_HANDOFF = 'conversation.captain_inference_handoff'
|
||||
|
||||
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
|
||||
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
|
||||
|
||||
@@ -66,7 +66,7 @@ module Integrations::LlmInstrumentationCompletionHelpers
|
||||
return if message.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.is_a?(String) ? message : message.to_json)
|
||||
end
|
||||
|
||||
def set_usage_metrics(span, result)
|
||||
|
||||
@@ -2,16 +2,14 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
let!(:inbox) { create(:inbox) }
|
||||
|
||||
let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) }
|
||||
let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 10.minutes.ago, status: :pending) }
|
||||
let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.minute.ago, status: :pending) }
|
||||
let!(:open_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.hour.ago, status: :open) }
|
||||
|
||||
let!(:captain_assistant) { create(:captain_assistant, account: inbox.account) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, inbox: inbox, captain_assistant: captain_assistant)
|
||||
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
||||
stub_const('Limits::BULK_ACTIONS_LIMIT', 3)
|
||||
inbox.reload
|
||||
end
|
||||
|
||||
@@ -20,49 +18,307 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
.to have_enqueued_job.on_queue('low')
|
||||
end
|
||||
|
||||
it 'resolves only the eligible pending conversations' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
expect(recent_pending_conversation.reload.status).to eq('pending')
|
||||
expect(open_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'creates exactly one outgoing message with configured content' do
|
||||
custom_message = 'This is a custom resolution message.'
|
||||
captain_assistant.update!(config: { 'resolution_message' => custom_message })
|
||||
|
||||
expect do
|
||||
context 'when captain_tasks is disabled' do
|
||||
it 'resolves pending conversations inactive for over 1 hour' do
|
||||
described_class.perform_now(inbox)
|
||||
end.to change { resolvable_pending_conversation.messages.outgoing.reload.count }.by(1)
|
||||
|
||||
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
|
||||
expect(outgoing_message.content).to eq(custom_message)
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
end
|
||||
|
||||
it 'does not resolve recent pending conversations' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(recent_pending_conversation.reload.status).to eq('pending')
|
||||
end
|
||||
|
||||
it 'does not affect open conversations' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(open_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'does not call ConversationCompletionService' do
|
||||
allow(Captain::ConversationCompletionService).to receive(:new)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(Captain::ConversationCompletionService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates an outgoing message with default auto resolution message if not configured' do
|
||||
captain_assistant.update!(config: {})
|
||||
context 'when captain_tasks is enabled' do
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
end
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
|
||||
expect(outgoing_message.content).to eq(
|
||||
I18n.t('conversations.activity.auto_resolution_message')
|
||||
)
|
||||
end
|
||||
it 'only evaluates eligible pending conversations (inactive > 1 hour)' do
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_call_original
|
||||
|
||||
it 'adds the correct activity message after resolution by Captain' do
|
||||
described_class.perform_now(inbox)
|
||||
expected_content = I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to have_been_enqueued.with(
|
||||
resolvable_pending_conversation,
|
||||
{
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
}
|
||||
# Mock the service to return complete for all conversations
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: true, reason: 'Test' })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
# Only resolvable conversation should be evaluated (not recent or open)
|
||||
expect(Captain::ConversationCompletionService).to have_received(:new).with(
|
||||
account: inbox.account,
|
||||
conversation_display_id: resolvable_pending_conversation.display_id
|
||||
)
|
||||
expect(recent_pending_conversation.reload.status).to eq('pending')
|
||||
expect(open_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'skips auto-action if conversation receives new activity after evaluation' do
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform) do
|
||||
resolvable_pending_conversation.update!(last_activity_at: Time.current)
|
||||
{ complete: true, reason: 'Customer question was answered' }
|
||||
end
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('pending')
|
||||
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM evaluation returns complete' do
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: true, reason: 'Customer question was answered' })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
end
|
||||
|
||||
it 'resolves the conversation' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
end
|
||||
|
||||
it 'creates a private note with the reason' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
private_note = resolvable_pending_conversation.messages.where(private: true).last
|
||||
expect(private_note.content).to eq('Auto-resolved: Customer question was answered')
|
||||
end
|
||||
|
||||
it 'creates resolution message with configured content' do
|
||||
custom_message = 'This is a custom resolution message.'
|
||||
captain_assistant.update!(config: { 'resolution_message' => custom_message })
|
||||
inbox.reload
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
|
||||
expect(public_message.content).to eq(custom_message)
|
||||
end
|
||||
|
||||
it 'creates resolution message with default if not configured' do
|
||||
captain_assistant.update!(config: {})
|
||||
inbox.reload
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
|
||||
expect(public_message.content).to eq(I18n.t('conversations.activity.auto_resolution_message'))
|
||||
end
|
||||
|
||||
it 'adds the correct activity message after resolution' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expected_content = I18n.t(
|
||||
'conversations.activity.captain.resolved_with_reason',
|
||||
user_name: captain_assistant.name,
|
||||
reason: 'no outstanding questions'
|
||||
)
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to have_been_enqueued.with(
|
||||
resolvable_pending_conversation,
|
||||
{
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a captain inference resolved reporting event' do
|
||||
perform_enqueued_jobs do
|
||||
described_class.perform_now(inbox)
|
||||
end
|
||||
|
||||
inference_event = ReportingEvent.find_by(
|
||||
conversation_id: resolvable_pending_conversation.id,
|
||||
name: 'conversation_captain_inference_resolved'
|
||||
)
|
||||
expect(inference_event).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM evaluation returns incomplete' do
|
||||
let(:handoff_reason) { 'Assistant asked for order number but customer did not respond' }
|
||||
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: handoff_reason })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
end
|
||||
|
||||
it 'hands off the conversation to agents (status becomes open)' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'creates a private note with the reason' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
private_note = resolvable_pending_conversation.messages.where(private: true).last
|
||||
expect(private_note.content).to eq("Auto-handoff: #{handoff_reason}")
|
||||
end
|
||||
|
||||
it 'creates handoff message with configured content' do
|
||||
handoff_message = 'Connecting you to a human agent...'
|
||||
captain_assistant.update!(config: { 'handoff_message' => handoff_message })
|
||||
inbox.reload
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
|
||||
expect(public_message.content).to eq(handoff_message)
|
||||
expect(public_message.additional_attributes['preserve_waiting_since']).to be_nil
|
||||
end
|
||||
|
||||
it 'preserves existing waiting_since when handoff message is configured' do
|
||||
handoff_message = 'Connecting you to a human agent...'
|
||||
original_waiting_since = 3.hours.ago
|
||||
|
||||
captain_assistant.update!(config: { 'handoff_message' => handoff_message })
|
||||
resolvable_pending_conversation.update!(waiting_since: original_waiting_since)
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:perform_if_applicable)
|
||||
inbox.reload
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.waiting_since).to be_within(1.second).of(original_waiting_since)
|
||||
end
|
||||
|
||||
it 'does not create handoff message if not configured' do
|
||||
captain_assistant.update!(config: {})
|
||||
inbox.reload
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
expect do
|
||||
described_class.perform_now(inbox)
|
||||
end.not_to(change { resolvable_pending_conversation.messages.where(private: false).count })
|
||||
end
|
||||
|
||||
it 'adds the correct activity message after handoff' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expected_content = I18n.t(
|
||||
'conversations.activity.captain.open_with_reason',
|
||||
user_name: captain_assistant.name,
|
||||
reason: 'pending clarification from customer'
|
||||
)
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to have_been_enqueued.with(
|
||||
resolvable_pending_conversation,
|
||||
{
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a captain inference handoff reporting event' do
|
||||
perform_enqueued_jobs do
|
||||
described_class.perform_now(inbox)
|
||||
end
|
||||
|
||||
inference_event = ReportingEvent.find_by(
|
||||
conversation_id: resolvable_pending_conversation.id,
|
||||
name: 'conversation_captain_inference_handoff'
|
||||
)
|
||||
expect(inference_event).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handoff occurs outside business hours' do
|
||||
let(:handoff_reason) { 'Customer has not responded to clarifying question' }
|
||||
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: handoff_reason })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
inbox.update!(working_hours_enabled: true, out_of_office_message: 'We are currently unavailable.')
|
||||
end
|
||||
|
||||
it 'sends OOO message for non-campaign conversations' do
|
||||
travel_to '01.11.2020 13:00'.to_datetime do
|
||||
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
ooo_message = resolvable_pending_conversation.messages.template.last
|
||||
expect(ooo_message).to be_present
|
||||
expect(ooo_message.content).to eq('We are currently unavailable.')
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not send OOO message for campaign conversations' do
|
||||
campaign = create(:campaign, account: inbox.account, inbox: inbox)
|
||||
resolvable_pending_conversation.update!(campaign: campaign)
|
||||
|
||||
travel_to '01.11.2020 13:00'.to_datetime do
|
||||
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.messages.template).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not send OOO message during business hours' do
|
||||
travel_to '26.10.2020 10:00'.to_datetime do
|
||||
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.messages.template).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM evaluation fails' do
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: 'API Error' })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
end
|
||||
|
||||
it 'hands off as safe default' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not resolve conversations when auto-resolve is disabled at execution time' do
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::ConversationCompletionService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation is complete' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'complete' => true, 'reason' => 'Customer question was fully answered' },
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Thanks!')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'returns complete: true with reason' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be true
|
||||
expect(result[:reason]).to eq('Customer question was fully answered')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is incomplete' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'complete' => false, 'reason' => 'Assistant asked for order number but customer did not respond' },
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Where is my order?')
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'Can you please share your order number?')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'returns complete: false with reason' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('Assistant asked for order number but customer did not respond')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has no messages' do
|
||||
it 'returns incomplete with appropriate reason' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('No messages found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM returns non-hash response' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: 'unexpected string response',
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'returns incomplete as safe default' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('Invalid response format')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API call fails' do
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
allow(mock_chat).to receive(:ask).and_raise(StandardError.new('API Error'))
|
||||
end
|
||||
|
||||
it 'returns incomplete with error message' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('API Error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain_tasks feature is disabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
end
|
||||
|
||||
it 'does not evaluate the conversation as complete' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).not_to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when customer quota is exhausted' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'complete' => true, 'reason' => 'Customer question was fully answered' },
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
allow(account).to receive(:usage_limits).and_return({
|
||||
captain: { responses: { current_available: 0 } }
|
||||
})
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'still runs the evaluation bypassing quota check' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:error]).to be_nil
|
||||
expect(result[:complete]).to be true
|
||||
expect(result[:reason]).to eq('Customer question was fully answered')
|
||||
end
|
||||
|
||||
it 'does not increment usage' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -63,6 +63,20 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
||||
tool.perform(tool_context, reason: 'Test reason')
|
||||
end
|
||||
|
||||
it 'creates a conversation_bot_handoff reporting event' do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
|
||||
Current.executed_by = assistant
|
||||
|
||||
perform_enqueued_jobs do
|
||||
tool.perform(tool_context, reason: 'Customer needs specialized support')
|
||||
end
|
||||
|
||||
reporting_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
|
||||
expect(reporting_event).to be_present
|
||||
ensure
|
||||
Current.reset
|
||||
end
|
||||
|
||||
it 'logs tool usage with reason' do
|
||||
reason = 'Customer needs help'
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
|
||||
@@ -29,10 +29,14 @@ RSpec.describe Captain::Tools::ResolveConversationTool do
|
||||
)
|
||||
end
|
||||
|
||||
it 'clears captain_resolve_reason after execution' do
|
||||
tool.perform(tool_context, reason: 'Possible spam')
|
||||
it 'creates a conversation_resolved reporting event' do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
|
||||
|
||||
expect(Current.captain_resolve_reason).to be_nil
|
||||
expect do
|
||||
perform_enqueued_jobs do
|
||||
tool.perform(tool_context, reason: 'Possible spam')
|
||||
end
|
||||
end.to change { ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_resolved').count }.by(1)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ describe CaptainListener do
|
||||
let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) }
|
||||
|
||||
describe '#conversation_resolved' do
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
|
||||
let(:event_name) { :conversation_resolved }
|
||||
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
|
||||
|
||||
@@ -27,12 +27,24 @@ describe ReportingEventListener do
|
||||
end
|
||||
|
||||
it 'creates conversation_resolved event with business hour value' do
|
||||
event = Events::Base.new('conversation.resolved', Time.zone.now, conversation: new_conversation)
|
||||
event = Events::Base.new('conversation.resolved', updated_at, conversation: new_conversation)
|
||||
listener.conversation_resolved(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_resolved')[0]['value_in_business_hours']).to be 144_000.0
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses event timestamp even when conversation updated_at changes later' do
|
||||
resolved_at = conversation.created_at + 20.minutes
|
||||
allow(conversation).to receive(:updated_at).and_return(resolved_at + 10.minutes)
|
||||
event = Events::Base.new('conversation.resolved', resolved_at, conversation: conversation)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_resolved').first
|
||||
expect(reporting_event.value).to eq 1200
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(resolved_at)
|
||||
end
|
||||
|
||||
describe 'conversation_bot_resolved' do
|
||||
# create an agent bot
|
||||
let!(:agent_bot_inbox) { create(:inbox, account: account) }
|
||||
@@ -261,11 +273,55 @@ describe ReportingEventListener do
|
||||
end
|
||||
|
||||
it 'creates conversation_bot_handoff event with business hour value' do
|
||||
event = Events::Base.new('conversation.bot_handoff', Time.zone.now, conversation: new_conversation)
|
||||
event = Events::Base.new('conversation.bot_handoff', updated_at, conversation: new_conversation)
|
||||
listener.conversation_bot_handoff(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_bot_handoff')[0]['value_in_business_hours']).to be 144_000.0
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses event timestamp even when conversation updated_at changes later' do
|
||||
handoff_at = conversation.created_at + 10.minutes
|
||||
allow(conversation).to receive(:updated_at).and_return(handoff_at + 15.minutes)
|
||||
event = Events::Base.new('conversation.bot_handoff', handoff_at, conversation: conversation)
|
||||
|
||||
listener.conversation_bot_handoff(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_bot_handoff').first
|
||||
expect(reporting_event.value).to eq 600
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(handoff_at)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_captain_inference_resolved' do
|
||||
it 'creates conversation_captain_inference_resolved event' do
|
||||
expect(account.reporting_events.where(name: 'conversation_captain_inference_resolved').count).to be 0
|
||||
decision_time = conversation.created_at + 60.seconds
|
||||
event = Events::Base.new('conversation.captain_inference_resolved', decision_time, conversation: conversation)
|
||||
allow(conversation).to receive(:updated_at).and_return(decision_time + 5.minutes)
|
||||
|
||||
listener.conversation_captain_inference_resolved(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_captain_inference_resolved').first
|
||||
expect(reporting_event).to be_present
|
||||
expect(reporting_event.value).to eq 60
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(decision_time)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_captain_inference_handoff' do
|
||||
it 'creates conversation_captain_inference_handoff event' do
|
||||
expect(account.reporting_events.where(name: 'conversation_captain_inference_handoff').count).to be 0
|
||||
decision_time = conversation.created_at + 90.seconds
|
||||
event = Events::Base.new('conversation.captain_inference_handoff', decision_time, conversation: conversation)
|
||||
allow(conversation).to receive(:updated_at).and_return(decision_time + 5.minutes)
|
||||
|
||||
listener.conversation_captain_inference_handoff(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_captain_inference_handoff').first
|
||||
expect(reporting_event).to be_present
|
||||
expect(reporting_event.value).to eq 90
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(decision_time)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_opened' do
|
||||
@@ -274,7 +330,8 @@ describe ReportingEventListener do
|
||||
|
||||
it 'creates conversation_opened event with value 0' do
|
||||
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
|
||||
event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation)
|
||||
opened_at = Time.zone.now
|
||||
event = Events::Base.new('conversation.opened', opened_at, conversation: new_conversation)
|
||||
listener.conversation_opened(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1
|
||||
|
||||
@@ -282,7 +339,7 @@ describe ReportingEventListener do
|
||||
expect(opened_event.value).to eq 0
|
||||
expect(opened_event.value_in_business_hours).to eq 0
|
||||
expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at)
|
||||
expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at)
|
||||
expect(opened_event.event_end_time).to be_within(1.second).of(opened_at)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -334,6 +391,17 @@ describe ReportingEventListener do
|
||||
expect(reopened_event.user_id).to eq(user.id)
|
||||
end
|
||||
|
||||
it 'uses event timestamp even when conversation updated_at changes later' do
|
||||
allow(reopened_conversation).to receive(:updated_at).and_return(reopened_time + 20.minutes)
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
|
||||
|
||||
listener.conversation_opened(event)
|
||||
|
||||
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(reopened_event.value).to be_within(1).of(3600)
|
||||
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
|
||||
end
|
||||
|
||||
context 'when business hours enabled for inbox' do
|
||||
let(:resolved_time) { Time.zone.parse('March 20, 2022 12:00') }
|
||||
let(:reopened_time) { Time.zone.parse('March 21, 2022 14:00') }
|
||||
@@ -406,6 +474,45 @@ describe ReportingEventListener do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when latest resolved event is after the conversation opened event timestamp' do
|
||||
let(:previous_resolved_time) { Time.zone.parse('March 22, 2022 09:00') }
|
||||
let(:future_resolved_time) { Time.zone.parse('March 22, 2022 11:00') }
|
||||
let(:reopened_time) { Time.zone.parse('March 22, 2022 10:00') }
|
||||
let(:reopened_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user, updated_at: reopened_time)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
conversation_id: reopened_conversation.id,
|
||||
user_id: user.id,
|
||||
event_start_time: reopened_conversation.created_at,
|
||||
event_end_time: previous_resolved_time)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
conversation_id: reopened_conversation.id,
|
||||
user_id: user.id,
|
||||
event_start_time: previous_resolved_time,
|
||||
event_end_time: future_resolved_time)
|
||||
end
|
||||
|
||||
it 'ignores future resolved events when computing reopen duration' do
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
|
||||
listener.conversation_opened(event)
|
||||
|
||||
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(reopened_event.value).to be_within(1).of(3600)
|
||||
expect(reopened_event.event_start_time).to be_within(1.second).of(previous_resolved_time)
|
||||
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent bot resolves and conversation is reopened' do
|
||||
# This implicitly tests that the first_response time is correctly calculated
|
||||
# By checking that a conversation reopened event is created with the correct values
|
||||
|
||||
@@ -366,6 +366,25 @@ RSpec.describe Message do
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when bot response should preserve waiting_since' do
|
||||
let(:agent_bot) { create(:agent_bot, account: conversation.account) }
|
||||
|
||||
it 'does not clear waiting_since when preserve_waiting_since is set' do
|
||||
original_waiting_since = 45.minutes.ago
|
||||
conversation.update!(waiting_since: original_waiting_since)
|
||||
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
sender: agent_bot,
|
||||
preserve_waiting_since: true
|
||||
)
|
||||
|
||||
expect(conversation.reload.waiting_since).to be_within(1.second).of(original_waiting_since)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with webhook_data' do
|
||||
|
||||
Reference in New Issue
Block a user