-
-
-
-
-
{{ t('CAPTAIN_HERMES_BUILDER.EMPTY_STATE') }}
-
-
-
-
-
{{ msg.content }}
-
{{ formatTime(msg.created_at) }}
-
-
-
-
-
-
-
-
- {{ t('CAPTAIN_HERMES_BUILDER.SESSION_LABEL') }} {{ sessionId }}
-
+
+
+
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index c8e0a556a..2ae7d592c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -62,6 +62,9 @@ Rails.application.routes.draw do
collection do
post :start
delete :reset
+ get :assistants
+ get :validate
+ post :repair
end
end
resource :preferences, only: [:show, :update]
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/hermes_builder_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/hermes_builder_controller.rb
index 25c2e5ba1..1b8368aaf 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/hermes_builder_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/hermes_builder_controller.rb
@@ -51,6 +51,33 @@ class Api::V1::Accounts::Captain::HermesBuilderController < Api::V1::Accounts::B
render json: { ok: true }
end
+ # Lista assistentes Hermes da conta atual pra dropdown da aba Verificação.
+ def assistants
+ rows = ::Captain::Assistant.where(account_id: Current.account.id, engine: 'hermes')
+ .order(:name)
+ .pluck(:id, :name, :hermes_profile_name)
+ .map { |id, name, slug| { id: id, name: name, slug: slug } }
+ render json: { assistants: rows }
+ end
+
+ # Roda o validator (porta dos checks DB do CLI hermes-validate).
+ def validate
+ slug = params[:slug].to_s.strip
+ return render json: { error: 'slug required' }, status: :bad_request if slug.blank?
+
+ render json: HermesBuilder::Validator.run(slug)
+ end
+
+ # Aplica reparo automatizado pra um check FAIL/WARN específico.
+ def repair
+ slug = params[:slug].to_s.strip
+ repair_id = params[:repair_id].to_s.strip
+ return render json: { ok: false, error: 'slug e repair_id required' }, status: :bad_request if slug.blank? || repair_id.blank?
+
+ result = HermesBuilder::Repairer.repair(slug: slug, repair_id: repair_id)
+ render json: result, status: result[:ok] ? :ok : :unprocessable_entity
+ end
+
private
def authorize_admin
diff --git a/enterprise/app/services/hermes_builder/repairer.rb b/enterprise/app/services/hermes_builder/repairer.rb
new file mode 100644
index 000000000..0e6340f62
--- /dev/null
+++ b/enterprise/app/services/hermes_builder/repairer.rb
@@ -0,0 +1,87 @@
+# Reparos automatizados pra checks FAIL/WARN identificados pelo Validator.
+#
+# Cada `repair_id` presente em Validator::Result mapeia pra um handler aqui.
+# Reparos cobrem só estado em DB (Captain::Assistant, CaptainInbox, Inbox,
+# config). Reparos de filesystem/systemd ficam pro CLI hermes-provision na
+# VPS — UI mostra mensagem orientadora pro admin.
+class HermesBuilder::Repairer
+ REPAIR_HANDLERS = %w[
+ set_engine_hermes
+ sync_captain_inbox_unit
+ set_default_typing_delay
+ set_default_response_delay
+ ].freeze
+
+ def self.repair(slug:, repair_id:)
+ asst = ::Captain::Assistant.find_by(hermes_profile_name: slug, engine: 'hermes')
+ return failure("Assistant '#{slug}' não encontrado") if asst.nil?
+ return failure("Reparo '#{repair_id}' não suportado pela UI. Rode hermes-provision na VPS.") unless REPAIR_HANDLERS.include?(repair_id)
+
+ send("repair_#{repair_id}", asst)
+ rescue StandardError => e
+ Rails.logger.error("[HermesBuilder::Repairer] error: #{e.class}: #{e.message}")
+ failure("Erro: #{e.class}: #{e.message}")
+ end
+
+ class << self
+ private
+
+ # rubocop:disable Rails/SkipsModelValidations
+ def repair_set_engine_hermes(asst)
+ asst.update_columns(engine: 'hermes')
+ success("Engine setado pra 'hermes' no assistant #{asst.id}")
+ end
+ # rubocop:enable Rails/SkipsModelValidations
+
+ # Resincroniza CaptainInbox.captain_unit_id com Assistant.captain_unit_id.
+ # Esse foi o bug raiz que travou a Juliana — CaptainInbox apontava pra
+ # unit antiga (Dolce Amore) enquanto Assistant.captain_unit_id era a
+ # nova (Qnn01). Resolve_unit usa CaptainInbox como segundo nível e
+ # vazava unit errada quando o context['assistant_id'] não chegava.
+ # rubocop:disable Rails/SkipsModelValidations
+ def repair_sync_captain_inbox_unit(asst)
+ return failure('Assistant sem captain_unit_id setado — corrija primeiro pelo cadastro') if asst.captain_unit_id.blank?
+
+ ci = ::CaptainInbox.where(captain_assistant_id: asst.id).first
+ return failure('Sem CaptainInbox pra esse assistant — vincule no painel de inboxes primeiro') if ci.nil?
+
+ old = ci.captain_unit_id
+ ci.update_columns(captain_unit_id: asst.captain_unit_id)
+
+ ::Captain::Unit.where(inbox_id: ci.inbox_id).where.not(id: asst.captain_unit_id).update_all(inbox_id: nil)
+ ::Captain::Unit.where(id: asst.captain_unit_id).update_all(inbox_id: ci.inbox_id)
+
+ success("CaptainInbox.unit_id ressincronizado: #{old} → #{asst.captain_unit_id}")
+ end
+ # rubocop:enable Rails/SkipsModelValidations
+
+ def repair_set_default_typing_delay(asst)
+ ci = ::CaptainInbox.where(captain_assistant_id: asst.id).first
+ return failure('Sem inbox vinculada') if ci&.inbox.nil?
+
+ ci.inbox.update!(typing_delay: 5)
+ success("Inbox #{ci.inbox.id} typing_delay setado pra 5s")
+ end
+
+ def repair_set_default_response_delay(asst)
+ cfg = asst.config.to_h.merge(
+ 'response_delay' => {
+ 'mode' => 'typing_simulation',
+ 'chars_per_second' => 25,
+ 'min_seconds' => 1.5,
+ 'max_seconds' => 6.0
+ }
+ )
+ asst.update!(config: cfg)
+ success('Humanização typing_simulation ativada (default: 25 cps, 1.5s..6s)')
+ end
+
+ def success(msg)
+ { ok: true, message: msg }
+ end
+
+ def failure(msg)
+ { ok: false, error: msg }
+ end
+ end
+end
diff --git a/enterprise/app/services/hermes_builder/validator.rb b/enterprise/app/services/hermes_builder/validator.rb
new file mode 100644
index 000000000..286aa3d51
--- /dev/null
+++ b/enterprise/app/services/hermes_builder/validator.rb
@@ -0,0 +1,199 @@
+# 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