584 lines
19 KiB
Ruby
Executable File
584 lines
19 KiB
Ruby
Executable File
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|
MAX_MESSAGE_LENGTH = 10_000
|
|
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
|
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
|
|
|
|
def perform(conversation, assistant)
|
|
Rails.logger.info "ResponseBuilderJob: Starting for Conv #{conversation.id}, Assistant #{assistant.id}"
|
|
@conversation = conversation
|
|
@inbox = conversation.inbox
|
|
@assistant = assistant
|
|
@start_time = Time.zone.now
|
|
@response_delivered = false
|
|
|
|
Current.executed_by = @assistant
|
|
Current.account = conversation.account
|
|
|
|
trigger_typing_status('on')
|
|
|
|
Rails.logger.info "[ResponseBuilderJob] Captain V2 Enabled? #{captain_v2_enabled?}"
|
|
File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.now}] ResponseBuilderJob: V2 Enabled? #{captain_v2_enabled?}" }
|
|
|
|
if captain_v2_enabled?
|
|
generate_response_with_v2
|
|
else
|
|
ActiveRecord::Base.transaction do
|
|
generate_and_process_response
|
|
end
|
|
end
|
|
rescue StandardError => e
|
|
trigger_typing_status('off')
|
|
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
|
|
|
|
handle_error(e)
|
|
end
|
|
|
|
private
|
|
|
|
delegate :account, :inbox, to: :@conversation
|
|
|
|
def generate_and_process_response
|
|
Rails.logger.info 'ResponseBuilderJob: Generating response...'
|
|
extract_contact_identity
|
|
faq_response = maybe_answer_from_faq
|
|
if faq_response.present?
|
|
@response = {
|
|
'response' => faq_response,
|
|
'reasoning' => 'faq_lookup_direct',
|
|
'sentiment' => 'neutral',
|
|
'agent_name' => @assistant.name
|
|
}
|
|
process_response
|
|
Rails.logger.info 'ResponseBuilderJob: FAQ response generated and processed.'
|
|
return
|
|
end
|
|
|
|
# Aggregation Logic
|
|
new_messages = fetch_new_incoming_messages
|
|
aggregated_text = new_messages.map(&:content).join("\n")
|
|
exclude_ids = new_messages.map(&:id)
|
|
|
|
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
|
additional_message: aggregated_text,
|
|
message_history: collect_previous_messages(exclude_ids: exclude_ids)
|
|
)
|
|
process_response
|
|
Rails.logger.info 'ResponseBuilderJob: Response generated and processed.'
|
|
end
|
|
|
|
def generate_response_with_v2
|
|
extract_contact_identity
|
|
faq_response = maybe_answer_from_faq
|
|
if faq_response.present?
|
|
@response = {
|
|
'response' => faq_response,
|
|
'reasoning' => 'faq_lookup_direct',
|
|
'sentiment' => 'neutral',
|
|
'agent_name' => @assistant.name
|
|
}
|
|
process_response
|
|
return
|
|
end
|
|
|
|
# Aggregation Logic (V2)
|
|
new_messages = fetch_new_incoming_messages
|
|
aggregated_text = new_messages.map(&:content).join("\n")
|
|
exclude_ids = new_messages.map(&:id)
|
|
|
|
history = collect_previous_messages(exclude_ids: exclude_ids)
|
|
history << { role: 'user', content: aggregated_text } if aggregated_text.present?
|
|
|
|
@response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
|
message_history: history
|
|
)
|
|
process_response
|
|
end
|
|
|
|
def process_response
|
|
handled = if @response['handoff_trigger'].present?
|
|
apply_handoff_behavior(@response['handoff_trigger'])
|
|
elsif handoff_requested?
|
|
apply_handoff_behavior('user_request')
|
|
elsif negative_sentiment?
|
|
apply_handoff_behavior('sentiment')
|
|
end
|
|
return if handled
|
|
|
|
humanized_delay(@response['response'])
|
|
trigger_typing_status('off')
|
|
create_messages
|
|
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
|
|
account.increment_response_usage
|
|
end
|
|
|
|
def negative_sentiment?
|
|
return false unless @assistant.config['handoff_on_sentiment']
|
|
|
|
# Force handoff if user is angry or very frustrated
|
|
%w[angry frustrated].include?(@response['sentiment']&.downcase)
|
|
end
|
|
|
|
def apply_handoff_behavior(trigger_key)
|
|
action = handoff_action_for(trigger_key)
|
|
case action
|
|
when 'handoff'
|
|
if handoff_allowed?
|
|
process_action('handoff', trigger_key: trigger_key)
|
|
return true
|
|
else
|
|
@response['response'] = fallback_handoff_blocked_message
|
|
@response['agent_name'] ||= @assistant.name
|
|
end
|
|
when 'reply'
|
|
@response['response'] = handoff_message_for(trigger_key)
|
|
@response['agent_name'] ||= @assistant.name
|
|
when 'ignore'
|
|
return unless @response['response'].to_s.strip == 'conversation_handoff'
|
|
|
|
@response['response'] = fallback_handoff_blocked_message
|
|
@response['agent_name'] ||= @assistant.name
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def handoff_action_for(trigger_key)
|
|
config = @assistant.config || {}
|
|
key = case trigger_key.to_s
|
|
when 'tool_failure' then 'handoff_on_tool_failure_action'
|
|
when 'llm_error' then 'handoff_on_llm_error_action'
|
|
when 'sentiment' then 'handoff_on_sentiment_action'
|
|
when 'user_request' then 'handoff_on_user_request_action'
|
|
end
|
|
|
|
action = key ? config[key].to_s : ''
|
|
action = action.presence || default_handoff_action(trigger_key)
|
|
%w[handoff reply ignore].include?(action) ? action : default_handoff_action(trigger_key)
|
|
end
|
|
|
|
def default_handoff_action(trigger_key)
|
|
return 'handoff' if %w[llm_error user_request sentiment].include?(trigger_key.to_s)
|
|
|
|
'ignore'
|
|
end
|
|
|
|
def handoff_message_for(trigger_key)
|
|
config = @assistant.config || {}
|
|
key = case trigger_key.to_s
|
|
when 'tool_failure' then 'handoff_on_tool_failure_message'
|
|
when 'llm_error' then 'handoff_on_llm_error_message'
|
|
when 'sentiment' then 'handoff_on_sentiment_message'
|
|
when 'user_request' then 'handoff_on_user_request_message'
|
|
end
|
|
|
|
message = key ? config[key].to_s.strip : ''
|
|
return message if message.present?
|
|
|
|
I18n.t('captain.handoff_default_message',
|
|
default: 'Desculpe, estou com dificuldades tecnicas no momento. Por favor, tente novamente em alguns instantes.')
|
|
end
|
|
|
|
def handoff_allowed?
|
|
value = @assistant.config['allow_handoff']
|
|
return true if value.nil?
|
|
|
|
value == true || value.to_s == 'true'
|
|
end
|
|
|
|
def trigger_typing_status(status)
|
|
Conversations::TypingStatusManager.new(
|
|
@conversation,
|
|
@assistant,
|
|
{ typing_status: status, is_private: false }
|
|
).toggle_typing_status
|
|
rescue StandardError => e
|
|
Rails.logger.warn "Failed to trigger typing status: #{e.message}"
|
|
end
|
|
|
|
def humanized_delay(response_text)
|
|
return if response_text.blank?
|
|
|
|
# Roughly 50ms per character simulation
|
|
typing_speed = 50
|
|
target_delay = (response_text.length * typing_speed) / 1000.0
|
|
|
|
# Cap at 7 seconds to balance humanization vs speed
|
|
target_delay = [target_delay, 7.0].min
|
|
|
|
elapsed_time = Time.zone.now - @start_time
|
|
remaining_delay = target_delay - elapsed_time
|
|
|
|
sleep(remaining_delay) if remaining_delay > 0
|
|
end
|
|
|
|
def fetch_new_incoming_messages
|
|
# Fetch all messages ordered by creation
|
|
all_messages = @conversation.messages.order(:created_at)
|
|
|
|
# Find the last message sent by the assistant (outgoing)
|
|
last_outgoing_index = all_messages.rindex { |m| m.outgoing? }
|
|
|
|
potential_messages = if last_outgoing_index
|
|
# Get all messages after the last outgoing one
|
|
all_messages[(last_outgoing_index + 1)..-1] || []
|
|
else
|
|
# If no outgoing messages, use all messages
|
|
all_messages
|
|
end
|
|
|
|
# Filter for valid incoming messages (not private, incoming type)
|
|
potential_messages.select { |m| m.incoming? && !m.private? }
|
|
end
|
|
|
|
def collect_previous_messages(exclude_ids: [])
|
|
@conversation
|
|
.messages
|
|
.where(message_type: [:incoming, :outgoing])
|
|
.where(private: false)
|
|
.where.not(id: exclude_ids)
|
|
.map do |message|
|
|
message_hash = {
|
|
content: prepare_multimodal_message_content(message),
|
|
role: determine_role(message)
|
|
}
|
|
|
|
# Include agent_name if present in additional_attributes
|
|
message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
|
|
|
|
message_hash
|
|
end
|
|
end
|
|
|
|
def extract_contact_identity
|
|
last_message = @conversation.messages
|
|
.where(message_type: :incoming, private: false)
|
|
.order(created_at: :desc)
|
|
.first
|
|
return if last_message.blank?
|
|
|
|
Captain::Llm::ContactIdentityService.new(
|
|
contact: @conversation.contact,
|
|
message_content: last_message.content
|
|
).extract_and_update
|
|
end
|
|
|
|
def maybe_answer_from_faq
|
|
return nil unless @assistant.config['feature_faq']
|
|
|
|
last_message = ::Message
|
|
.where(conversation_id: @conversation.id, message_type: :incoming, private: false)
|
|
.order(created_at: :desc)
|
|
.first
|
|
return nil if last_message.blank?
|
|
|
|
query = last_message.content.to_s.strip
|
|
return nil unless faq_question_like?(query)
|
|
|
|
Rails.logger.info("[CAPTAIN][FAQ] Forcing FAQ lookup for query: #{query.inspect}")
|
|
|
|
tool = Captain::Tools::FaqLookupTool.new(@assistant, conversation: @conversation, user: @conversation.contact)
|
|
result = tool.perform({ conversation: { id: @conversation.id }, last_user_message: query }, { query: query })
|
|
return nil if result.to_s.match?(/No relevant FAQs found/i)
|
|
|
|
extract_faq_answer(result)
|
|
rescue StandardError => e
|
|
Rails.logger.warn("[CAPTAIN][FAQ] Prelookup failed: #{e.message}")
|
|
nil
|
|
end
|
|
|
|
def extract_faq_answer(result)
|
|
match = result.to_s.match(/Answer:\s*(.+)$/m)
|
|
return result.to_s.strip if match.blank?
|
|
|
|
match[1].to_s.strip
|
|
end
|
|
|
|
def faq_question_like?(query)
|
|
normalized = query.to_s.downcase.strip
|
|
return false if normalized.blank?
|
|
|
|
greeting = normalized.gsub(/[^a-z0-9]/, '')
|
|
return false if %w[oi ola bomdia boatarde boanoite].include?(greeting)
|
|
|
|
normalized.match?(/\?|qual|quanto|valor|preco|preço|como|onde|horario|hora|cardapio|cardápio/)
|
|
end
|
|
|
|
def determine_role(message)
|
|
message.message_type == 'incoming' ? 'user' : 'assistant'
|
|
end
|
|
|
|
def prepare_multimodal_message_content(message)
|
|
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
|
end
|
|
|
|
def handoff_requested?
|
|
@response['response'] == 'conversation_handoff'
|
|
end
|
|
|
|
def fallback_handoff_blocked_message
|
|
I18n.t('conversations.captain.error',
|
|
default: 'Desculpe, estou com dificuldades técnicas no momento. Por favor, tente novamente em alguns instantes.')
|
|
end
|
|
|
|
def process_action(action, trigger_key: nil)
|
|
case action
|
|
when 'handoff'
|
|
I18n.with_locale(@assistant.account.locale) do
|
|
create_handoff_message
|
|
# @conversation.bot_handoff!
|
|
# [FIX] Use manual handoff with 'pausar_ia' to avoid Automation Rule loop
|
|
@conversation.open!
|
|
@conversation.account.labels.find_or_create_by!(title: 'pausar_ia') do |label|
|
|
label.description = 'Pausa a IA e evita loops de regras externas'
|
|
label.color = '#f59e0b'
|
|
label.show_on_sidebar = true
|
|
end
|
|
@conversation.add_labels(['pausar_ia'])
|
|
@conversation.save!
|
|
apply_handoff_side_effects
|
|
handle_sentiment_handoff_alerts if trigger_key.to_s == 'sentiment'
|
|
deliver_handoff_webhook
|
|
log_handoff_event
|
|
send_out_of_office_message_if_applicable
|
|
end
|
|
end
|
|
end
|
|
|
|
def send_out_of_office_message_if_applicable
|
|
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
|
|
end
|
|
|
|
def handle_sentiment_handoff_alerts
|
|
trigger_excerpt = last_incoming_message_excerpt
|
|
summary = build_handoff_summary
|
|
|
|
create_private_note_for_handoff(trigger_excerpt, summary)
|
|
send_leader_whatsapp_alert(trigger_excerpt, summary)
|
|
end
|
|
|
|
def last_incoming_message_excerpt
|
|
message = @conversation.messages.where(message_type: :incoming, private: false).order(created_at: :desc).first
|
|
message&.content.to_s.strip[0, 400]
|
|
end
|
|
|
|
def build_handoff_summary
|
|
summary = @conversation.latest_crm_insight&.summary_text.to_s.strip
|
|
summary = build_conversation_summary if summary.blank?
|
|
summary.to_s.strip[0, 400]
|
|
end
|
|
|
|
def create_private_note_for_handoff(trigger_excerpt, summary)
|
|
return if trigger_excerpt.blank? && summary.blank?
|
|
|
|
note_parts = []
|
|
note_parts << 'Handoff automatico por sentimento negativo.'
|
|
note_parts << "Resumo: #{summary}" if summary.present?
|
|
note_parts << "Trecho: \"#{trigger_excerpt}\"" if trigger_excerpt.present?
|
|
content = note_parts.join("\n")
|
|
|
|
@conversation.messages.create!(
|
|
message_type: :outgoing,
|
|
account_id: account.id,
|
|
inbox_id: inbox.id,
|
|
sender: @assistant,
|
|
content: content,
|
|
private: true
|
|
)
|
|
end
|
|
|
|
def send_leader_whatsapp_alert(trigger_excerpt, summary)
|
|
unit = resolve_unit_for_conversation
|
|
return if unit.blank?
|
|
|
|
leader_phone = unit.leader_whatsapp.to_s.gsub(/[^\d]/, '')
|
|
return if leader_phone.blank?
|
|
return if unit.inbox.blank?
|
|
|
|
contact_inbox = ContactInboxWithContactBuilder.new(
|
|
inbox: unit.inbox,
|
|
contact_attributes: {
|
|
name: "Lider #{unit.name}",
|
|
phone_number: leader_phone
|
|
},
|
|
source_id: leader_phone
|
|
).perform
|
|
|
|
leader_conversation = contact_inbox.conversations.order(created_at: :desc).first
|
|
leader_conversation ||= Conversation.create!(
|
|
account_id: unit.inbox.account_id,
|
|
inbox_id: unit.inbox.id,
|
|
contact_id: contact_inbox.contact_id,
|
|
contact_inbox_id: contact_inbox.id,
|
|
status: :open
|
|
)
|
|
|
|
message_text = build_leader_alert_message(unit.name, summary, trigger_excerpt)
|
|
leader_conversation.messages.create!(
|
|
message_type: :outgoing,
|
|
account_id: unit.inbox.account_id,
|
|
inbox_id: unit.inbox.id,
|
|
sender: @assistant,
|
|
content: message_text
|
|
)
|
|
rescue StandardError => e
|
|
Rails.logger.warn "[CAPTAIN][handoff] Failed to alert leader: #{e.message}"
|
|
end
|
|
|
|
def resolve_unit_for_conversation
|
|
CaptainInbox.find_by(inbox_id: inbox.id, captain_assistant_id: @assistant.id)&.unit ||
|
|
Captain::Unit.find_by(inbox_id: inbox.id)
|
|
end
|
|
|
|
def build_leader_alert_message(unit_name, summary, trigger_excerpt)
|
|
link = conversation_link
|
|
parts = []
|
|
parts << "ALERTA: cliente irritado - Unidade #{unit_name}"
|
|
parts << 'O cliente precisa ser atendido para nao termos maiores problemas.'
|
|
parts << 'Valor: obsessao pelo cliente.'
|
|
parts << "Resumo: #{summary}" if summary.present?
|
|
parts << "Trecho: \"#{trigger_excerpt}\"" if trigger_excerpt.present?
|
|
parts << "Link da conversa: #{link}" if link.present?
|
|
parts.join("\n")
|
|
end
|
|
|
|
def conversation_link
|
|
base_url = ENV.fetch('FRONTEND_URL', '').to_s
|
|
base_url = base_url.gsub('0.0.0.0', '127.0.0.1')
|
|
return '' if base_url.blank?
|
|
|
|
"#{base_url}/app/accounts/#{account.id}/conversations/#{@conversation.id}"
|
|
end
|
|
|
|
def create_handoff_message
|
|
create_outgoing_message(
|
|
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
|
|
)
|
|
end
|
|
|
|
def create_messages
|
|
response_text = inject_preferred_name(@response['response'])
|
|
response_text = prevent_fake_handoff(response_text)
|
|
validate_message_content!(response_text)
|
|
create_outgoing_message(response_text, agent_name: @response['agent_name'])
|
|
end
|
|
|
|
def validate_message_content!(content)
|
|
raise ArgumentError, 'Message content cannot be blank' if content.blank?
|
|
end
|
|
|
|
def create_outgoing_message(message_content, agent_name: nil)
|
|
additional_attrs = {}
|
|
additional_attrs[:agent_name] = agent_name if agent_name.present?
|
|
|
|
@conversation.messages.create!(
|
|
message_type: :outgoing,
|
|
account_id: account.id,
|
|
inbox_id: inbox.id,
|
|
sender: @assistant,
|
|
content: message_content,
|
|
additional_attributes: additional_attrs
|
|
)
|
|
@response_delivered = true
|
|
end
|
|
|
|
def inject_preferred_name(content)
|
|
return content if content.blank?
|
|
|
|
attributes = @conversation.contact&.additional_attributes || {}
|
|
preferred_name = attributes['preferred_name'].to_s.strip
|
|
confidence = attributes['name_confidence'].to_f
|
|
return content if preferred_name.blank? || confidence < 0.8
|
|
return content if content.downcase.include?(preferred_name.downcase)
|
|
|
|
"#{preferred_name}, #{content}"
|
|
end
|
|
|
|
def prevent_fake_handoff(content)
|
|
return content if content.blank? || handoff_requested?
|
|
|
|
handoff_message = @assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
|
|
return content unless content.strip == handoff_message.to_s.strip
|
|
|
|
fallback_question
|
|
end
|
|
|
|
def fallback_question
|
|
'Pode me dizer sua duvida de forma mais especifica?'
|
|
end
|
|
|
|
def apply_handoff_side_effects
|
|
@conversation.add_labels(['handoff_requested'])
|
|
|
|
return if @conversation.assignee.present?
|
|
|
|
allowed_agent_ids = @conversation.inbox.member_ids_with_assignment_capacity
|
|
AutoAssignment::AgentAssignmentService.new(conversation: @conversation, allowed_agent_ids: allowed_agent_ids).perform
|
|
end
|
|
|
|
def deliver_handoff_webhook
|
|
handoff_context = {
|
|
trigger: determine_handoff_trigger,
|
|
sentiment: @response['sentiment'],
|
|
reason: @response['reasoning'],
|
|
last_message: @conversation.messages.incoming.last&.content,
|
|
conversation_summary: build_conversation_summary
|
|
}
|
|
|
|
Captain::HandoffWebhookService.new(
|
|
conversation: @conversation,
|
|
assistant: @assistant,
|
|
handoff_context: handoff_context
|
|
).deliver
|
|
end
|
|
|
|
def determine_handoff_trigger
|
|
return 'sentiment' if negative_sentiment?
|
|
return 'error' if @response&.dig('error').present?
|
|
|
|
'ai_decision'
|
|
end
|
|
|
|
def build_conversation_summary
|
|
# Use existing CRM insight summary if available
|
|
@conversation.latest_crm_insight&.summary_text ||
|
|
# Otherwise, concatenate last 5 messages
|
|
@conversation.messages.where(private: false).last(5).map(&:content).join(' | ')
|
|
end
|
|
|
|
def log_handoff_event
|
|
Rails.logger.info(
|
|
"[CAPTAIN][handoff] request_id=#{extract_request_id} conversation_id=#{@conversation.id} assistant_id=#{@assistant.id} " \
|
|
"assignee_id=#{@conversation.assignee_id} team_id=#{@conversation.team_id}"
|
|
)
|
|
end
|
|
|
|
def extract_request_id
|
|
return RequestStore.store[:request_id] if defined?(RequestStore) && RequestStore.store[:request_id].present?
|
|
|
|
Thread.current[:request_id] || 'unknown'
|
|
end
|
|
|
|
def handle_error(error)
|
|
log_error(error)
|
|
return true if @response_delivered
|
|
|
|
@response ||= {
|
|
'response' => fallback_handoff_blocked_message,
|
|
'sentiment' => 'neutral',
|
|
'agent_name' => @assistant.name
|
|
}
|
|
|
|
handled = apply_handoff_behavior('llm_error')
|
|
create_messages unless handled
|
|
true
|
|
end
|
|
|
|
def log_error(error)
|
|
ChatwootExceptionTracker.new(error, account: account).capture_exception
|
|
end
|
|
|
|
def captain_v2_enabled?
|
|
account.feature_enabled?('captain_integration_v2')
|
|
end
|
|
end
|