iachat/enterprise/app/services/hermes_builder/validator.rb
Rodribm10 28e880d7b6 feat(captain/hermes-builder): aba Verificação com 22+ checks + reparo automático
UI nova dentro do Construtor (Hermes) — TabBar com Chat e Verificação.
Verificação roda HermesBuilder::Validator (DB+runtime) e exibe resultado
agrupado por categoria, com botão Refazer inline em FAIL/WARN reparáveis.

Backend (porta dos checks DB do CLI bin/hermes-validate):
- HermesBuilder::Validator com 22+ checks: engine, profile, port,
  secret, parent, unit, Brand, CaptainInbox sync (o bug que travou
  Juliana), pricing dry-run, Inter creds, typing/response_delay,
  registry MCP completo.
- HermesBuilder::Repairer com 4 handlers automáticos: set_engine_hermes,
  sync_captain_inbox_unit, set_default_typing_delay,
  set_default_response_delay.
- Endpoints novos: GET assistants, GET validate?slug=, POST repair.

Frontend:
- builder/Index.vue: wrapper com TabBar.
- builder/BuilderChat.vue: extraído do Index original.
- builder/BuilderVerification.vue: dropdown + Conferir agora + lista
  agrupada por categoria com badges + botão Refazer inline.

i18n: keys em pt_BR e en sob CAPTAIN_HERMES_BUILDER.VERIFY.*.

Filesystem/systemd checks ficam pro CLI hermes-validate (Rails container
não enxerga /root/.hermes/profiles do host).

Validado HTTP: GET /validate?slug=juliana_qnn1 → 28 PASS / 0 FAIL / 1 WARN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:27:40 -03:00

200 lines
7.3 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
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