feat(captain/mcp): tools read-only pro Skill Construtor

4 tools novas (admin scope, read-only) usadas pelo profile Hermes
"construtor" durante criacao guiada de novos agentes:

- list_assistants: lista todos os assistants da conta com badge engine
  (Captain interno vs Hermes), counts de scenarios/FAQs, marca/unidade
- get_assistant_pricing: retorna tabela de preços parsed (Captain::Mcp::
  PricingTables se Hermes, scenario fallback se Captain interno)
- get_assistant_faqs: retorna FAQs aprovados em markdown (até 50)
- save_agent_spec: salva JSON da especificacao em /tmp/agent-specs/<slug>.json
  (NÃO cria filesystem do profile nem registros DB — só persiste spec
  pra revisao/provisionamento separado depois)

Construtor coleta perguntas socraticas, oferece "copiar de existente" pra
acelerar quando marca for igual (Prime VL/AL/ADE preços iguais), e ao
final salva spec.json pra admin revisar antes de provisionar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-01 20:24:02 -03:00
parent 48fad2977b
commit d35084334c
5 changed files with 315 additions and 1 deletions

View File

@ -17,7 +17,12 @@ class Captain::Mcp::ToolRegistry
Captain::Mcp::Tools::CheckPixPaymentTool,
Captain::Mcp::Tools::SendSuiteImagesTool,
Captain::Mcp::Tools::RescheduleReservationTool,
Captain::Mcp::Tools::ReactToMessageTool
Captain::Mcp::Tools::ReactToMessageTool,
# Construtor (admin scope) — usadas pelo profile Hermes "construtor" pra criar novos agentes
Captain::Mcp::Tools::ListAssistantsTool,
Captain::Mcp::Tools::GetAssistantPricingTool,
Captain::Mcp::Tools::GetAssistantFaqsTool,
Captain::Mcp::Tools::SaveAgentSpecTool
# Captain::Mcp::Tools::HandoffTool — fluxo via automation hoje, MCP futuro
].freeze

View File

@ -0,0 +1,57 @@
# Tool MCP: retorna FAQs aprovadas de um assistente existente.
#
# Caso de uso: Construtor pergunta "copiar FAQs de outro assistente?".
# Tool retorna lista markdown de Q&A pra usuário avaliar e (próxima
# tool) salvar no spec do novo agente.
class Captain::Mcp::Tools::GetAssistantFaqsTool < Captain::Mcp::Tools::BaseTool
MAX_FAQS = 50
class << self
def name
'get_assistant_faqs'
end
def description
'Retorna FAQs (perguntas/respostas) aprovadas de um assistente existente. ' \
'Use quando o usuário decidir copiar FAQs de outro assistente durante ' \
'criação. Retorna até 50 FAQs em markdown.'
end
def input_schema
{
type: 'object',
properties: {
assistant_id: {
type: 'integer',
description: 'ID do assistente fonte (pegue via list_assistants).'
}
},
required: ['assistant_id']
}
end
end
def call(args, _context:) # rubocop:disable Metrics/AbcSize
assistant = Captain::Assistant.find_by(id: args['assistant_id'])
return error_response("Assistente #{args['assistant_id']} não encontrado.") if assistant.blank?
faqs = assistant.responses
.where(documentable_type: nil)
.where(status: 'approved')
.order(:id)
.limit(MAX_FAQS)
return text_response("_(#{assistant.name} não tem FAQs aprovados)_") if faqs.empty?
lines = ["# FAQs de #{assistant.name} (#{faqs.size})", '']
faqs.each_with_index do |f, i|
lines << "**#{i + 1}. #{f.question}**"
lines << f.answer.to_s.gsub(/\s+/, ' ').strip
lines << ''
end
text_response(lines.join("\n"))
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GetAssistantFaqsTool] error: #{e.class}: #{e.message}")
error_response("Erro ao buscar FAQs: #{e.message}")
end
end

View File

@ -0,0 +1,99 @@
# Tool MCP: retorna tabela de preços de um assistente existente.
#
# Caso de uso: Construtor copia tabela durante criação de novo agente
# (mesma marca → mesma tabela).
#
# Estratégia de leitura (em ordem de tentativa):
# 1. Se assistant tem unit vinculada e Captain::Mcp::PricingTables
# conhece essa unit → retorna tabela estruturada (Hermes-friendly)
# 2. Senão tenta extrair markdown de scenarios do assistant (caminho
# legado — Captain interno usa scenarios pra guardar tabela)
# 3. Senão retorna mensagem de "não encontrado"
class Captain::Mcp::Tools::GetAssistantPricingTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'get_assistant_pricing'
end
def description
'Retorna a tabela de preços de um assistente existente em markdown. ' \
'Use quando o usuário (na criação de novo agente) decidir copiar ' \
'a tabela de outro assistente. Retorna estrutura categórias × períodos ' \
'com regras de pessoa extra.'
end
def input_schema
{
type: 'object',
properties: {
assistant_id: {
type: 'integer',
description: 'ID do assistente fonte. Pegue via list_assistants.'
}
},
required: ['assistant_id']
}
end
end
def call(args, _context:)
assistant = Captain::Assistant.find_by(id: args['assistant_id'])
return error_response("Assistente #{args['assistant_id']} não encontrado.") if assistant.blank?
text_response(extract_pricing_markdown(assistant))
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GetAssistantPricingTool] error: #{e.class}: #{e.message}")
error_response("Erro ao buscar tabela de preços: #{e.message}")
end
private
def extract_pricing_markdown(assistant)
structured = structured_pricing_for(assistant)
return structured if structured.present?
scenario = pricing_scenario_for(assistant)
return scenario if scenario.present?
"_(assistente #{assistant.name} não tem tabela de preços estruturada nem em scenario)_"
end
# Lookup em Captain::Mcp::PricingTables (Hermes-side hardcoded).
def structured_pricing_for(assistant)
inbox = CaptainInbox.find_by(captain_assistant_id: assistant.id)
return nil if inbox.blank?
unit = Captain::Unit.find_by(inbox_id: inbox.inbox_id)
return nil if unit.blank?
table = Captain::Mcp::PricingTables::TABLES[unit.id]
return nil if table.blank?
format_structured_table(unit, table)
end
def format_structured_table(unit, table)
lines = ["# Tabela de preços — #{unit.name}", '']
lines << '| Categoria | 3h | Pernoite Promo | Pernoite Integral | Diária | Pessoa extra a partir |'
lines << '|---|---|---|---|---|---|'
table[:categories].each do |key, data|
prices = data[:prices]
lines << "| #{key.tr('_', ' ').capitalize} | #{prices['3h']} | #{prices['pernoite_promo']} | " \
"#{prices['pernoite_integral']} | #{prices['diaria']} | #{data[:extra_person_starts_at]}ª pessoa |"
end
lines << ''
lines << "**Taxa pessoa extra:** R$ #{table[:extra_person_fee]}"
lines.join("\n")
end
# Captain interno guarda a tabela no instruction de algum scenario
# (geralmente o de reservas/preços). Retorna o markdown bruto pra
# usuário copiar ou Construtor parsear.
def pricing_scenario_for(assistant)
candidate = assistant.scenarios.where('LOWER(title) ~ ?', '(preç|tabela|reserva|valor)').first ||
assistant.scenarios.first
return nil if candidate.blank?
"# Scenario fonte — #{candidate.title}\n\n#{candidate.instruction}"
end
end

View File

@ -0,0 +1,82 @@
# Tool MCP: lista assistentes existentes pro agente Construtor consultar.
#
# Caso de uso: durante criação de novo agente Hermes via skill socrática,
# o Construtor pergunta "quer copiar tabela de preços de outro assistente?".
# Esta tool retorna todos os assistentes cadastrados com metadata útil pra
# escolha (nome, marca, engine, scenarios count, FAQs count).
#
# Read-only. Scope: account_id obrigatório (vem do header X-Captain-Account-Id
# ou body context).
class Captain::Mcp::Tools::ListAssistantsTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'list_assistants'
end
def description
'Lista todos os assistentes existentes da conta. Use durante criação ' \
'de novo agente pra oferecer "copiar tabela/regras/FAQs de outro ' \
'assistente". Retorna nome, id, marca/unidade, engine (interno ou hermes), ' \
'qtd de scenarios e FAQs pra cada um.'
end
def input_schema
{
type: 'object',
properties: {
account_id: {
type: 'integer',
description: 'ID da conta. Default: account_id do contexto MCP.'
}
}
}
end
end
def call(args, context:)
account_id = args['account_id'].presence || context[:account_id]
return error_response('account_id obrigatório.') if account_id.blank?
account = Account.find_by(id: account_id)
return error_response("Account #{account_id} não encontrada.") if account.blank?
rows = account.captain_assistants.order(:id).map { |a| describe(a) }
text_response(format_markdown(rows))
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::ListAssistantsTool] error: #{e.class}: #{e.message}")
error_response("Erro ao listar assistentes: #{e.message}")
end
private
def describe(assistant)
inboxes = CaptainInbox.where(captain_assistant_id: assistant.id).filter_map(&:inbox)
units = inboxes.filter_map { |i| Captain::Unit.find_by(inbox_id: i.id) }
{
id: assistant.id,
name: assistant.name,
engine: assistant.config.to_h['engine_type'].presence || 'internal',
scenarios: assistant.scenarios.count,
faqs: assistant.responses.count,
inboxes: inboxes.map(&:name),
units: units.map(&:name),
brand: units.first&.brand&.name
}
end
def format_markdown(rows)
return '_(nenhum assistente cadastrado nesta conta)_' if rows.empty?
lines = ['# Assistentes da conta', '']
rows.each do |r|
engine_badge = r[:engine] == 'hermes' ? '⚡ Hermes' : '🧠 Captain interno'
lines << "## ##{r[:id]}#{r[:name]} · #{engine_badge}"
lines << "- Marca: #{r[:brand].presence || '_não vinculada_'}"
lines << "- Inbox(es): #{r[:inboxes].join(', ').presence || '_nenhuma_'}"
lines << "- Unidade(s): #{r[:units].join(', ').presence || '_nenhuma_'}"
lines << "- Scenarios: #{r[:scenarios]} · FAQs: #{r[:faqs]}"
lines << ''
end
lines.join("\n")
end
end

View File

@ -0,0 +1,71 @@
# Tool MCP: salva especificação completa de um novo agente Hermes em JSON.
#
# Caso de uso: ao final do fluxo socrático do Construtor, ele junta tudo
# que coletou (persona, marca, tabela, regras, FAQs, identidade da unidade)
# num JSON e chama esta tool pra persistir em /tmp/agent-specs/<slug>.json.
#
# **NÃO cria filesystem do profile Hermes** nem registros no Captain DB.
# Apenas salva a especificação. Provisionamento real é etapa SEPARADA
# (próxima sessão) — Construtor só coleta e prepara o JSON.
#
# Útil pra UI Captain depois listar specs prontas e o admin clicar
# "provisionar" — ou pra revisor humano validar antes de criar.
class Captain::Mcp::Tools::SaveAgentSpecTool < Captain::Mcp::Tools::BaseTool
SPEC_DIR = '/tmp/agent-specs'.freeze
class << self
def name
'save_agent_spec'
end
def description
'Salva especificação completa de um novo agente Hermes em JSON. Use ao ' \
'final do fluxo socrático quando tiver coletado TUDO: name, persona, ' \
'brand, unit, pricing, rules, faqs, identity. Não cria o agente — só ' \
'salva o spec pra revisão/provisionamento separado depois.'
end
def input_schema
{
type: 'object',
properties: {
slug: {
type: 'string',
description: 'Slug único pro agente (ex: "jasmine_prime_al"). Lowercase, snake_case. Vai ser nome do arquivo.'
},
spec: {
type: 'object',
description: 'JSON completo da especificação — persona, marca, unidade, tabela, regras, FAQs, identidade. ' \
'Estrutura livre, mas inclua todos os campos que coletou no fluxo.'
}
},
required: %w[slug spec]
}
end
end
def call(args, _context:) # rubocop:disable Metrics/AbcSize
slug = args['slug'].to_s.strip.downcase.gsub(/[^a-z0-9_]/, '_').squeeze('_')
return error_response('slug inválido (use lowercase, snake_case, só letras/números/underscore).') if slug.blank? || slug.length < 3
spec = args['spec']
return error_response('spec deve ser um objeto JSON.') unless spec.is_a?(Hash)
FileUtils.mkdir_p(SPEC_DIR)
path = File.join(SPEC_DIR, "#{slug}.json")
enriched = spec.merge(
'slug' => slug,
'saved_at' => Time.current.iso8601,
'saved_by_tool' => 'mcp_save_agent_spec'
)
File.write(path, JSON.pretty_generate(enriched))
text_response(
"Spec do agente '#{slug}' salvo em #{path}. " \
'Próximo passo (separado): admin revisa o JSON e dispara provisionamento real.'
)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::SaveAgentSpecTool] error: #{e.class}: #{e.message}")
error_response("Erro ao salvar spec: #{e.message}")
end
end