diff --git a/enterprise/app/services/captain/mcp/tool_registry.rb b/enterprise/app/services/captain/mcp/tool_registry.rb index 747f5cac2..2d791d321 100644 --- a/enterprise/app/services/captain/mcp/tool_registry.rb +++ b/enterprise/app/services/captain/mcp/tool_registry.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/get_assistant_faqs_tool.rb b/enterprise/app/services/captain/mcp/tools/get_assistant_faqs_tool.rb new file mode 100644 index 000000000..d7b45f448 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/get_assistant_faqs_tool.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/get_assistant_pricing_tool.rb b/enterprise/app/services/captain/mcp/tools/get_assistant_pricing_tool.rb new file mode 100644 index 000000000..3b8e7ffb4 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/get_assistant_pricing_tool.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/list_assistants_tool.rb b/enterprise/app/services/captain/mcp/tools/list_assistants_tool.rb new file mode 100644 index 000000000..80756627b --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/list_assistants_tool.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/save_agent_spec_tool.rb b/enterprise/app/services/captain/mcp/tools/save_agent_spec_tool.rb new file mode 100644 index 000000000..5a84b2ed2 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/save_agent_spec_tool.rb @@ -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/.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