From f12e1d6545df21d56b428f9a6c042065c13e5bb8 Mon Sep 17 00:00:00 2001 From: Rodrigo Borba Date: Thu, 26 Feb 2026 12:47:41 -0300 Subject: [PATCH] fix(captain): integracao do delay do inbox com resposta humanizada e remocao de race conditions --- .../conversation/response_builder_job.rb | 56 ++++++++++++++++--- .../hook_execution_service.rb | 9 +-- .../captain/conversation/reaction_policy.rb | 23 ++++++-- .../lib/captain/prompts/assistant.liquid | 4 +- progresso/humanized_typing_delay.md | 24 ++++++++ scripts/dev/test_wuzapi_parser.rb | 21 +++++++ scripts/dev/test_wuzapi_service.rb | 32 +++++++++++ scripts/dev/test_wuzapi_service2.rb | 39 +++++++++++++ scripts/dev/test_wuzapi_service3.rb | 38 +++++++++++++ .../conversation/response_builder_job_spec.rb | 24 ++++++-- 10 files changed, 241 insertions(+), 29 deletions(-) create mode 100644 progresso/humanized_typing_delay.md create mode 100644 scripts/dev/test_wuzapi_parser.rb create mode 100644 scripts/dev/test_wuzapi_service.rb create mode 100644 scripts/dev/test_wuzapi_service2.rb create mode 100644 scripts/dev/test_wuzapi_service3.rb diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index e1f7096f7..f81b584db 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -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 diff --git a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb index 06b7161bc..3c9fa9105 100644 --- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb +++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb @@ -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 diff --git a/enterprise/lib/captain/conversation/reaction_policy.rb b/enterprise/lib/captain/conversation/reaction_policy.rb index 08a1b04dc..8eff7de76 100644 --- a/enterprise/lib/captain/conversation/reaction_policy.rb +++ b/enterprise/lib/captain/conversation/reaction_policy.rb @@ -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) diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid index cb06bc8f1..e19131d77 100644 --- a/enterprise/lib/captain/prompts/assistant.liquid +++ b/enterprise/lib/captain/prompts/assistant.liquid @@ -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. diff --git a/progresso/humanized_typing_delay.md b/progresso/humanized_typing_delay.md new file mode 100644 index 000000000..ee3dbe440 --- /dev/null +++ b/progresso/humanized_typing_delay.md @@ -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. diff --git a/scripts/dev/test_wuzapi_parser.rb b/scripts/dev/test_wuzapi_parser.rb new file mode 100644 index 000000000..ab0d870d2 --- /dev/null +++ b/scripts/dev/test_wuzapi_parser.rb @@ -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}" diff --git a/scripts/dev/test_wuzapi_service.rb b/scripts/dev/test_wuzapi_service.rb new file mode 100644 index 000000000..105fb15b4 --- /dev/null +++ b/scripts/dev/test_wuzapi_service.rb @@ -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 diff --git a/scripts/dev/test_wuzapi_service2.rb b/scripts/dev/test_wuzapi_service2.rb new file mode 100644 index 000000000..021e502b6 --- /dev/null +++ b/scripts/dev/test_wuzapi_service2.rb @@ -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 diff --git a/scripts/dev/test_wuzapi_service3.rb b/scripts/dev/test_wuzapi_service3.rb new file mode 100644 index 000000000..6e13b3779 --- /dev/null +++ b/scripts/dev/test_wuzapi_service3.rb @@ -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 diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index 699bf8149..81d977d44 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -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