fix(captain): integracao do delay do inbox com resposta humanizada e remocao de race conditions
This commit is contained in:
parent
ccc1bdf35f
commit
f12e1d6545
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
|
||||
24
progresso/humanized_typing_delay.md
Normal file
24
progresso/humanized_typing_delay.md
Normal 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.
|
||||
21
scripts/dev/test_wuzapi_parser.rb
Normal file
21
scripts/dev/test_wuzapi_parser.rb
Normal 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}"
|
||||
32
scripts/dev/test_wuzapi_service.rb
Normal file
32
scripts/dev/test_wuzapi_service.rb
Normal 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
|
||||
39
scripts/dev/test_wuzapi_service2.rb
Normal file
39
scripts/dev/test_wuzapi_service2.rb
Normal 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
|
||||
38
scripts/dev/test_wuzapi_service3.rb
Normal file
38
scripts/dev/test_wuzapi_service3.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user