revert(hermes): remove camadas 2/3/4 + reabilita memória

A causa raiz dos bugs de "info repetida em turns anteriores" era o
default_scope ASC do Message conflitando com .order(desc) no debounce
(ver commit f1d3a124d). Como já corrigi com .reorder, as Camadas 2, 3 e
4 viraram peso morto que adicionava latência/false positive sem ganho.

Removido:
- Camada 2 (factual sem tool → retrigger force_factual_tool)
- Camada 3 (strip de linhas repetidas com pool de outgoings anteriores)
- Camada 4 (topic gating: bloqueio quando resposta tem tópico não pedido)
- Tracker de tool calls em McpController (suportava Camada 2)
- Snapshot baseline em OutgoingJob (suportava Camada 2)
- Regra "🚨 NÃO CONFIE NA SUA MEMÓRIA" das 4 SOUL.md Hermes

Mantido:
- Camada 1: handoff intencional ("Um momento — vou verificar") +
  loop detection (Jaccard >= 0.50 ou pergunta reformulada com 3+
  keywords). Genuíno pra bot externo (Claro/Vivo) e loops óbvios.
- Label-guard em OutgoingJob (não dispatch se conv tem triagem_humana).
- Auto-react ambient (feature original).
- Reorder fix no combined_incoming_content (causa raiz).

Memory + user_profile reabilitados nos 4 Hermes (config.yaml) e no
template do hermes-provision pra futuros agentes. Sem memória, cliente
precisa repetir nome/CPF/contexto a cada turn — UX horrível.
Contaminação cross-unit que justificava desligar vinha de outro bug
(X-Captain-Assistant-Id apontando pro parent), já corrigido.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-02 18:39:43 -03:00
parent f1d3a124d5
commit b561aa8451
4 changed files with 16 additions and 248 deletions

View File

@ -13,7 +13,7 @@
# de forma confiável, identificamos pela ÚLTIMA conversation pending da inbox # de forma confiável, identificamos pela ÚLTIMA conversation pending da inbox
# que recebeu mensagem nos últimos 5 minutos. Aceitável pra PoC com 1 conversa # que recebeu mensagem nos últimos 5 minutos. Aceitável pra PoC com 1 conversa
# de teste por vez. Pra produção, melhorar com Redis: delivery_id → conversation_id. # de teste por vez. Pra produção, melhorar com Redis: delivery_id → conversation_id.
class Webhooks::Captain::HermesCallbackController < ApplicationController # rubocop:disable Metrics/ClassLength class Webhooks::Captain::HermesCallbackController < ApplicationController
RECENT_WINDOW = 5.minutes RECENT_WINDOW = 5.minutes
# "Um momento — vou verificar" é a frase-âncora de handoff intencional # "Um momento — vou verificar" é a frase-âncora de handoff intencional
@ -26,55 +26,12 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController # rubo
/\A\s*aguarde\s+um\s+instante/i /\A\s*aguarde\s+um\s+instante/i
].freeze ].freeze
# Camada 2 — Gating de saída factual:
# Se a resposta do Hermes contém info factual (preço, senha, regra,
# horário) E o LLM NÃO chamou nenhuma tool MCP entre o dispatch e o
# callback, é alucinação de memória. Bloqueia a resposta, força
# consulta a tool via notify_event. 1 retry; se persistir, escala humano.
FACTUAL_PATTERNS = [
/R\$\s*\d/i, # R$ 50, R$ 125,00
/\b\d+\s*(reais|reai|real)\b/i,
/\b\d+\s*%/, # 50%
/\bsenha\b/i, # qualquer menção a senha
/\b(c[óo]digo)\s+(de|do)\s+(porta|portao|portão|garagem)\b/i,
/\b(check[-]?in|check[-]?out|hor[áa]rio)\s+(é|eh|de)\s+\d/i, # check-in é 14h
/\b(política|politica|regra)\s+de\s+(cancelamento|no[\s-]?show|reembolso)/i,
/(n[ãa]o\s+(é\s+)?permitido|é\s+permitido)\s+\w{3,}/i,
/(pode|podem)\s+(levar|trazer)\s+(animal|pet|cachorro|gato|crian[çc]a)/i
].freeze
MAX_FACTUAL_GATE_RETRIES = 1
# Loop detection: 2 sinais combinados. # Loop detection: 2 sinais combinados.
# 1. Jaccard de tokens >= 0.50 → resposta praticamente igual. # 1. Jaccard de tokens >= 0.50 → resposta praticamente igual.
# 2. >= 3 palavras-chave em comum (sem stopwords) E ambas terminam com # 2. >= 3 palavras-chave em comum (sem stopwords) E ambas inquisitivas →
# "?" → repetiu pergunta sobre o mesmo tópico (caso clássico do # repetiu pergunta sobre o mesmo tópico.
# "endereço completo ou link?" → "apenas link ou link + endereço?").
LOOP_SIMILARITY_THRESHOLD = 0.50 LOOP_SIMILARITY_THRESHOLD = 0.50
LOOP_TOPIC_KEYWORD_OVERLAP = 3 LOOP_TOPIC_KEYWORD_OVERLAP = 3
# Camada 3 — Anti-repetição de linhas:
# LLM tende a "resumir" info de turns anteriores em toda nova resposta
# (ex: depois que mandou senha do Wi-Fi, repete em todo callback futuro).
# Strip de linhas onde >70% das palavras significativas já apareceram em
# outgoings recentes da mesma conv. Saudações/confirmações curtas
# (<5 chars) são preservadas.
REPEAT_OVERLAP_THRESHOLD = 0.70
REPEAT_LOOKBACK_OUTGOINGS = 3
# Camada 4 — Topic gating:
# Detecta quando a resposta menciona um tópico factual (Wi-Fi/senha/pet/
# estacionamento/etc) que NÃO foi pedido na última msg do cliente.
# Caso clássico: cliente pergunta só sobre pet, Hermes responde com
# "Senha=X e pode levar pet" (puxando wifi de turn anterior). Bloqueia,
# dispara notify_event forçando responder só o tópico atual. 1 retry.
TOPIC_INDICATORS = {
wifi: %w[wifi wi-fi internet rede prime2025 senha password],
pet: %w[pet pets animal animais cachorro gato bicho],
estacionamento: %w[estacionamento garagem carro vaga],
cancelamento: %w[cancelar cancelamento reembolso desmarcar],
preco: %w[preco preço valor reais]
}.freeze
MAX_OFFTOPIC_RETRIES = 1
LOOP_STOPWORDS = %w[ LOOP_STOPWORDS = %w[
voce voces para por pra como mas isso esse essa estou esta este aqui ali 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 eles elas tem ter tinha tendo era ser sou foi fui agora ainda ja muito mais
@ -98,21 +55,6 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController # rubo
return log_no_conversation_and_ack if conversation.blank? return log_no_conversation_and_ack if conversation.blank?
log_reply(conversation, content) log_reply(conversation, content)
# Camada 2: resposta factual sem chamada de tool durante a turn é
# alucinação de memória. Bloqueia entrega + re-dispara forçando tool
# call. NÃO entrega a msg pro cliente até o LLM consultar a fonte.
return head :ok if gate_factual_no_tool!(conversation, content)
# Camada 3: strip de linhas repetidas de outgoings anteriores
# (LLM resumindo info de turns anteriores em cada nova resposta).
content = strip_repeated_lines(conversation, content)
# Camada 4: detecta tópico factual misturado na resposta sem ter sido
# pedido (ex: cliente pergunta sobre pet, Hermes manda Wi-Fi+pet juntos).
# Bloqueia + retrigger forçando responder só o tópico atual.
return head :ok if gate_off_topic_factual!(conversation, content)
detect_handoff_or_loop(conversation, content) detect_handoff_or_loop(conversation, content)
deliver_outgoing(conversation, content) deliver_outgoing(conversation, content)
head :ok head :ok
@ -122,6 +64,8 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController # rubo
head :internal_server_error head :internal_server_error
end end
private
# Hermes mandou frase-âncora de handoff: entrega ao cliente normalmente, # Hermes mandou frase-âncora de handoff: entrega ao cliente normalmente,
# mas marca conv pra triagem humana — próximas msgs não disparam Hermes # mas marca conv pra triagem humana — próximas msgs não disparam Hermes
# de novo (guard em OutgoingJob). OU: detectou loop (mesma resposta / # de novo (guard em OutgoingJob). OU: detectou loop (mesma resposta /
@ -142,88 +86,28 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController # rubo
end end
end end
private
def handoff_response?(content) def handoff_response?(content)
return false if content.blank? return false if content.blank?
HANDOFF_PATTERNS.any? { |re| content.match?(re) } HANDOFF_PATTERNS.any? { |re| content.match?(re) }
end end
# Retorna true se bloqueou a resposta (callback deve dar 200 + sair sem
# entregar). Retorna false pra fluxo normal continuar.
def gate_factual_no_tool!(conversation, content)
return false unless looks_factual?(content)
return false if tool_called_in_this_turn?(conversation)
retry_key = "hermes_factual_gate_retry:#{conversation.id}"
retries = Rails.cache.read(retry_key, raw: true).to_i
if retries >= MAX_FACTUAL_GATE_RETRIES
Rails.logger.error("[Hermes::Callback] factual sem tool persistente em conv #{conversation.display_id} — escalando")
mark_for_human_triage(conversation, reason: 'factual_no_tool_persistente')
Rails.cache.delete(retry_key)
# Entrega o conteúdo nesse caso (melhor algo do que silêncio); humano vê pela label.
deliver_outgoing(conversation, content)
return true
end
Rails.cache.write(retry_key, retries + 1, expires_in: 5.minutes, raw: true)
Rails.logger.warn("[Hermes::Callback] factual sem tool em conv #{conversation.display_id} — re-dispatch (retry #{retries + 1})")
trigger_force_tool_call!(conversation, content)
true
end
def looks_factual?(content)
return false if content.blank?
FACTUAL_PATTERNS.any? { |re| content.match?(re) }
end
# Compara contador de tool calls atual com baseline gravado pelo
# OutgoingJob no momento do dispatch. Se subiu, o LLM chamou tool —
# resposta é fundamentada. Se igual, é puro palpite.
def tool_called_in_this_turn?(conversation)
baseline = Rails.cache.read("hermes_tool_calls_baseline:#{conversation.id}", raw: true).to_i
current = Rails.cache.read("hermes_tool_calls:#{conversation.id}", raw: true).to_i
current > baseline
end
def trigger_force_tool_call!(conversation, original_content)
Captain::Hermes::Client.new(@inbox).notify_event(
conversation: conversation,
event_type: 'force_factual_tool',
system_message: '[SISTEMA: force_factual_tool] Você emitiu uma resposta com info ' \
"factual ('#{original_content.truncate(120)}') SEM consultar tool. " \
'Cliente NÃO recebeu essa mensagem. Releia a última pergunta do ' \
'cliente e CHAME a tool relevante AGORA: faq_lookup pra regra/' \
'política/Wi-Fi/horário, tabela de preços da skill pra valores. ' \
'Se a tool retornar vazio, NÃO INVENTE: responda exatamente ' \
"'⏳ Um momento — vou verificar.' e pare."
)
rescue Captain::Hermes::Client::DispatchError => e
Rails.logger.error("[Hermes::Callback] force_factual_tool dispatch falhou: #{e.message}")
mark_for_human_triage(conversation, reason: 'force_factual_dispatch_falhou')
deliver_outgoing(conversation, original_content)
end
# Detecta loop: a resposta atual do Hermes é muito parecida com a anterior # Detecta loop: a resposta atual do Hermes é muito parecida com a anterior
# outgoing dele na mesma conv (Jaccard de tokens >= 0.70). Sinaliza que o # outgoing dele na mesma conv (Jaccard de tokens >= 0.50). Sinaliza que o
# agente está repetindo pergunta/resposta sem progredir — geralmente # agente está repetindo pergunta/resposta sem progredir — geralmente
# cliente fora do escopo OU fluxo travado (pediu link sem ter como # cliente fora do escopo (operadora telefonia, banco, suporte de outro
# entregar, perguntou de novo a mesma confirmação, etc). # app, etc) OU fluxo travado.
def looped_response?(conversation, content) def looped_response?(conversation, content)
prev = conversation.messages prev = conversation.messages
.where(message_type: :outgoing) .where(message_type: :outgoing)
.where("content_attributes ->> 'external_source' = ?", 'hermes_callback') .where("content_attributes ->> 'external_source' = ?", 'hermes_callback')
.order(created_at: :desc) .reorder(created_at: :desc)
.limit(1) .limit(1)
.pick(:content) .pick(:content)
return false if prev.blank? return false if prev.blank?
return true if similarity(content, prev) >= LOOP_SIMILARITY_THRESHOLD return true if similarity(content, prev) >= LOOP_SIMILARITY_THRESHOLD
# Caso "pergunta reformulada sobre o mesmo tópico": ambas terminam com "?"
# e compartilham >= 3 palavras-chave (sem stopwords).
repeated_question?(content, prev) repeated_question?(content, prev)
end end
@ -264,101 +148,6 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController # rubo
Rails.logger.info("[Hermes::Callback] conv #{conversation.display_id} → triagem_humana (#{reason})") Rails.logger.info("[Hermes::Callback] conv #{conversation.display_id} → triagem_humana (#{reason})")
end end
# Strip linhas onde a maioria das palavras significativas já foi
# enviada em outgoings recentes. Preserva saudações curtas (<5 chars)
# e tudo se a resposta ficaria vazia (melhor repetido do que mudo).
def strip_repeated_lines(conversation, content)
pool = recent_outgoings_pool(conversation)
return content if pool.blank?
lines = content.split("\n")
kept = lines.reject { |line| line_already_said?(line, pool) }
return content if kept.empty? || kept.size == lines.size
Rails.logger.info(
"[Hermes::Callback] strip de #{lines.size - kept.size} linha(s) repetida(s) em conv #{conversation.display_id}"
)
kept.join("\n").strip
end
def recent_outgoings_pool(conversation)
msgs = conversation.messages
.where(message_type: :outgoing)
.order(created_at: :desc)
.limit(REPEAT_LOOKBACK_OUTGOINGS * 2)
texts = msgs.reject do |m|
attrs = m.content_attributes.to_h
attrs['is_reaction'] || attrs['external_source'] != 'hermes_callback'
end.first(REPEAT_LOOKBACK_OUTGOINGS).map(&:content)
return '' if texts.empty?
ActiveSupport::Inflector.transliterate(texts.join("\n").downcase)
end
# Detecta tópico factual na resposta que NÃO foi pedido na última pergunta
# do cliente. Retorna true se bloqueou (callback retorna 200 sem entregar);
# false pra fluxo normal continuar.
def gate_off_topic_factual!(conversation, content)
off_topics = unrequested_topics(conversation, content)
return false if off_topics.empty?
retry_key = "hermes_off_topic_retry:#{conversation.id}"
retries = Rails.cache.read(retry_key, raw: true).to_i
if retries >= MAX_OFFTOPIC_RETRIES
Rails.logger.warn("[Hermes::Callback] off-topic persistente em conv #{conversation.display_id} — entregando")
Rails.cache.delete(retry_key)
return false
end
Rails.cache.write(retry_key, retries + 1, expires_in: 5.minutes, raw: true)
Rails.logger.warn("[Hermes::Callback] off-topic detectado em conv #{conversation.display_id} (#{off_topics.join(',')}) — re-dispatch")
trigger_force_topic_focus!(conversation, content, off_topics)
true
end
# Retorna lista de tópicos presentes na resposta MAS ausentes da última
# pergunta do cliente. Vazio = resposta é coerente com pergunta.
def unrequested_topics(conversation, content)
last_q = conversation.messages.where(message_type: :incoming).order(created_at: :desc).limit(1).pick(:content).to_s
return [] if last_q.blank?
q_norm = ActiveSupport::Inflector.transliterate(last_q.downcase)
r_norm = ActiveSupport::Inflector.transliterate(content.to_s.downcase)
TOPIC_INDICATORS.filter_map do |topic, words|
in_response = words.any? { |w| r_norm.include?(w) }
in_query = words.any? { |w| q_norm.include?(w) }
topic if in_response && !in_query
end
end
def trigger_force_topic_focus!(conversation, original, off_topics)
last_q = conversation.messages.where(message_type: :incoming).order(created_at: :desc).limit(1).pick(:content).to_s
Captain::Hermes::Client.new(@inbox).notify_event(
conversation: conversation,
event_type: 'force_topic_focus',
system_message: '[SISTEMA: force_topic_focus] Sua última resposta misturou tópicos ' \
"(#{off_topics.join(', ')}) que o cliente NÃO pediu agora. Cliente NÃO " \
"recebeu. Releia APENAS a última pergunta dele ('#{last_q.truncate(100)}') " \
'e responda EXCLUSIVAMENTE sobre esse tópico. NÃO mencione info de ' \
'turns anteriores se não foi explicitamente pedido nesta pergunta.'
)
rescue Captain::Hermes::Client::DispatchError => e
Rails.logger.error("[Hermes::Callback] force_topic dispatch falhou: #{e.message}")
deliver_outgoing(conversation, original)
end
def line_already_said?(line, pool)
stripped = line.strip
return false if stripped.length < 5
words = ActiveSupport::Inflector.transliterate(stripped.downcase).scan(/[a-z0-9]+/).reject { |w| w.length < 3 }
return false if words.empty?
matches = words.count { |w| pool.include?(w) }
matches.to_f / words.size >= REPEAT_OVERLAP_THRESHOLD
end
def fetch_inbox def fetch_inbox
inbox_id = params[:inbox_id].presence || params.dig(:metadata, :inbox_id).presence inbox_id = params[:inbox_id].presence || params.dig(:metadata, :inbox_id).presence
if inbox_id.present? if inbox_id.present?
@ -390,7 +179,7 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController # rubo
inbox.conversations inbox.conversations
.where('updated_at >= ?', RECENT_WINDOW.ago) .where('updated_at >= ?', RECENT_WINDOW.ago)
.where(status: %w[pending open]) .where(status: %w[pending open])
.order(updated_at: :desc) .reorder(updated_at: :desc)
.first .first
end end

View File

@ -33,7 +33,6 @@ class Webhooks::Captain::McpController < ApplicationController
context: extract_context(request_body) context: extract_context(request_body)
) )
track_tool_call!(request_body)
return head :ok if response.nil? # MCP notifications return head :ok if response.nil? # MCP notifications
render json: response render json: response
@ -108,18 +107,4 @@ class Webhooks::Captain::McpController < ApplicationController
value.to_i value.to_i
end end
# Incrementa contador de tool calls por conversation. HermesCallbackController
# usa o snapshot pré-dispatch (gravado pelo OutgoingJob) vs valor atual pra
# detectar respostas factuais SEM chamada de tool (alucinação de memória).
def track_tool_call!(request_body)
return unless request_body['method'] == 'tools/call'
args = request_body.dig('params', 'arguments') || {}
conv_id = args['conversation_id'] || args[:conversation_id]
return if conv_id.blank?
Rails.cache.increment("hermes_tool_calls:#{conv_id}", 1, expires_in: 5.minutes, raw: true) ||
Rails.cache.write("hermes_tool_calls:#{conv_id}", 1, expires_in: 5.minutes, raw: true)
end
end end

View File

@ -279,8 +279,12 @@ fi
# tinha a senha real "Prime2025". # tinha a senha real "Prime2025".
sed -i "s/port: 8645/port: $PORT/" "$PROFILES_DIR/$SLUG/config.yaml" sed -i "s/port: 8645/port: $PORT/" "$PROFILES_DIR/$SLUG/config.yaml"
sed -i "s/X-Captain-Assistant-Id: '6'/X-Captain-Assistant-Id: '$ASSISTANT_ID'/" "$PROFILES_DIR/$SLUG/config.yaml" sed -i "s/X-Captain-Assistant-Id: '6'/X-Captain-Assistant-Id: '$ASSISTANT_ID'/" "$PROFILES_DIR/$SLUG/config.yaml"
sed -i 's/ memory_enabled: true/ memory_enabled: false/' "$PROFILES_DIR/$SLUG/config.yaml" # memory_enabled / user_profile_enabled ficam LIGADOS (default da Valentina
sed -i 's/ user_profile_enabled: true/ user_profile_enabled: false/' "$PROFILES_DIR/$SLUG/config.yaml" # template). Antes desligávamos achando que evitaria contaminação cross-unit
# — mas a contaminação real vinha do X-Captain-Assistant-Id apontando pro
# parent (já corrigido). Memória off mata UX (cliente repete nome/CPF a
# cada turn), e cada Hermes tem session isolada por chat_id, então memória
# de uma conv não vaza pra outra naturalmente.
# SOUL.md: clona a da Valentina (template canônico) e substitui identidade. # SOUL.md: clona a da Valentina (template canônico) e substitui identidade.
# Tudo que NÃO for identidade/marca/categoria — tom, formatação WhatsApp, [ctx], # Tudo que NÃO for identidade/marca/categoria — tom, formatação WhatsApp, [ctx],

View File

@ -35,8 +35,6 @@ class Captain::Hermes::OutgoingJob < ApplicationJob
# texto agrupado pra Hermes ver o pensamento completo do cliente. # texto agrupado pra Hermes ver o pensamento completo do cliente.
combined = combined_incoming_content(conversation, message) combined = combined_incoming_content(conversation, message)
snapshot_tool_call_baseline(conversation)
Captain::Hermes::Client.new(conversation.inbox).dispatch( Captain::Hermes::Client.new(conversation.inbox).dispatch(
message: message, conversation: conversation, content_override: combined message: message, conversation: conversation, content_override: combined
) )
@ -44,14 +42,6 @@ class Captain::Hermes::OutgoingJob < ApplicationJob
private 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 # 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 # (não-reaction) do agente e a msg âncora. Retorna nil se só tem 1 msg
# (pra dispatch usar message.content normal). # (pra dispatch usar message.content normal).