feat(captain/hermes): camada 2 — gating de saída factual sem tool call
Detecta alucinação de memória: se resposta do Hermes contém info factual (preço/senha/horário/regra/política) E o LLM NÃO chamou nenhuma tool MCP entre dispatch e callback, bloqueia entrega + dispara system_message forçando consulta a tool. 1 retry; persistindo, escala. Implementação: - McpController: incrementa Rails.cache hermes_tool_calls:<conv_id> em cada tools/call. - OutgoingJob: snapshot do contador como hermes_tool_calls_baseline ANTES de despachar pro Hermes. - HermesCallbackController.gate_factual_no_tool!: compara baseline vs current; se igual + FACTUAL_PATTERNS bate, intercepta. Patterns cobrem R$, %, "senha", check-in/out + horário, política de cancelamento, "permitido", "pode levar pet/animal". Caso real: cliente pede senha do Wi-Fi → Hermes responde de cabeça "é passada presencialmente" sem chamar faq_lookup → callback intercepta, não entrega pro cliente, manda [SISTEMA: force_factual_tool] pro Hermes com instrução de chamar faq_lookup. Se faq_lookup vier vazio → frase- âncora handoff. Auto-react ambient: removido filtro de "?" que barrava em prod. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9f2a08f478
commit
ed99f67525
@ -13,7 +13,7 @@
|
||||
# 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
|
||||
# de teste por vez. Pra produção, melhorar com Redis: delivery_id → conversation_id.
|
||||
class Webhooks::Captain::HermesCallbackController < ApplicationController
|
||||
class Webhooks::Captain::HermesCallbackController < ApplicationController # rubocop:disable Metrics/ClassLength
|
||||
RECENT_WINDOW = 5.minutes
|
||||
|
||||
# "Um momento — vou verificar" é a frase-âncora de handoff intencional
|
||||
@ -26,6 +26,24 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController
|
||||
/\A\s*aguarde\s+um\s+instante/i
|
||||
].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.
|
||||
# 1. Jaccard de tokens >= 0.50 → resposta praticamente igual.
|
||||
# 2. >= 3 palavras-chave em comum (sem stopwords) E ambas terminam com
|
||||
@ -56,6 +74,12 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController
|
||||
return log_no_conversation_and_ack if conversation.blank?
|
||||
|
||||
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)
|
||||
|
||||
detect_handoff_or_loop(conversation, content)
|
||||
deliver_outgoing(conversation, content)
|
||||
head :ok
|
||||
@ -93,6 +117,62 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController
|
||||
HANDOFF_PATTERNS.any? { |re| content.match?(re) }
|
||||
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
|
||||
# outgoing dele na mesma conv (Jaccard de tokens >= 0.70). Sinaliza que o
|
||||
# agente está repetindo pergunta/resposta sem progredir — geralmente
|
||||
|
||||
@ -33,6 +33,7 @@ class Webhooks::Captain::McpController < ApplicationController
|
||||
context: extract_context(request_body)
|
||||
)
|
||||
|
||||
track_tool_call!(request_body)
|
||||
return head :ok if response.nil? # MCP notifications
|
||||
|
||||
render json: response
|
||||
@ -107,4 +108,18 @@ class Webhooks::Captain::McpController < ApplicationController
|
||||
|
||||
value.to_i
|
||||
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
|
||||
|
||||
@ -35,6 +35,8 @@ class Captain::Hermes::OutgoingJob < ApplicationJob
|
||||
# texto agrupado pra Hermes ver o pensamento completo do cliente.
|
||||
combined = combined_incoming_content(conversation, message)
|
||||
|
||||
snapshot_tool_call_baseline(conversation)
|
||||
|
||||
Captain::Hermes::Client.new(conversation.inbox).dispatch(
|
||||
message: message, conversation: conversation, content_override: combined
|
||||
)
|
||||
@ -42,6 +44,14 @@ class Captain::Hermes::OutgoingJob < ApplicationJob
|
||||
|
||||
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
|
||||
# (não-reaction) do agente e a msg âncora. Retorna nil se só tem 1 msg
|
||||
# (pra dispatch usar message.content normal).
|
||||
|
||||
@ -88,12 +88,13 @@ class Captain::Hermes::AutoReactService
|
||||
end
|
||||
|
||||
# Mensagens "neutras" elegíveis pra reação ambiente: nem curtas demais
|
||||
# (provavelmente saudação que já pega regex), nem longas (geralmente
|
||||
# narrativa que pede atenção), sem ?, sem termos de fluxo de reserva
|
||||
# (preço/cpf/data — cliente está esperando ação, não emoji).
|
||||
# (provavelmente saudação que já pega regex), nem longas (narrativa
|
||||
# pede atenção), sem termos de fluxo de reserva crítico (preço/cpf/data
|
||||
# — cliente está esperando ação, não emoji). AS perguntas comuns
|
||||
# (com "?") TAMBÉM elegíveis: WhatsApp de motel é majoritariamente
|
||||
# interrogativo; se filtrar "?" o ambient nunca dispara em prod.
|
||||
def ambient_eligible?(text)
|
||||
return false if text.length < 6 || text.length > 180
|
||||
return false if text.include?('?')
|
||||
return false if text.match?(AMBIENT_RESERVATION_KEYWORDS)
|
||||
return false if text.match?(/\A\d/)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user