feat(captain/hermes): camada 3 — strip de linhas repetidas + check FAQs

LLM tende a "resumir" info de turns anteriores em toda nova resposta.
Camada 3 strip linhas onde >=70% das palavras significativas já apareceram
nas últimas 3 outgoings (filtra reactions). Saudações curtas preservadas.

Caso real Juliana 2026-05-02 (turn 3 ela ia repetir "Senha Prime2025 +
pet" mesmo cliente só dizendo "valeu"). Após strip: vira só "Imagina,
Rodrigo 😊".

Validator UI: novo check "FAQs próprias aprovadas > 0" — alerta quando
zero (faq_lookup cai no parent, risco de info desatualizada igual ao
bug do X-Captain-Assistant-Id que vazou Wi-Fi do parent hoje cedo).

Filtro SQL `content_attributes ->> 'external_source'` não casava (coluna
json, não jsonb); migrado pra filtro Ruby.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-02 18:16:39 -03:00
parent 30fc2460bb
commit cc58805722
2 changed files with 68 additions and 0 deletions

View File

@ -51,6 +51,15 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController # rubo
# "endereço completo ou link?" → "apenas link ou link + endereço?"). # "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
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
@ -80,6 +89,10 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController # rubo
# call. NÃO entrega a msg pro cliente até o LLM consultar a fonte. # call. NÃO entrega a msg pro cliente até o LLM consultar a fonte.
return head :ok if gate_factual_no_tool!(conversation, content) 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)
detect_handoff_or_loop(conversation, content) detect_handoff_or_loop(conversation, content)
deliver_outgoing(conversation, content) deliver_outgoing(conversation, content)
head :ok head :ok
@ -231,6 +244,48 @@ 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
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?

View File

@ -170,6 +170,19 @@ class HermesBuilder::Validator
EXPECTED_MCP_TOOLS.each do |t| EXPECTED_MCP_TOOLS.each do |t|
add("MCP tool '#{t}' registrado", registered.include?(t) ? 'PASS' : 'FAIL', nil, category: 'mcp') add("MCP tool '#{t}' registrado", registered.include?(t) ? 'PASS' : 'FAIL', nil, category: 'mcp')
end end
check_own_faqs
end
# FAQs aprovadas vinculadas ao próprio assistant (não ao parent). Se zero,
# toda chamada faq_lookup vai cair no parent — vazou senha errada do
# Wi-Fi em 2026-05-02 porque parent.id=1 tinha FAQ "presencial" e a
# senha nova só estava cadastrada no próprio Hermes.id=10.
def check_own_faqs
count = ::Captain::AssistantResponse.where(assistant_id: @asst.id, status: :approved).count
add('FAQs próprias aprovadas > 0', count.positive? ? 'PASS' : 'WARN',
"#{count} FAQs (zero significa que faq_lookup busca dados do parent — risco de info desatualizada)",
category: 'mcp')
end end
def mcp_tool_names def mcp_tool_names