iachat/enterprise/app/jobs/captain/hermes/outgoing_job.rb
Rodribm10 f1d3a124d5 fix(hermes): debounce agrupava msgs de turns ANTIGOS (default_scope ASC)
Bug raiz que causou todos os problemas de "Hermes repetindo info de
turns anteriores" hoje:

Message tem default_scope order(created_at: :asc). O combined_incoming_
content fazia .order(created_at: :desc) que GERA SQL com 2 orderings
em conflito ("ORDER BY created_at ASC, created_at DESC") — no Postgres
o ASC ganha quando a primeira coluna é igual. Resultado:
last_real_outgoing virava a MAIS ANTIGA outgoing (a saudação inicial),
não a mais recente. Aí o scope incoming agrupava TODAS as msgs do
cliente desde o "Oi" da Juliana, juntando wifi+pet num turn só.

Caso real conv 6064 (2026-05-02 21:18):
  T1: cliente "Preciso senha wifi" → Hermes "Prime2025"
  T2: cliente "Posso levar animais?"
  → debounce agrupou ["Preciso senha wifi", "Posso levar animais"]
    como se fossem MSGS DO MESMO TURN, mandou pro Hermes como
    "Cliente acabou de dizer: Preciso senha wifi.\nPosso levar animais?"
  → Hermes consultou faq_lookup com query "senha wifi e animais"
  → Resposta: "Senha Prime2025 e pode levar animais"

Fix: usa .reorder(created_at: :desc) que sobrescreve default_scope,
gerando SQL limpa com só "ORDER BY created_at DESC".

Camadas 3 (strip linhas) e 4 (topic gating) que adicionei antes são
paliativos válidos como defesa em profundidade, mas o problema raiz
era esse default_scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:30:12 -03:00

82 lines
3.9 KiB
Ruby

# Dispara o webhook do Hermes Agent assincronamente quando uma mensagem
# do cliente chega numa inbox marcada como Hermes-enabled.
#
# Acionado pelo Enterprise::MessageTemplates::HookExecutionService no lugar do
# Captain::Conversation::ResponseBuilderJob padrão, quando
# Captain::Hermes.enabled_for?(inbox) retorna true.
class Captain::Hermes::OutgoingJob < ApplicationJob
queue_as :default
retry_on Captain::Hermes::Client::DispatchError, attempts: 3, wait: 5.seconds
HUMAN_TRIAGE_LABELS = %w[triagem_humana hermes_placeholder].freeze
def perform(conversation_id, message_id)
conversation = Conversation.find_by(id: conversation_id)
message = Message.find_by(id: message_id)
return if conversation.blank? || message.blank?
return unless Captain::Hermes.enabled_for?(conversation.inbox)
# Conv marcada pra triagem humana = Hermes não responde mais (até admin
# remover label). Evita gastar token e gerar loop em msgs claramente fora
# de escopo (operadora telefonia, banco, suporte de outro app, etc).
if conversation.label_list.intersect?(HUMAN_TRIAGE_LABELS)
Rails.logger.info("[Captain::Hermes::OutgoingJob] conv #{conversation.display_id} em triagem humana — pulando dispatch")
return
end
# 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)
# 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)
snapshot_tool_call_baseline(conversation)
Captain::Hermes::Client.new(conversation.inbox).dispatch(
message: message, conversation: conversation, content_override: combined
)
end
private
# Salva o contador atual de tool calls da conv ANTES do dispatch.
# HermesCallbackController compara contra valor pós-callback pra detectar
# respostas factuais sem chamada de tool (alucinação de memória).
def snapshot_tool_call_baseline(conversation)
current = Rails.cache.read("hermes_tool_calls:#{conversation.id}", raw: true).to_i
Rails.cache.write("hermes_tool_calls_baseline:#{conversation.id}", current, expires_in: 5.minutes, raw: true)
end
# 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).
#
# Atenção: usa `reorder` em vez de `order` porque o model Message tem
# default_scope `order(created_at: :asc)` — sem reorder, a SQL final fica
# `ORDER BY created_at ASC, created_at DESC` e o ASC ganha. Resultado:
# last_real_outgoing virava a MAIS ANTIGA, agrupando msgs de turns
# passados (caso real: Hermes recebia "wifi+pet" colado mesmo em turns
# separados — visto em conv 6064 em 2026-05-02).
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'")
.reorder(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.reorder(created_at: :asc).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