fix(captain): integracao do delay do inbox com resposta humanizada e remocao de race conditions

This commit is contained in:
Rodrigo Borba 2026-02-26 12:47:41 -03:00
parent ccc1bdf35f
commit f12e1d6545
10 changed files with 241 additions and 29 deletions

View File

@ -17,6 +17,12 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
# Cancel if there are newer messages after the provided message
return if debounce_requested?(message)
# Simulate reading the message before starting to type
delay_before_typing(message)
# Re-check debounce to avoid race condition where another message arrived during reading sleep
return if debounce_requested?(message)
# Trigger typing on before processing
simulate_typing('typing_on')
@start_time = Time.zone.now
@ -53,6 +59,28 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
last_incoming.present? && last_incoming.id != message.id
end
def delay_before_typing(message)
return if message.blank? || message.content.blank?
chars_count = message.content.to_s.length
configured_delay = @inbox.typing_delay.to_i
# Simulate reading time proportional to configured delay. Max reading is ~40% of configured delay.
max_reading = configured_delay.positive? ? (configured_delay * 0.4) : 4.0
min_reading = [1.0, max_reading].min
reading_time = (chars_count / 25.0).clamp(min_reading, max_reading)
# Add jitter (randomness between 85% and 120%)
jitter = 0.85 + (rand * 0.35)
delay = reading_time * jitter
Rails.logger.info(
"[CAPTAIN][ResponseBuilderJob] Simulating reading delay of #{delay.round(2)}s " \
"for message of #{chars_count} chars"
)
sleep(delay)
end
def simulate_typing(status)
# Trigger ActionCable for the Chatwoot dashboard
cable_status = status == 'typing_on' ? 'on' : 'off'
@ -101,23 +129,33 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
return if response_text.blank?
text = response_text.to_s
words_count = text.scan(/\b[\p{L}\p{N}]+\b/u).size
chars_count = text.length
punctuation_pauses = text.count(',.!?;:')
configured_delay = @inbox.typing_delay.to_i
# Modela tempo de digitação de forma mais humana:
# - base por palavra (mais estável para textos longos),
# - ajuste por tamanho,
# - pequenas pausas por pontuação,
# - jitter para não repetir sempre o mesmo tempo.
base_time = (words_count * 0.32) + (chars_count * 0.01) + (punctuation_pauses * 0.18)
# Modela tempo de digitação de forma mais humana no WhatsApp:
# - Velocidade média de digitação: ~15 a 20 caracteres por segundo
# - Pequenas pausas por pontuação
base_time = (chars_count / 15.0) + (punctuation_pauses * 0.25)
# Se configurado, max_typing é o valor escolhido, senão, 12s
max_typing = configured_delay.positive? ? configured_delay.to_f : 12.0
min_delay = [2.0, max_typing].min
# Adicionando uma ligeira variação humana
jitter = 0.85 + (rand * 0.35)
target_delay = (base_time * jitter).clamp(1.8, 18.0)
target_delay = (base_time * jitter).clamp(min_delay, max_typing)
elapsed_time = Time.zone.now - @start_time
remaining_delay = target_delay - elapsed_time
sleep(remaining_delay) if remaining_delay.positive?
return unless remaining_delay.positive?
Rails.logger.info(
"[CAPTAIN][ResponseBuilderJob] Simulating typing delay of #{remaining_delay.round(2)}s " \
"(target: #{target_delay.round(2)}s, total elapsed: #{elapsed_time.round(2)}s, configured_max: #{max_typing}s)"
)
sleep(remaining_delay)
end
def collect_previous_messages

View File

@ -31,16 +31,11 @@ module Enterprise::MessageTemplates::HookExecutionService
def schedule_captain_response
job_args = [conversation, conversation.inbox.captain_assistant, message]
delay = conversation.inbox.typing_delay.to_i
if message.attachments.blank?
if delay.positive?
Captain::Conversation::ResponseBuilderJob.set(wait: delay.seconds).perform_later(*job_args)
else
Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
end
Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
else
wait_time = calculate_attachment_wait_time + delay.seconds
wait_time = calculate_attachment_wait_time
Captain::Conversation::ResponseBuilderJob.set(wait: wait_time).perform_later(*job_args)
end
end

View File

@ -1,5 +1,6 @@
module Captain::Conversation::ReactionPolicy
REACTION_SAMPLE_RATE = 5
REACTION_SAMPLE_RATE = 20
REACTION_SAMPLE_THRESHOLD = 7
GREETING_PATTERNS = [
/\boi\b/,
/\bola\b/,
@ -11,6 +12,17 @@ module Captain::Conversation::ReactionPolicy
/\bhi\b/,
/\bhey\b/
].freeze
GRATITUDE_PATTERNS = [
/\bobrigad[oa]\b/,
/\bobg\b/,
/\bvaleu\b/,
/\bagradeco\b/,
/\bagradecid[oa]\b/,
/\bthanks\b/,
/\bthank you\b/,
/\bthx\b/,
/\bty\b/
].freeze
FAREWELL_PATTERNS = [
/\btchau\b/,
/\bate mais\b/,
@ -27,19 +39,20 @@ module Captain::Conversation::ReactionPolicy
def should_send_reaction_for?(target_message)
return false if @response['reaction_emoji'].blank?
return false if target_message.blank?
return true if greeting_or_farewell?(target_message.content)
return true if greeting_farewell_or_gratitude?(target_message.content)
sampled_reaction_slot?(target_message)
end
def sampled_reaction_slot?(target_message)
(target_message.id % REACTION_SAMPLE_RATE).zero?
(target_message.id % REACTION_SAMPLE_RATE) < REACTION_SAMPLE_THRESHOLD
end
def greeting_or_farewell?(text)
def greeting_farewell_or_gratitude?(text)
normalized = normalize_reaction_text(text)
GREETING_PATTERNS.any? { |pattern| normalized.match?(pattern) } ||
FAREWELL_PATTERNS.any? { |pattern| normalized.match?(pattern) }
FAREWELL_PATTERNS.any? { |pattern| normalized.match?(pattern) } ||
GRATITUDE_PATTERNS.any? { |pattern| normalized.match?(pattern) }
end
def normalize_reaction_text(text)

View File

@ -49,8 +49,8 @@ CRITICAL: Do NOT react to every single message! This makes the interaction feel
- AVOID reacting to serious complaints or basic continuous questions if the tone doesn't fit.
- If you just sent an emoji in the previous turn, try to hold off on sending another right away. When in doubt, leave `reaction_emoji` empty.
- Frequency policy:
- Prefer reaction on greeting/farewell messages.
- For regular conversation, react only occasionally (roughly 20% of turns or less).
- Always react on greeting, farewell, and thank-you/appreciation messages when tone is positive.
- For regular conversation, react only occasionally (roughly 35% of turns).
- If uncertain, keep `reaction_emoji` empty.

View File

@ -0,0 +1,24 @@
# Melhorias no Delay Humanizado (Wuzapi/Captain)
**Objetivo:** Integrar o delay humanizado (leitura e digitação simuladas) com a configuração de atraso base controlada pelo front-end (`typing_delay` da Inbox), evitando atrasos excessivos e resolvendo uma *Race Condition* de mensagens concorrentes. Ocultar scripts temporários de teste da raiz do repositório.
**Contexto:**
- O delay fixo adicionado anteriormente bloqueava os testes e fugia do controle do administrador. Se o admin configurasse o delay no painel para "5", o sistema adicionava os passos humanizados de leitura e digitação *em cima* desses 5 segundos originais na fila do Sidekiq.
- Risco técnico (Race Condition): um `sleep(4)` para simulação de leitura permitia a chegada de uma nova mensagem, que ficaria ignorada ou atropelada.
- Sujeira no repositório: arquivos `.rb` na raiz do projeto poluindo o tracking do git.
**Passos:**
1. **Limpeza do Tracking:** Movidos os scripts descartáveis `test_wuzapi_parser.rb`, `test_wuzapi_service.rb`, `test_wuzapi_service2.rb` e `test_wuzapi_service3.rb` para `scripts/dev/`.
2. **Transferência do Delay Total:** Removemos o atraso cego na fila pelo Sidekiq (antes em `HookExecutionService` via `wait: delay.seconds`). O job engatilha agora imediatamente para a mensagem começar o fluxo visivelmente.
3. **Escalonamento do Delay Dinâmico:** Atualizado `ResponseBuilderJob` para usar `@inbox.typing_delay.to_i`. Agora, se `typing_delay` for maior que zero, ele é utilizado como *peso máximo* para a leitura e digitação, combinando a variação humana (jitter) mas respeitando a escala do front-end.
4. **Fechamento de Race Condition:** Adicionado novamente um check `return if debounce_requested?(message)` no final da pausa de "leitura", certificando que nenhuma mensagem invadiu nos segundos intermediários do `sleep`.
**Principais Códigos Alterados:**
- `enterprise/app/services/enterprise/message_templates/hook_execution_service.rb`
- `enterprise/app/jobs/captain/conversation/response_builder_job.rb`
**Como Validar:**
No frontend, altere o valor de "Delay before responding" para um número baixo (ex: 2 segundos) e envie mensagens variadas no whatsapp. A resposta de leitura e digitação não vai exceder este tempo máximo. Depois aumente o número livremente. Teste enviar duas mensagens curtas em menos de 2 segundos para validar se a resposta não está atropelando o raciocínio.
**Como Reverter:**
As alterações em `HookExecutionService` podem ser revertidas readicionando `wait: delay.seconds` no `.set()` de enqueue do job. Em `ResponseBuilderJob`, as chamadas das lógicas de escala de delays com `.clamp(1.0, max...)` podem retornar ao padrão duro das branchs anteriores.

View File

@ -0,0 +1,21 @@
require_relative 'config/environment'
payload = {
"event" => {
"Info" => {
"Type" => "text",
"Sender" => "556182098580@s.whatsapp.net",
"PushName" => "😅‼️",
"Chat" => "556182098580@s.whatsapp.net"
},
"Message" => {
"conversation" => "teste4000"
}
},
"type" => "Message",
"phone_number" => "556133712229"
}
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(payload)
puts "Message Type: #{parser.message_type}"
puts "Text: #{parser.text_content}"
puts "From Me: #{parser.from_me?}"
puts "Sender: #{parser.sender_phone_number}"

View File

@ -0,0 +1,32 @@
require_relative 'config/environment'
payload = {
"event" => {
"Info" => {
"Type" => "text",
"Sender" => "556182098580@s.whatsapp.net",
"PushName" => "😅‼️",
"Chat" => "556182098580@s.whatsapp.net"
},
"Message" => {
"conversation" => "teste4000"
}
},
"type" => "Message",
"phone_number" => "556133712229",
"instanceName" => "Chatwoot_61 33712229"
}
channel = Channel::Whatsapp.find_by(phone_number: "556133712229")
if channel
puts "Channel found: #{channel.id} / #{channel.phone_number}"
service = Whatsapp::IncomingMessageWuzapiService.new(inbox: channel.inbox, params: payload)
begin
service.perform
puts "Service performed successfully."
rescue StandardError => e
puts "ERROR: #{e.message}"
puts e.backtrace.first(10)
end
else
puts "Channel not found for 556133712229"
end

View File

@ -0,0 +1,39 @@
require_relative 'config/environment'
payload = {
"event" => {
"Info" => {
"Type" => "text",
"Sender" => "556182098580@s.whatsapp.net",
"PushName" => "😅‼️",
"Chat" => "556182098580@s.whatsapp.net"
},
"Message" => {
"conversation" => "teste4000"
}
},
"type" => "Message",
"phone_number" => "556133712229",
"instanceName" => "Chatwoot_61 33712229"
}
phone = "556133712229"
channel = Channel::Whatsapp.find_by(phone_number: phone) ||
Channel::Whatsapp.find_by(phone_number: "+#{phone}") ||
Channel::Whatsapp.where("regexp_replace(phone_number, '[^0-9]', '', 'g') = ?", phone).first
if channel
puts "Channel found: #{channel.id} / #{channel.phone_number}"
service = Whatsapp::IncomingMessageWuzapiService.new(inbox: channel.inbox, params: payload)
begin
service.perform
puts "Service performed successfully."
rescue StandardError => e
puts "ERROR: #{e.message}"
puts e.backtrace.first(10)
end
else
puts "Channel still not found for #{phone}. Let's list all whatsapp channels:"
Channel::Whatsapp.all.each do |c|
puts "- ID: #{c.id}, Phone: #{c.phone_number}, Provider: #{c.provider}"
end
end

View File

@ -0,0 +1,38 @@
require_relative 'config/environment'
payload = {
"event" => {
"Info" => {
"Type" => "text",
"Sender" => "556182098580@s.whatsapp.net",
"PushName" => "😅‼️",
"Chat" => "556182098580@s.whatsapp.net"
},
"Message" => {
"conversation" => "teste4000"
}
},
"type" => "Message",
"phone_number" => "556191544165",
"instanceName" => "Chatwoot_556191544165"
}
phone = "556191544165"
channel = Channel::Whatsapp.find_by(phone_number: phone) ||
Channel::Whatsapp.find_by(phone_number: "+#{phone}") ||
Channel::Whatsapp.where("regexp_replace(phone_number, '[^0-9]', '', 'g') = ?", phone).first
if channel
puts "Channel found: #{channel.id} / #{channel.phone_number}"
service = Whatsapp::IncomingMessageWuzapiService.new(inbox: channel.inbox, params: payload)
begin
service.perform
puts "Service performed successfully."
puts "Last conversation: #{channel.inbox.conversations.last&.id}"
puts "Last message: #{channel.inbox.messages.last&.content}"
rescue StandardError => e
puts "ERROR: #{e.message}"
puts e.backtrace.first(10)
end
else
puts "Channel still not found for #{phone}."
end

View File

@ -239,7 +239,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
job.instance_variable_set(:@response, { 'reaction_emoji' => '👍' })
end
def create_incoming_until_slot(modulo_target:)
def create_incoming_until_slot(sample_target:)
loop do
message = create(
:message,
@ -248,7 +248,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
message_type: :incoming,
content: 'quero saber disponibilidade'
)
return message if (message.id % described_class::REACTION_SAMPLE_RATE).zero? == modulo_target
return message if job.send(:sampled_reaction_slot?, message) == sample_target
end
end
@ -264,14 +264,26 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
expect(job.send(:should_send_reaction_for?, message)).to be(true)
end
it 'blocks reaction for regular messages outside the 20% sample' do
message = create_incoming_until_slot(modulo_target: false)
it 'always allows reaction for gratitude messages' do
message = create(
:message,
conversation: conversation,
inbox: inbox,
message_type: :incoming,
content: 'Obrigado pelo atendimento'
)
expect(job.send(:should_send_reaction_for?, message)).to be(true)
end
it 'blocks reaction for regular messages outside the 35% sample' do
message = create_incoming_until_slot(sample_target: false)
expect(job.send(:should_send_reaction_for?, message)).to be(false)
end
it 'allows reaction for regular messages inside the 20% sample' do
message = create_incoming_until_slot(modulo_target: true)
it 'allows reaction for regular messages inside the 35% sample' do
message = create_incoming_until_slot(sample_target: true)
expect(job.send(:should_send_reaction_for?, message)).to be(true)
end