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:
parent
30fc2460bb
commit
cc58805722
@ -51,6 +51,15 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController # rubo
|
||||
# "endereço completo ou link?" → "apenas link ou link + endereço?").
|
||||
LOOP_SIMILARITY_THRESHOLD = 0.50
|
||||
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[
|
||||
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
|
||||
@ -80,6 +89,10 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController # rubo
|
||||
# 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)
|
||||
|
||||
detect_handoff_or_loop(conversation, content)
|
||||
deliver_outgoing(conversation, content)
|
||||
head :ok
|
||||
@ -231,6 +244,48 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController # rubo
|
||||
Rails.logger.info("[Hermes::Callback] conv #{conversation.display_id} → triagem_humana (#{reason})")
|
||||
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
|
||||
inbox_id = params[:inbox_id].presence || params.dig(:metadata, :inbox_id).presence
|
||||
if inbox_id.present?
|
||||
|
||||
@ -170,6 +170,19 @@ class HermesBuilder::Validator
|
||||
EXPECTED_MCP_TOOLS.each do |t|
|
||||
add("MCP tool '#{t}' registrado", registered.include?(t) ? 'PASS' : 'FAIL', nil, category: 'mcp')
|
||||
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
|
||||
|
||||
def mcp_tool_names
|
||||
|
||||
Loading…
Reference in New Issue
Block a user