feat(captain/hermes): handoff por loop + label-guard em outgoing

Substitui o interceptor agressivo de "Um momento — vou verificar"
(que bloqueava silenciosamente) por handoff explícito + loop detection:

- HANDOFF_PATTERNS: detecta a frase-âncora ("Um momento — vou
  verificar", "Aguarde um instante") e ENTREGA pro cliente,
  marcando conv com label triagem_humana.
- looped_response?: detecta 2 outgoing similares (Jaccard >= 0.50)
  OU pergunta reformulada sobre mesmo tópico (>= 3 keywords em comum
  + ambas inquisitivas via "?" / "me confirma" / "qual prefere"
  etc). 1ª resposta passa, 2ª escala. Cobre o caso "endereço ou
  link?" → "apenas link ou link + endereço?".
- OutgoingJob: guard que pula dispatch se conv tem label
  triagem_humana ou hermes_placeholder. Hermes não responde mais →
  não gasta token + não gera loop.

Cobre 2 casos do Rodrigo:
1. Bot da Claro insistindo em menu → 2ª resposta similar escala.
2. Hermes pedindo confirmação 2x sem entregar → escala.

Tokenize normaliza acentos (transliterate) pra stopwords baterem
"voce/você", "endereco/endereço", etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-02 17:30:58 -03:00
parent 10fec1d2cb
commit c960dc7e1e
2 changed files with 109 additions and 60 deletions

View File

@ -16,17 +16,33 @@
class Webhooks::Captain::HermesCallbackController < ApplicationController class Webhooks::Captain::HermesCallbackController < ApplicationController
RECENT_WINDOW = 5.minutes RECENT_WINDOW = 5.minutes
# Placeholders que o LLM emite quando "decide" mandar um sinal de espera # "Um momento — vou verificar" é a frase-âncora de handoff intencional
# antes de chamar uma tool — mas às vezes manda E PARA, sem chamar a tool # (quando o agente não sabe responder e quer escalar pra humano). NÃO
# (api_calls=1 no daemon). Quando detectamos, NÃO entregamos pro cliente # bloqueamos — entregamos pro cliente e marcamos triagem_humana pra
# e re-disparamos pro Hermes uma instrução pra retomar e chamar a tool. # próximas msgs não dispararem Hermes.
PLACEHOLDER_PATTERNS = [ HANDOFF_PATTERNS = [
/\A\s*[⏳⌛]?\s*um\s+momento/i, /\A\s*[⏳⌛]?\s*um\s+momento.*verificar/i,
/\A\s*[⏳⌛]\s*vou\s+verificar/i, /\A\s*[⏳⌛]?\s*um\s+instante.*verificar/i,
/\A\s*aguarde\s+um\s+instante/i, /\A\s*aguarde\s+um\s+instante/i
/\A\s*deixa\s+eu\s+(verificar|conferir|checar)/i ].freeze
# Loop detection: 2 sinais combinados.
# 1. Jaccard de tokens >= 0.50 → resposta praticamente igual.
# 2. >= 3 palavras-chave em comum (sem stopwords) E ambas terminam com
# "?" → repetiu pergunta sobre o mesmo tópico (caso clássico do
# "endereço completo ou link?" → "apenas link ou link + endereço?").
LOOP_SIMILARITY_THRESHOLD = 0.50
LOOP_TOPIC_KEYWORD_OVERLAP = 3
LOOP_STOPWORDS = %w[
voce voces para por pra como mas isso esse essa estou esta este aqui ali
eles elas tem ter tinha tendo era ser sou foi fui agora ainda ja muito mais
quer quero queria pode posso podia consegue consigo conseguia preciso precisar
sim nao não talvez bom boa olha veja oi ola ola tchau certo ok blz beleza
obrigado obrigada valeu vlw thanks por favor please
apenas somente algum alguma quem onde quando o a os as do da dos das no na nos nas
em com sem sob sobre antes apos depois entre meio tudo todo toda
perfeito otimo certinho confirma confirme
].freeze ].freeze
MAX_PLACEHOLDER_RETRIES = 2
skip_before_action :verify_authenticity_token, raise: false skip_before_action :verify_authenticity_token, raise: false
before_action :verify_signature before_action :verify_signature
@ -39,17 +55,9 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController
conversation = recent_conversation_for(@inbox) conversation = recent_conversation_for(@inbox)
return log_no_conversation_and_ack if conversation.blank? return log_no_conversation_and_ack if conversation.blank?
if placeholder_response?(content)
handle_placeholder_response(conversation, content)
return head :ok
end
log_reply(conversation, content) log_reply(conversation, content)
if defined?(Captain::Hermes::DelayedReplyJob) detect_handoff_or_loop(conversation, content)
Captain::Hermes::DelayedReplyJob.perform_later(conversation.id, content) deliver_outgoing(conversation, content)
else
create_outgoing_message(conversation, content)
end
head :ok head :ok
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[Hermes::Callback] error: #{e.class}: #{e.message}" Rails.logger.error "[Hermes::Callback] error: #{e.class}: #{e.message}"
@ -57,59 +65,90 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController
head :internal_server_error head :internal_server_error
end end
# Hermes mandou frase-âncora de handoff: entrega ao cliente normalmente,
# mas marca conv pra triagem humana — próximas msgs não disparam Hermes
# de novo (guard em OutgoingJob). OU: detectou loop (mesma resposta /
# pergunta reformulada) e escala.
def detect_handoff_or_loop(conversation, content)
if handoff_response?(content)
mark_for_human_triage(conversation, reason: 'handoff_intencional')
elsif looped_response?(conversation, content)
mark_for_human_triage(conversation, reason: 'loop_detectado')
end
end
def deliver_outgoing(conversation, content)
if defined?(Captain::Hermes::DelayedReplyJob)
Captain::Hermes::DelayedReplyJob.perform_later(conversation.id, content)
else
create_outgoing_message(conversation, content)
end
end
private private
def placeholder_response?(content) def handoff_response?(content)
return false if content.blank? return false if content.blank?
PLACEHOLDER_PATTERNS.any? { |re| content.match?(re) } HANDOFF_PATTERNS.any? { |re| content.match?(re) }
end end
# Quando o Hermes responde só placeholder e para, fazemos 2 coisas: # Detecta loop: a resposta atual do Hermes é muito parecida com a anterior
# 1. NÃO entregamos a msg pro cliente (UX terrível ver "vou verificar" e # outgoing dele na mesma conv (Jaccard de tokens >= 0.70). Sinaliza que o
# ficar esperando indefinidamente); # agente está repetindo pergunta/resposta sem progredir — geralmente
# 2. Disparamos notify_event pro Hermes com instrução pra retomar e # cliente fora do escopo OU fluxo travado (pediu link sem ter como
# chamar a tool relevante. Limite 2 retries por conversation pra # entregar, perguntou de novo a mesma confirmação, etc).
# evitar loop. Após esgotar, marcamos pix_falhou_fallback pra def looped_response?(conversation, content)
# triagem humana. prev = conversation.messages
def handle_placeholder_response(conversation, content) .where(message_type: :outgoing)
cache_key = "hermes_placeholder_retry:#{conversation.id}" .where("content_attributes ->> 'external_source' = ?", 'hermes_callback')
retries = Rails.cache.read(cache_key).to_i .order(created_at: :desc)
.limit(1)
.pick(:content)
return false if prev.blank?
Rails.logger.warn( return true if similarity(content, prev) >= LOOP_SIMILARITY_THRESHOLD
"[Hermes::Callback] placeholder detectado em conv #{conversation.display_id} (retries=#{retries}): #{content.truncate(80)}"
)
if retries >= MAX_PLACEHOLDER_RETRIES # Caso "pergunta reformulada sobre o mesmo tópico": ambas terminam com "?"
Rails.logger.error("[Hermes::Callback] esgotou retries de placeholder em conv #{conversation.display_id} — marcando triagem") # e compartilham >= 3 palavras-chave (sem stopwords).
mark_for_human_triage(conversation) repeated_question?(content, prev)
Rails.cache.delete(cache_key)
return
end
Rails.cache.write(cache_key, retries + 1, expires_in: 5.minutes)
retrigger_hermes_no_placeholder!(conversation)
end end
def retrigger_hermes_no_placeholder!(conversation) def similarity(text_a, text_b)
Captain::Hermes::Client.new(@inbox).notify_event( set_a = tokenize(text_a)
conversation: conversation, set_b = tokenize(text_b)
event_type: 'force_tool_call', return 0.0 if set_a.empty? || set_b.empty?
system_message: '[SISTEMA: force_tool_call] Você acabou de emitir um placeholder ' \
'("Um momento", "vou verificar" etc) sem chamar tool. Cliente NÃO ' \ intersection = (set_a & set_b).size
'recebeu essa mensagem. Releia a última mensagem do cliente e ' \ union = (set_a | set_b).size
'CHAME A TOOL CORRESPONDENTE AGORA (generate_pix se confirmou ' \ intersection.to_f / union
'reserva, send_suite_images se pediu foto, faq_lookup se pediu ' \
'info de regra). Se faltar dado, pergunte direto sem placeholder.'
)
rescue Captain::Hermes::Client::DispatchError => e
Rails.logger.error("[Hermes::Callback] retrigger falhou: #{e.message}")
mark_for_human_triage(conversation)
end end
def mark_for_human_triage(conversation) # Pergunta/confirmação reformulada sobre o mesmo tópico. Detecta tanto "?"
# quanto formas imperativas comuns ("me confirma", "qual", "quer").
def repeated_question?(text_a, text_b)
return false unless inquisitive?(text_a) && inquisitive?(text_b)
keywords_a = tokenize(text_a) - LOOP_STOPWORDS
keywords_b = tokenize(text_b) - LOOP_STOPWORDS
(keywords_a & keywords_b).size >= LOOP_TOPIC_KEYWORD_OVERLAP
end
INQUISITIVE_REGEX = /(\?|\bme\s+confirm|\bvoce\s+(prefere|quer)|\bqual\s+(prefere|deseja|seria)|\bquer\s+(que|saber|ver|um|uma))/i
def inquisitive?(text)
INQUISITIVE_REGEX.match?(ActiveSupport::Inflector.transliterate(text.to_s))
end
def tokenize(text)
normalized = ActiveSupport::Inflector.transliterate(text.to_s.downcase)
normalized.scan(/[a-z0-9]+/).reject { |w| w.length < 3 }.to_set
end
def mark_for_human_triage(conversation, reason: nil)
current = conversation.label_list current = conversation.label_list
conversation.update_labels((current + %w[hermes_placeholder triagem_humana]).uniq) conversation.update_labels((current + %w[triagem_humana]).uniq)
Rails.logger.info("[Hermes::Callback] conv #{conversation.display_id} → triagem_humana (#{reason})")
end end
def fetch_inbox def fetch_inbox

View File

@ -9,12 +9,22 @@ class Captain::Hermes::OutgoingJob < ApplicationJob
retry_on Captain::Hermes::Client::DispatchError, attempts: 3, wait: 5.seconds 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) def perform(conversation_id, message_id)
conversation = Conversation.find_by(id: conversation_id) conversation = Conversation.find_by(id: conversation_id)
message = Message.find_by(id: message_id) message = Message.find_by(id: message_id)
return if conversation.blank? || message.blank? return if conversation.blank? || message.blank?
return unless Captain::Hermes.enabled_for?(conversation.inbox) 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. # Auto-react ANTES do dispatch — gesto chega <1s sem esperar Codex.
# Não bloqueia fluxo: se falhar, dispatch normal continua. # Não bloqueia fluxo: se falhar, dispatch normal continua.
Captain::Hermes::AutoReactService.maybe_react!(message) Captain::Hermes::AutoReactService.maybe_react!(message)