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:
parent
48fad2977b
commit
d35084334c
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user