feat(captain/hermes): payload enriquecido + humanizadores + notif Pix proativa

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) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-01 20:15:50 -03:00
parent 9ed3491d55
commit 48fad2977b
5 changed files with 292 additions and 4 deletions

View File

@ -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}"

View File

@ -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

View File

@ -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

View File

@ -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: <event_type>]` 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' }

View File

@ -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