From 48fad2977b6840835e2a6c332295d798b11c4314 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 20:15:50 -0300 Subject: [PATCH] feat(captain/hermes): payload enriquecido + humanizadores + notif Pix proativa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captain::Hermes::Client (enterprise/app/services/captain/hermes/client.rb): - text_for_hermes: transcreve audio via Whisper antes de enviar pro Hermes (reusa Captain::OpenAiMessageBuilderService) - image_urls_for_hermes: URLs publicas de imagens da message; plugin captain-webhook do Hermes baixa em /tmp/ e popula event.media_urls pra vision multimodal (gpt-4o-mini auxiliary) - contact_history_snapshot: dados eager pro [ctx] (last_reservation_*, total_conversations, ultima_suite, etc) — memoria do contato direto no prompt sem precisar tool call - notify_event + build_event_payload: dispara webhook sintetico pro Hermes pra eventos do sistema (Pix pago etc) — Valentina manda mensagem espontanea sem cliente perguntar Captain::Payments::ConfirmationService: - Hook notify_hermes_proactively! enfileira NotifyPaymentConfirmedJob apos confirmacao de Pix, somente se inbox estiver no fluxo Hermes (Captain interno continua igual sem mudanca) Captain::Hermes::NotifyPaymentConfirmedJob (NOVO): - Monta system_message "[SISTEMA: pagamento_confirmado]\n..." e dispara webhook pro Hermes Valentina - Valentina (via SOUL.md) interpreta como evento do Captain e manda mensagem celebrativa pro cliente Captain::Hermes::DelayedReplyJob (NOVO) — humanizadores: - Liga indicador "digitando..." (composing) via wuzapi - Aguarda delay configuravel via Captain::Assistant.config['response_delay'] (modos: none, fixed, typing_simulation com chars_per_second + min/max) - Posta msg outgoing - Desliga typing - Fallback no HermesCallbackController posta direto se class nao carregada Co-Authored-By: Claude Opus 4.7 (1M context) --- .../captain/hermes_callback_controller.rb | 6 +- .../jobs/captain/hermes/delayed_reply_job.rb | 84 +++++++++++ .../hermes/notify_payment_confirmed_job.rb | 56 +++++++ .../app/services/captain/hermes/client.rb | 139 +++++++++++++++++- .../captain/payments/confirmation_service.rb | 11 ++ 5 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 enterprise/app/jobs/captain/hermes/delayed_reply_job.rb create mode 100644 enterprise/app/jobs/captain/hermes/notify_payment_confirmed_job.rb diff --git a/app/controllers/webhooks/captain/hermes_callback_controller.rb b/app/controllers/webhooks/captain/hermes_callback_controller.rb index cad7174c5..901b00ed3 100644 --- a/app/controllers/webhooks/captain/hermes_callback_controller.rb +++ b/app/controllers/webhooks/captain/hermes_callback_controller.rb @@ -28,7 +28,11 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController return log_no_conversation_and_ack if conversation.blank? log_reply(conversation, content) - create_outgoing_message(conversation, content) + if defined?(Captain::Hermes::DelayedReplyJob) + Captain::Hermes::DelayedReplyJob.perform_later(conversation.id, content) + else + create_outgoing_message(conversation, content) + end head :ok rescue StandardError => e Rails.logger.error "[Hermes::Callback] error: #{e.class}: #{e.message}" diff --git a/enterprise/app/jobs/captain/hermes/delayed_reply_job.rb b/enterprise/app/jobs/captain/hermes/delayed_reply_job.rb new file mode 100644 index 000000000..e460b858b --- /dev/null +++ b/enterprise/app/jobs/captain/hermes/delayed_reply_job.rb @@ -0,0 +1,84 @@ +# Posta a resposta do Hermes na conversa simulando comportamento humano: +# 1. Liga indicador de "digitando..." (composing) via wuzapi +# 2. Aguarda delay configurado pelo assistant (typing_simulation, fixed ou none) +# 3. Posta a mensagem outgoing +# +# Config vive em `Captain::Assistant.config['response_delay']`: +# { +# "mode": "typing_simulation" | "fixed" | "none", +# "chars_per_second": 25, # apenas typing_simulation +# "seconds": 3, # apenas fixed +# "min_seconds": 1.5, # cap inferior pra typing_simulation +# "max_seconds": 8.0 # cap superior pra typing_simulation +# } +# +# Default: none (zero delay, igual antes — defensivo). +class Captain::Hermes::DelayedReplyJob < ApplicationJob + queue_as :default + + DEFAULT_CONFIG = { + 'mode' => 'none', + 'chars_per_second' => 25, + 'min_seconds' => 1.5, + 'max_seconds' => 8.0 + }.freeze + + def perform(conversation_id, content) + conversation = Conversation.find_by(id: conversation_id) + if conversation.blank? + Rails.logger.warn("[Captain::Hermes::DelayedReplyJob] conv #{conversation_id} not found") + return + end + + delay = compute_delay(conversation, content) + + if delay.positive? + send_typing(conversation, 'typing_on') + sleep(delay) + end + + create_outgoing_message(conversation, content) + + # WhatsApp cliente costuma sumir typing automático ao receber msg, mas + # mandamos typing_off explícito por segurança. + send_typing(conversation, 'typing_off') if delay.positive? + end + + private + + def compute_delay(conversation, content) + cfg = DEFAULT_CONFIG.merge(conversation.inbox.captain_assistant&.config.to_h.fetch('response_delay', {})) + case cfg['mode'] + when 'fixed' then cfg['seconds'].to_f + when 'typing_simulation' + cps = cfg['chars_per_second'].to_f + cps = 25 if cps <= 0 + raw = content.to_s.length / cps + raw.clamp(cfg['min_seconds'].to_f, cfg['max_seconds'].to_f) + else 0.0 + end + end + + def send_typing(conversation, status) + return unless conversation.inbox.respond_to?(:channel) + return unless conversation.inbox.channel.respond_to?(:toggle_typing_status) + + conversation.inbox.channel.toggle_typing_status(status, conversation: conversation) + rescue StandardError => e + Rails.logger.warn("[Captain::Hermes::DelayedReplyJob] toggle_typing_status #{status} failed: #{e.class} - #{e.message}") + end + + def create_outgoing_message(conversation, content) + assistant = conversation.inbox.captain_assistant + sender = assistant.presence || User.find_by(id: conversation.assignee_id) + + conversation.messages.create!( + message_type: :outgoing, + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + sender: sender, + content: content, + content_attributes: { external_source: 'hermes_callback' } + ) + end +end diff --git a/enterprise/app/jobs/captain/hermes/notify_payment_confirmed_job.rb b/enterprise/app/jobs/captain/hermes/notify_payment_confirmed_job.rb new file mode 100644 index 000000000..80dee2e1f --- /dev/null +++ b/enterprise/app/jobs/captain/hermes/notify_payment_confirmed_job.rb @@ -0,0 +1,56 @@ +# Notifica o Hermes Agent sobre confirmação de pagamento de uma reserva, pra +# que o agente mande mensagem espontânea pro cliente celebrando (sem cliente +# precisar perguntar "já caiu?"). +# +# Disparado por Captain::Payments::ConfirmationService (somente quando a +# inbox da reservation está em CAPTAIN_HERMES_INBOX_IDS — coexiste com o +# fluxo Captain interno). +class Captain::Hermes::NotifyPaymentConfirmedJob < ApplicationJob + queue_as :default + + retry_on Captain::Hermes::Client::DispatchError, attempts: 3, wait: 5.seconds + + def perform(reservation_id) # rubocop:disable Metrics/MethodLength + reservation = Captain::Reservation.find_by(id: reservation_id) + if reservation.blank? + Rails.logger.warn("[Captain::Hermes::NotifyPaymentConfirmedJob] reservation #{reservation_id} not found") + return + end + + conversation = reservation.conversation + if conversation.blank? + Rails.logger.info("[Captain::Hermes::NotifyPaymentConfirmedJob] reservation #{reservation_id} has no conversation — skipping") + return + end + + unless Captain::Hermes.enabled_for?(conversation.inbox) + Rails.logger.info( + "[Captain::Hermes::NotifyPaymentConfirmedJob] inbox #{conversation.inbox_id} " \ + 'not Hermes-enabled — skipping (Captain interno cuida)' + ) + return + end + + Captain::Hermes::Client.new(conversation.inbox).notify_event( + conversation: conversation, + event_type: 'payment_confirmed', + system_message: build_system_message(reservation) + ) + end + + private + + def build_system_message(reservation) + deposit = reservation.metadata.to_h['deposit_amount'].to_f + total = reservation.total_amount.to_f + suite = reservation.suite_identifier.to_s + check_in = reservation.check_in_at&.strftime('%d/%m/%Y às %Hh%M') + + [ + '[SISTEMA: pagamento_confirmado]', + "Pix da reserva ##{reservation.id} acabou de cair pelo banco.", + "Sinal R$ #{format('%.2f', deposit)} de R$ #{format('%.2f', total)} (#{suite}, check-in #{check_in}).", + 'Mande mensagem espontânea celebrando a reserva confirmada e dando próximos passos curtos. Tom íntimo, sem voltar a oferecer outras coisas.' + ].join("\n") + end +end diff --git a/enterprise/app/services/captain/hermes/client.rb b/enterprise/app/services/captain/hermes/client.rb index 8e926a092..7a5c7f8a2 100644 --- a/enterprise/app/services/captain/hermes/client.rb +++ b/enterprise/app/services/captain/hermes/client.rb @@ -37,6 +37,27 @@ class Captain::Hermes::Client raise DispatchError, "Network error contacting Hermes (#{e.class}): #{e.message}" end + # Notificação proativa de evento do Captain pro Hermes — usado pra eventos + # do sistema (Pix pago, reserva expirando, etc) onde o agente deve mandar + # mensagem espontânea sem o cliente ter falado nada. + # + # `system_message` deve começar com `[SISTEMA: ]` pra Valentina + # diferenciar de fala real do cliente (ver regra correspondente em SOUL.md). + def notify_event(conversation:, event_type:, system_message:) + payload = build_event_payload(conversation, event_type, system_message) + body = payload.to_json + headers = signed_headers(body) + + Rails.logger.info "[Captain::Hermes::Client] notifying event #{event_type} (conv #{conversation.display_id}) → #{webhook_url}" + + response = HTTParty.post(webhook_url, body: body, headers: headers, timeout: TIMEOUT_SECONDS) + return response if response.success? || response.code == 202 + + raise DispatchError, "Hermes webhook returned HTTP #{response.code}: #{response.body.to_s.truncate(300)}" + rescue HTTParty::Error, Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED => e + raise DispatchError, "Network error contacting Hermes (#{e.class}): #{e.message}" + end + private attr_reader :inbox @@ -45,11 +66,28 @@ class Captain::Hermes::Client Captain::Hermes.webhook_url_for(inbox) end - def build_payload(message:, conversation:) + def build_payload(message:, conversation:) # 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: message.content.to_s, - contact_name: conversation.contact&.name, + message: text_for_hermes(message), + image_urls: image_urls_for_hermes(message), + contact_name: contact&.name, + contact_first_name: contact&.name.to_s.split.first, contact_id: conversation.contact_id, + contact_cpf_present: cpf_digits.length == 11, + contact_email_present: contact&.email.to_s.include?('@'), + contact_total_reservas: contact_attrs[:total_reservas].to_i, + contact_ultima_suite: contact_attrs[:ultima_suite].to_s.presence, + last_reservation_date: history[:last_reservation_date], + last_reservation_status: history[:last_reservation_status], + last_reservation_amount: history[:last_reservation_amount], + last_reservation_suite: history[:last_reservation_suite], + last_conversation_at: history[:last_conversation_at], + total_conversations: history[:total_conversations], conversation_id: conversation.display_id, conversation_internal_id: conversation.id, inbox_id: inbox.id, @@ -60,6 +98,101 @@ class Captain::Hermes::Client } end + # Constroi payload pra notificação de evento sistema. Reusa todo o [ctx] + # do build_payload normal; só substitui `message` pelo system_message e + # marca `is_system_event=true` pra debug/logging. + def build_event_payload(conversation, event_type, system_message) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + 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: system_message, + image_urls: [], + is_system_event: true, + event_type: event_type, + contact_name: contact&.name, + contact_first_name: contact&.name.to_s.split.first, + contact_id: conversation.contact_id, + contact_cpf_present: cpf_digits.length == 11, + contact_email_present: contact&.email.to_s.include?('@'), + contact_total_reservas: contact_attrs[:total_reservas].to_i, + contact_ultima_suite: contact_attrs[:ultima_suite].to_s.presence, + last_reservation_date: history[:last_reservation_date], + last_reservation_status: history[:last_reservation_status], + last_reservation_amount: history[:last_reservation_amount], + last_reservation_suite: history[:last_reservation_suite], + last_conversation_at: history[:last_conversation_at], + total_conversations: history[:total_conversations], + conversation_id: conversation.display_id, + conversation_internal_id: conversation.id, + inbox_id: inbox.id, + inbox_name: inbox.name, + account_id: inbox.account_id, + message_id: 0, + timestamp: Time.current.to_i + } + end + + # Resolve texto da message pro Hermes consumir. Reusa + # Captain::OpenAiMessageBuilderService que JÁ implementa transcrição de + # áudio (Whisper) + placeholder pra outros attachments. Garante que + # mensagem nunca chega vazia mesmo quando cliente manda só áudio/foto. + # Imagens viram URL/base64 dentro do builder mas pra Hermes via texto, o + # generate_text_content normaliza pra "[image]" placeholder — ainda + # útil pro LLM saber que veio anexo visual. + def text_for_hermes(message) + raw = message.content.to_s + return raw if message.attachments.blank? + + Captain::OpenAiMessageBuilderService.new(message: message).generate_text_content.presence || raw + rescue StandardError => e + Rails.logger.warn("[Captain::Hermes::Client] text_for_hermes fallback: #{e.class} - #{e.message}") + message.content.to_s + end + + # URLs públicas das imagens que vieram nessa message. Plugin captain-webhook + # do Hermes baixa essas URLs localmente e popula event.media_urls — daí o + # gpt-5.3-codex (multimodal) consegue ler. Vídeo/PDF/etc ficam de fora por + # enquanto — só imagem é suportada pro LLM ver de fato. + def image_urls_for_hermes(message) + return [] if message.attachments.blank? + + message.attachments.where(file_type: :image).filter_map do |att| + next nil unless att.file.attached? + + att.download_url.presence || att.external_url.presence || att.file_url + end + rescue StandardError => e + Rails.logger.warn("[Captain::Hermes::Client] image_urls_for_hermes fallback: #{e.class} - #{e.message}") + [] + end + + # Snapshot eager pra alimentar o [ctx]. Determinístico (lido do DB), só + # campos estruturados — pra detalhes livres o agente chama + # `get_contact_history` MCP. Limita a últimas reservation/conversation pra + # não estourar token budget. + def contact_history_snapshot(contact, current_conversation) + return {} if contact.blank? + + last_res = Captain::Reservation + .where(contact_id: contact.id) + .where.not(status: 'draft') + .order(check_in_at: :desc) + .first + other_convs = contact.conversations.where.not(id: current_conversation.id) + + { + last_reservation_date: last_res&.check_in_at&.to_date&.iso8601, + last_reservation_status: last_res&.status, + last_reservation_amount: last_res&.total_amount&.to_f, + last_reservation_suite: last_res&.suite_identifier, + last_conversation_at: other_convs.maximum(:last_activity_at)&.iso8601, + total_conversations: other_convs.count + }.compact + end + def signed_headers(body) headers = { 'Content-Type' => 'application/json; charset=utf-8' } diff --git a/enterprise/app/services/captain/payments/confirmation_service.rb b/enterprise/app/services/captain/payments/confirmation_service.rb index f132eec1f..14c0a322b 100644 --- a/enterprise/app/services/captain/payments/confirmation_service.rb +++ b/enterprise/app/services/captain/payments/confirmation_service.rb @@ -20,6 +20,7 @@ class Captain::Payments::ConfirmationService end enqueue_roulette_offer! unless was_already_paid + notify_hermes_proactively! unless was_already_paid Rails.logger.info "[PaymentConfirmation] Reserva #{@reservation.id} confirmada (#{source_label})" end @@ -89,4 +90,14 @@ class Captain::Payments::ConfirmationService rescue StandardError => e Rails.logger.warn("[PaymentConfirmation] falha ao enfileirar roleta reserva=#{reservation.id}: #{e.class} - #{e.message}") end + + # Notifica o Hermes Agent (se a inbox estiver no fluxo Hermes) pra mandar + # mensagem espontânea pro cliente. Coexiste com o fluxo Captain interno — + # se a inbox NÃO estiver no Hermes, o job ignora silenciosamente. Side + # effect: nunca bloqueia a confirmação. + def notify_hermes_proactively! + Captain::Hermes::NotifyPaymentConfirmedJob.perform_later(reservation.id) + rescue StandardError => e + Rails.logger.warn("[PaymentConfirmation] falha ao notificar Hermes reserva=#{reservation.id}: #{e.class} - #{e.message}") + end end