From cc58805722c3a95284028ce929f693df9b5c713a Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Sat, 2 May 2026 18:16:39 -0300 Subject: [PATCH] =?UTF-8?q?feat(captain/hermes):=20camada=203=20=E2=80=94?= =?UTF-8?q?=20strip=20de=20linhas=20repetidas=20+=20check=20FAQs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../captain/hermes_callback_controller.rb | 55 +++++++++++++++++++ .../app/services/hermes_builder/validator.rb | 13 +++++ 2 files changed, 68 insertions(+) diff --git a/app/controllers/webhooks/captain/hermes_callback_controller.rb b/app/controllers/webhooks/captain/hermes_callback_controller.rb index 770b6dc5a..4c5b755ee 100644 --- a/app/controllers/webhooks/captain/hermes_callback_controller.rb +++ b/app/controllers/webhooks/captain/hermes_callback_controller.rb @@ -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? diff --git a/enterprise/app/services/hermes_builder/validator.rb b/enterprise/app/services/hermes_builder/validator.rb index 286aa3d51..9354508ed 100644 --- a/enterprise/app/services/hermes_builder/validator.rb +++ b/enterprise/app/services/hermes_builder/validator.rb @@ -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