diff --git a/enterprise/app/jobs/captain/hermes/outgoing_job.rb b/enterprise/app/jobs/captain/hermes/outgoing_job.rb index 983e9d3d4..672fabac2 100644 --- a/enterprise/app/jobs/captain/hermes/outgoing_job.rb +++ b/enterprise/app/jobs/captain/hermes/outgoing_job.rb @@ -12,25 +12,43 @@ class Captain::Hermes::OutgoingJob < ApplicationJob def perform(conversation_id, message_id) conversation = Conversation.find_by(id: conversation_id) message = Message.find_by(id: message_id) - - if conversation.blank? || message.blank? - 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 + return if conversation.blank? || message.blank? + return unless Captain::Hermes.enabled_for?(conversation.inbox) # Auto-react ANTES do dispatch — gesto chega <1s sem esperar Codex. # Não bloqueia fluxo: se falhar, dispatch normal continua. 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 diff --git a/enterprise/app/services/captain/hermes/client.rb b/enterprise/app/services/captain/hermes/client.rb index 7a5c7f8a2..68f41864f 100644 --- a/enterprise/app/services/captain/hermes/client.rb +++ b/enterprise/app/services/captain/hermes/client.rb @@ -16,8 +16,8 @@ class Captain::Hermes::Client @inbox = inbox end - def dispatch(message:, conversation:) - payload = build_payload(message: message, conversation: conversation) + def dispatch(message:, conversation:, content_override: nil) + payload = build_payload(message: message, conversation: conversation, content_override: content_override) body = payload.to_json headers = signed_headers(body) @@ -66,14 +66,14 @@ class Captain::Hermes::Client Captain::Hermes.webhook_url_for(inbox) 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_attrs = contact&.custom_attributes.to_h.with_indifferent_access cpf_digits = contact_attrs[:cpf].to_s.gsub(/\D/, '') 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), contact_name: contact&.name, contact_first_name: contact&.name.to_s.split.first, 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 0f1037990..33f31b7c0 100644 --- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb +++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb @@ -36,9 +36,38 @@ module Enterprise::MessageTemplates::HookExecutionService end def schedule_hermes_response - # Inbox marcada via CAPTAIN_HERMES_INBOX_IDS roteia pro gateway do Hermes - # Agent em vez do orquestrador interno do Captain. - Captain::Hermes::OutgoingJob.perform_later(conversation.id, message.id) + # Inbox roteada pro Hermes Agent (engine='hermes' no assistant ou env var legacy). + # Usa inbox.typing_delay como buffer/debounce: se outra msg chegar antes do delay + # 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 def schedule_internal_response