feat(captain/hermes): debounce — inbox.typing_delay como buffer + agrupar msgs
Antes: inbox.typing_delay funcionava só pro Captain interno (schedule_internal_response). Hermes ignorava o campo e disparava OutgoingJob na hora — campo da UI era cosmético pra inboxes Hermes. Agora: - schedule_hermes_response cancela jobs OutgoingJob pendentes pra mesma conversa e enfileira com wait=inbox.typing_delay (debounce window). - OutgoingJob agrupa todas msgs incoming entre a última resposta real do agente (ignora reactions) e a msg âncora; dispatch envia o texto concatenado pro Hermes via novo content_override no Client#dispatch. Resultado: cliente que digita "Oi" + "quero pernoite Master" em segundos vê o agente esperar até o buffer vencer e responder UMA vez cobrindo ambas as falas, em vez de 2 respostas atropelando o pensamento. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e662913b21
commit
a2bb613e68
@ -12,25 +12,43 @@ class Captain::Hermes::OutgoingJob < ApplicationJob
|
|||||||
def perform(conversation_id, message_id)
|
def perform(conversation_id, message_id)
|
||||||
conversation = Conversation.find_by(id: conversation_id)
|
conversation = Conversation.find_by(id: conversation_id)
|
||||||
message = Message.find_by(id: message_id)
|
message = Message.find_by(id: message_id)
|
||||||
|
return if conversation.blank? || message.blank?
|
||||||
if conversation.blank? || message.blank?
|
return unless Captain::Hermes.enabled_for?(conversation.inbox)
|
||||||
Rails.logger.warn(
|
|
||||||
"[Captain::Hermes::OutgoingJob] conversation/message not found: c=#{conversation_id} m=#{message_id}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
unless Captain::Hermes.enabled_for?(conversation.inbox)
|
|
||||||
Rails.logger.info(
|
|
||||||
"[Captain::Hermes::OutgoingJob] inbox #{conversation.inbox_id} not in CAPTAIN_HERMES_INBOX_IDS — skipping"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Auto-react ANTES do dispatch — gesto chega <1s sem esperar Codex.
|
# Auto-react ANTES do dispatch — gesto chega <1s sem esperar Codex.
|
||||||
# Não bloqueia fluxo: se falhar, dispatch normal continua.
|
# Não bloqueia fluxo: se falhar, dispatch normal continua.
|
||||||
Captain::Hermes::AutoReactService.maybe_react!(message)
|
Captain::Hermes::AutoReactService.maybe_react!(message)
|
||||||
|
|
||||||
Captain::Hermes::Client.new(conversation.inbox).dispatch(message: message, conversation: conversation)
|
# Debounce: agrupa msgs incoming desde a última resposta real do
|
||||||
|
# agente. Quando inbox.typing_delay>0, schedule_hermes_response
|
||||||
|
# cancela jobs pendentes e enfileira só o último — aqui pegamos o
|
||||||
|
# texto agrupado pra Hermes ver o pensamento completo do cliente.
|
||||||
|
combined = combined_incoming_content(conversation, message)
|
||||||
|
|
||||||
|
Captain::Hermes::Client.new(conversation.inbox).dispatch(
|
||||||
|
message: message, conversation: conversation, content_override: combined
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Concatena texto de todas as msgs incoming entre a última resposta real
|
||||||
|
# (não-reaction) do agente e a msg âncora. Retorna nil se só tem 1 msg
|
||||||
|
# (pra dispatch usar message.content normal).
|
||||||
|
def combined_incoming_content(conversation, anchor_message)
|
||||||
|
last_real_outgoing = conversation.messages
|
||||||
|
.where(message_type: :outgoing)
|
||||||
|
.where("(content_attributes ->> 'is_reaction') IS NULL OR (content_attributes ->> 'is_reaction') != 'true'")
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.first
|
||||||
|
|
||||||
|
scope = conversation.messages.where(message_type: :incoming).where('created_at <= ?', anchor_message.created_at)
|
||||||
|
scope = scope.where('created_at > ?', last_real_outgoing.created_at) if last_real_outgoing
|
||||||
|
|
||||||
|
texts = scope.order(:created_at).pluck(:content).map(&:to_s).reject(&:blank?).uniq
|
||||||
|
return nil if texts.size <= 1
|
||||||
|
|
||||||
|
Rails.logger.info("[Captain::Hermes::Debounce] agrupando #{texts.size} msgs do cliente em conv #{conversation.id}")
|
||||||
|
texts.join("\n")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -16,8 +16,8 @@ class Captain::Hermes::Client
|
|||||||
@inbox = inbox
|
@inbox = inbox
|
||||||
end
|
end
|
||||||
|
|
||||||
def dispatch(message:, conversation:)
|
def dispatch(message:, conversation:, content_override: nil)
|
||||||
payload = build_payload(message: message, conversation: conversation)
|
payload = build_payload(message: message, conversation: conversation, content_override: content_override)
|
||||||
body = payload.to_json
|
body = payload.to_json
|
||||||
headers = signed_headers(body)
|
headers = signed_headers(body)
|
||||||
|
|
||||||
@ -66,14 +66,14 @@ class Captain::Hermes::Client
|
|||||||
Captain::Hermes.webhook_url_for(inbox)
|
Captain::Hermes.webhook_url_for(inbox)
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_payload(message:, conversation:) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
def build_payload(message:, conversation:, content_override: nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
||||||
contact = conversation.contact
|
contact = conversation.contact
|
||||||
contact_attrs = contact&.custom_attributes.to_h.with_indifferent_access
|
contact_attrs = contact&.custom_attributes.to_h.with_indifferent_access
|
||||||
cpf_digits = contact_attrs[:cpf].to_s.gsub(/\D/, '')
|
cpf_digits = contact_attrs[:cpf].to_s.gsub(/\D/, '')
|
||||||
history = contact_history_snapshot(contact, conversation)
|
history = contact_history_snapshot(contact, conversation)
|
||||||
|
|
||||||
{
|
{
|
||||||
message: text_for_hermes(message),
|
message: content_override.presence || text_for_hermes(message),
|
||||||
image_urls: image_urls_for_hermes(message),
|
image_urls: image_urls_for_hermes(message),
|
||||||
contact_name: contact&.name,
|
contact_name: contact&.name,
|
||||||
contact_first_name: contact&.name.to_s.split.first,
|
contact_first_name: contact&.name.to_s.split.first,
|
||||||
|
|||||||
@ -36,9 +36,38 @@ module Enterprise::MessageTemplates::HookExecutionService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def schedule_hermes_response
|
def schedule_hermes_response
|
||||||
# Inbox marcada via CAPTAIN_HERMES_INBOX_IDS roteia pro gateway do Hermes
|
# Inbox roteada pro Hermes Agent (engine='hermes' no assistant ou env var legacy).
|
||||||
# Agent em vez do orquestrador interno do Captain.
|
# Usa inbox.typing_delay como buffer/debounce: se outra msg chegar antes do delay
|
||||||
Captain::Hermes::OutgoingJob.perform_later(conversation.id, message.id)
|
# vencer, cancela a anterior e reenfileira (a OutgoingJob agrupa msgs incoming
|
||||||
|
# desde a última resposta real do Hermes ao dispatch).
|
||||||
|
delay = conversation.inbox.typing_delay.to_i
|
||||||
|
cancel_pending_hermes_jobs!(conversation.id) if delay.positive?
|
||||||
|
|
||||||
|
if delay.positive?
|
||||||
|
Captain::Hermes::OutgoingJob.set(wait: delay.seconds).perform_later(conversation.id, message.id)
|
||||||
|
else
|
||||||
|
Captain::Hermes::OutgoingJob.perform_later(conversation.id, message.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancel_pending_hermes_jobs!(conv_id)
|
||||||
|
require 'sidekiq/api'
|
||||||
|
cancelled = 0
|
||||||
|
Sidekiq::ScheduledSet.new.each do |job|
|
||||||
|
args = begin
|
||||||
|
job.args.first
|
||||||
|
rescue StandardError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
next unless args.is_a?(Hash) && args['job_class'] == 'Captain::Hermes::OutgoingJob'
|
||||||
|
next unless args['arguments']&.first == conv_id
|
||||||
|
|
||||||
|
job.delete
|
||||||
|
cancelled += 1
|
||||||
|
end
|
||||||
|
Rails.logger.info("[Captain::Hermes::Debounce] cancelled #{cancelled} pending OutgoingJob for conv #{conv_id}") if cancelled.positive?
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn("[Captain::Hermes::Debounce] failed to cancel pending: #{e.class} - #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def schedule_internal_response
|
def schedule_internal_response
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user