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::CheckPixPaymentTool,
|
||||||
Captain::Mcp::Tools::SendSuiteImagesTool,
|
Captain::Mcp::Tools::SendSuiteImagesTool,
|
||||||
Captain::Mcp::Tools::RescheduleReservationTool,
|
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
|
# Captain::Mcp::Tools::HandoffTool — fluxo via automation hoje, MCP futuro
|
||||||
].freeze
|
].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