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:
parent
9ed3491d55
commit
48fad2977b
@ -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}"
|
||||
|
||||
84
enterprise/app/jobs/captain/hermes/delayed_reply_job.rb
Normal file
84
enterprise/app/jobs/captain/hermes/delayed_reply_job.rb
Normal 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
|
||||
@ -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
|
||||
@ -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' }
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user