iachat/enterprise/app/services/hermes_builder/validator.rb
Rodribm10 cc58805722 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>
2026-05-02 18:16:39 -03:00

213 lines
7.9 KiB
Ruby

# Validação de saúde de um agente Hermes — porta dos checks DB/runtime do
# script CLI `bin/hermes-validate` pro contexto Rails (consumido pela aba
# "Verificação" no Construtor UI).
#
# Cobre: configuração do Captain::Assistant, mapeamento CaptainInbox/Unit,
# pricing dry-run, credenciais Inter, registry MCP, humanização. NÃO cobre
# filesystem (SOUL.md/SKILL.md/config.yaml) nem systemd — esses ficam pro
# CLI (rodar no shell da VPS) porque o container Rails não tem visibilidade
# do `/root/.hermes/profiles/` do host.
#
# Cada check tem `repair_id` opcional — quando setado, indica que existe
# handler em HermesBuilder::Repairer pra ressincronizar/corrigir o estado
# (ex: sync_captain_inbox_unit, set_engine_hermes, set_default_typing).
class HermesBuilder::Validator
EXPECTED_MCP_TOOLS = %w[
generate_pix faq_lookup add_label send_suite_images react_to_message
update_contact get_contact_history check_pix_payment reschedule_reservation
].freeze
Result = Struct.new(:label, :status, :detail, :repair_id, :category, keyword_init: true) do
def to_h
{ label: label, status: status, detail: detail.to_s, repair_id: repair_id, category: category }
end
end
def self.run(slug)
new(slug).call
end
def initialize(slug)
@slug = slug.to_s.strip
@results = []
end
def call
asst = ::Captain::Assistant.find_by(hermes_profile_name: @slug, engine: 'hermes')
if asst.nil?
add('Captain::Assistant existe', 'FAIL', "Nenhum assistant com hermes_profile_name='#{@slug}'", category: 'db')
return summary
end
@asst = asst
@ci = ::CaptainInbox.where(captain_assistant_id: asst.id).first
@inbox = @ci&.inbox
@unit = asst.captain_unit
check_db
check_pricing
check_routing
check_humanization
check_mcp_tools
summary
end
private
def check_db
check_db_engine
check_db_endpoint
check_db_unit_consistency
end
def check_db_engine
add('engine=hermes', pf(@asst.engine == 'hermes'), @asst.engine,
repair_id: 'set_engine_hermes', category: 'db')
add('hermes_profile_name setado', pf(@asst.hermes_profile_name.present?),
@asst.hermes_profile_name, category: 'db')
add('parent_assistant_id setado', pw(@asst.parent_assistant_id.present?),
"parent=#{@asst.parent_assistant_id}", category: 'db')
end
def check_db_endpoint
add('hermes_port setado', pf(@asst.hermes_port.present?),
"port=#{@asst.hermes_port}", category: 'db')
add('hermes_subscription_secret setado', pf(@asst.hermes_subscription_secret.present?),
@asst.hermes_subscription_secret.present? ? 'presente' : 'vazio', category: 'db')
add('hermes_webhook_base_url', pf(@asst.hermes_webhook_base_url.to_s.start_with?('http')),
@asst.hermes_webhook_base_url, category: 'db')
end
def pf(bool) = bool ? 'PASS' : 'FAIL'
def pw(bool) = bool ? 'PASS' : 'WARN'
def check_db_unit_consistency
add('captain_unit_id setado', @asst.captain_unit_id.present? ? 'PASS' : 'FAIL',
@unit&.name, category: 'db')
ci_unit = @ci&.captain_unit_id
sync_ok = @asst.captain_unit_id.present? && ci_unit == @asst.captain_unit_id
add('CaptainInbox.unit == Assistant.unit', sync_ok ? 'PASS' : 'FAIL',
"asst=#{@asst.captain_unit_id} ci=#{ci_unit}",
repair_id: sync_ok ? nil : 'sync_captain_inbox_unit', category: 'db')
add('Brand resolvida', @unit&.brand.present? ? 'PASS' : 'FAIL', @unit&.brand&.name, category: 'db')
add('CaptainInbox mapeada', @inbox.present? ? 'PASS' : 'WARN', "inbox=#{@inbox&.id}", category: 'db')
end
def check_pricing
cats = (@unit&.pricing_categories&.includes(:amounts)&.to_a) || []
add('Pricing categorias > 0', cats.any? ? 'PASS' : 'FAIL',
"#{cats.size} cats: #{cats.map(&:key).join(',')}", category: 'pricing')
add('Pricing amounts > 0', cats.flat_map { |c| c.amounts.to_a }.size.positive? ? 'PASS' : 'FAIL',
"#{cats.flat_map { |c| c.amounts.to_a }.size} amounts", category: 'pricing')
check_pricing_dry_run(cats)
check_inter_creds
end
def check_pricing_dry_run(cats)
return if cats.empty?
first_cat = cats.first.key
res = ::Captain::Mcp::PricingTables.calculate(
unit_id: @unit.id, suite_category: first_cat,
period: 'pernoite_promo', total_guests: 2
)
if res[:error]
add('Pricing dry-run', 'FAIL', "ERR: #{res[:error]}", category: 'pricing')
else
add('Pricing dry-run', 'PASS', "OK R$ #{res[:amount]} (#{first_cat}/pernoite)", category: 'pricing')
end
end
def check_inter_creds
inter_ok = @unit.present? && @unit.respond_to?(:inter_credentials_present?) && @unit.inter_credentials_present?
add('Credenciais Inter completas', pw(inter_ok),
inter_ok ? 'cert+key+client_id presentes' : 'faltam — generate_pix cai no fallback',
category: 'pricing')
end
def check_routing
if @inbox.blank?
add('Captain::Hermes.enabled_for?', 'WARN', 'sem inbox mapeada', category: 'routing')
return
end
enabled = ::Captain::Hermes.enabled_for?(@inbox)
add('Captain::Hermes.enabled_for?', enabled ? 'PASS' : 'FAIL', enabled.to_s, category: 'routing')
url = ::Captain::Hermes.webhook_url_for(@inbox).to_s
expected_suffix = "/webhooks/captain-inbox-#{@slug}"
add('webhook_url aponta pra slug', url.end_with?(expected_suffix) ? 'PASS' : 'FAIL',
url, category: 'routing')
secret = ::Captain::Hermes.subscription_signing_secret(@inbox).to_s
add('subscription_signing_secret presente', secret.present? ? 'PASS' : 'FAIL',
secret.present? ? "#{secret.first(8)}..." : 'vazio', category: 'routing')
end
def check_humanization
typing_delay = @inbox.respond_to?(:typing_delay) ? @inbox.typing_delay.to_i : 0
add('Inbox.typing_delay > 0 (debounce)', typing_delay.positive? ? 'PASS' : 'WARN',
"#{typing_delay}s",
repair_id: typing_delay.positive? ? nil : 'set_default_typing_delay',
category: 'humanization')
mode = @asst.config.to_h.dig('response_delay', 'mode')
add('config.response_delay (typing simulation)', mode == 'typing_simulation' ? 'PASS' : 'WARN',
mode || 'none',
repair_id: mode == 'typing_simulation' ? nil : 'set_default_response_delay',
category: 'humanization')
gallery_count = @unit&.gallery_items&.count || 0
add('GalleryItem para fotos', gallery_count.positive? ? 'PASS' : 'WARN',
"#{gallery_count} items (send_suite_images precisa)", category: 'humanization')
end
def check_mcp_tools
registered = mcp_tool_names
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
::Captain::Mcp::ToolRegistry::TOOLS.map(&:name)
rescue StandardError
[]
end
def add(label, status, detail = nil, repair_id: nil, category: nil)
@results << Result.new(label: label, status: status, detail: detail, repair_id: repair_id, category: category)
end
def summary
pass = @results.count { |r| r.status == 'PASS' }
fail_n = @results.count { |r| r.status == 'FAIL' }
warn = @results.count { |r| r.status == 'WARN' }
{
slug: @slug,
ok: fail_n.zero?,
total: @results.size,
pass: pass,
fail: fail_n,
warn: warn,
results: @results.map(&:to_h)
}
end
end