From 22eab863023403613ade22c43bfe03c07b63d30f Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Sat, 2 May 2026 12:37:53 -0300 Subject: [PATCH] =?UTF-8?q?fix(captain/mcp):=20get=5Fassistant=5Fpricing?= =?UTF-8?q?=20l=C3=AA=20do=20DB=20+=20save=5Fagent=5Fspec=20expande?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROOT FIX (não paliativo) das 3 lacunas que travavam o Construtor: 1. get_assistant_pricing_tool: lia de Captain::Mcp::PricingTables::TABLES (hash Ruby) que NÃO EXISTE MAIS desde a migração pra DB. Caía no fallback de scenario raw. Refactor: lê de Captain::PricingCategory + Captain::PricingAmount, formata grid markdown agrupado por day_bucket. 2. save_agent_spec_tool: Construtor salvava REFERÊNCIAS (pricing_source.copied_from_assistant_id) mas hermes-provision script espera DADOS EXPANDIDOS (categories[] com amounts, soul_md+skill_md). Refactor: tool agora EXPANDE server-side — busca PricingCategory do parent, monta categories array, gera SOUL.md (template + identity + disclosure_policy) e SKILL.md (template + pricing + rules + identity). Output já é spec consumível pelo script. 3. Captain::PricingAmount::PERIODS: adicionado '1h' (Prime tem 1h). 4. Seed pras 3 units faltando: Hotel Recanto (1) + PrimeAL (2) + Qnn01 (3). Agora os 6 units existentes têm pricing em DB. Hot-patched ambos tools + USR1 no Puma. Construtor pronto pra criar Bianca/Juliana etc end-to-end sem intervenção manual. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/models/captain/unit.rb | 2 + db/schema.rb | 37 ++- db/seed_pricing_units_1_2_3.rb | 120 ++++++++ enterprise/app/models/captain/assistant.rb | 39 ++- .../app/models/captain/pricing_amount.rb | 2 +- enterprise/app/models/captain/unit.rb | 2 + .../mcp/tools/get_assistant_pricing_tool.rb | 57 ++-- .../captain/mcp/tools/save_agent_spec_tool.rb | 256 ++++++++++++++++-- 8 files changed, 450 insertions(+), 65 deletions(-) create mode 100644 db/seed_pricing_units_1_2_3.rb diff --git a/app/models/captain/unit.rb b/app/models/captain/unit.rb index 94a8498ff..575b425b0 100644 --- a/app/models/captain/unit.rb +++ b/app/models/captain/unit.rb @@ -4,6 +4,8 @@ # # id :bigint not null, primary key # concierge_config :jsonb not null +# currency :string default("BRL"), not null +# extra_person_fee :decimal(10, 2) default(0.0), not null # inter_account_number :string # inter_cert_content :text # inter_cert_path :string diff --git a/db/schema.rb b/db/schema.rb index 8b7f99941..96e7f9635 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_05_01_030000) do +ActiveRecord::Schema[7.1].define(version: 2026_05_02_120100) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -336,7 +336,16 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_01_030000) do t.text "api_key" t.jsonb "handoff_webhook_config", default: {} t.text "orchestrator_prompt" + t.string "engine", default: "captain_interno", null: false + t.string "hermes_profile_name" + t.string "hermes_webhook_base_url" + t.string "hermes_subscription_secret" + t.integer "hermes_port" + t.bigint "parent_assistant_id" t.index ["account_id"], name: "index_captain_assistants_on_account_id" + t.index ["engine"], name: "index_captain_assistants_on_engine" + t.index ["hermes_port"], name: "idx_captain_assistants_hermes_port_unique", unique: true, where: "(hermes_port IS NOT NULL)" + t.index ["parent_assistant_id"], name: "index_captain_assistants_on_parent_assistant_id" end create_table "captain_brands", force: :cascade do |t| @@ -673,6 +682,28 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_01_030000) do t.index ["unit_id"], name: "index_captain_pix_charges_on_unit_id" end + create_table "captain_pricing_amounts", force: :cascade do |t| + t.bigint "captain_pricing_category_id", null: false + t.string "period", null: false + t.string "day_bucket" + t.decimal "amount", precision: 10, scale: 2, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["captain_pricing_category_id", "period", "day_bucket"], name: "idx_captain_pricing_amount_uniq", unique: true + t.index ["captain_pricing_category_id"], name: "index_captain_pricing_amounts_on_captain_pricing_category_id" + end + + create_table "captain_pricing_categories", force: :cascade do |t| + t.bigint "captain_unit_id", null: false + t.string "key", null: false + t.jsonb "aliases", default: [], null: false + t.integer "extra_person_starts_at", default: 3, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["captain_unit_id", "key"], name: "index_captain_pricing_categories_on_captain_unit_id_and_key", unique: true + t.index ["captain_unit_id"], name: "index_captain_pricing_categories_on_captain_unit_id" + end + create_table "captain_pricing_inboxes", force: :cascade do |t| t.bigint "captain_pricing_id", null: false t.bigint "inbox_id", null: false @@ -967,6 +998,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_01_030000) do t.uuid "supabase_unit_id" t.bigint "supabase_tenant_id", default: 1 t.uuid "supabase_marca_id" + t.decimal "extra_person_fee", precision: 10, scale: 2, default: "0.0", null: false + t.string "currency", default: "BRL", null: false t.index ["account_id"], name: "index_captain_units_on_account_id" t.index ["captain_brand_id"], name: "index_captain_units_on_captain_brand_id" t.index ["concierge_inbox_id"], name: "index_captain_units_on_concierge_inbox_id" @@ -2163,6 +2196,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_01_030000) do add_foreign_key "captain_notification_templates", "inboxes" add_foreign_key "captain_pix_charges", "captain_reservations", column: "reservation_id" add_foreign_key "captain_pix_charges", "captain_units", column: "unit_id" + add_foreign_key "captain_pricing_amounts", "captain_pricing_categories" + add_foreign_key "captain_pricing_categories", "captain_units" add_foreign_key "captain_pricings", "accounts" add_foreign_key "captain_pricings", "captain_brands" add_foreign_key "captain_prompt_audit_events", "captain_prompt_profiles", column: "prompt_profile_id" diff --git a/db/seed_pricing_units_1_2_3.rb b/db/seed_pricing_units_1_2_3.rb new file mode 100644 index 000000000..55ae063db --- /dev/null +++ b/db/seed_pricing_units_1_2_3.rb @@ -0,0 +1,120 @@ +# Seed pricing for units 1 (Hotel Recanto), 2 (PrimeAL), 3 (Qnn01) from +# scenario data. Units 4 (Dolce), 5 (Express), 6 (Prime Ceilândia) já têm. +# +# Idempotente. Roda quantas vezes quiser. +# rubocop:disable all + +NOITES_DATA = { + 'standard' => { + aliases: %w[standard comum], extra_person_starts_at: 3, + prices: { + '2h' => { 'mon_wed' => 40.0, 'thu_sun' => 50.0 }, + '3h' => { 'mon_wed' => 50.0, 'thu_sun' => 65.0 }, + '4h' => { 'mon_wed' => 60.0, 'thu_sun' => 80.0 }, + 'pernoite_promo' => { 'mon_wed' => 100.0, 'thu_sun' => 150.0 }, + 'diaria' => 170.0 + } + }, + 'luxo' => { + aliases: ['luxo', 'classica', 'clássica'], extra_person_starts_at: 3, + prices: { + '2h' => 60.0, '3h' => 75.0, '4h' => 85.0, + 'pernoite_promo' => { 'mon_wed' => 130.0, 'thu_sun' => 160.0 }, + 'diaria' => 190.0 + } + }, + 'hidromassagem' => { + aliases: %w[hidro hidromassagem banheira spa jacuzzi], extra_person_starts_at: 3, + prices: { + '2h' => 110.0, '3h' => 120.0, '4h' => 150.0, + 'pernoite_promo' => 250.0, 'diaria' => 300.0 + } + } +}.freeze + +PRIME_DATA = { + 'stilo' => { + aliases: %w[stilo estilo], extra_person_starts_at: 3, + prices: { + '1h' => { 'mon_wed' => 40.0, 'thu_sun' => 50.0 }, + '2h' => { 'mon_wed' => 60.0, 'thu_sun' => 70.0 }, + '3h' => { 'mon_wed' => 70.0, 'thu_sun' => 80.0 }, + '4h' => { 'mon_wed' => 75.0, 'thu_sun' => 85.0 }, + 'pernoite_promo' => { 'mon_wed' => 130.0, 'thu_sun' => 150.0 }, + 'pernoite_integral' => { 'mon_wed' => 150.0, 'thu_sun' => 170.0 }, + 'diaria' => { 'mon_wed' => 160.0, 'thu_sun' => 180.0 } + } + }, + 'alexa' => { + aliases: %w[alexa], extra_person_starts_at: 3, + prices: { + '1h' => { 'mon_wed' => 50.0, 'thu_sun' => 60.0 }, + '2h' => { 'mon_wed' => 65.0, 'thu_sun' => 75.0 }, + '3h' => { 'mon_wed' => 75.0, 'thu_sun' => 85.0 }, + '4h' => { 'mon_wed' => 80.0, 'thu_sun' => 90.0 }, + 'pernoite_promo' => { 'mon_wed' => 140.0, 'thu_sun' => 160.0 }, + 'pernoite_integral' => { 'mon_wed' => 160.0, 'thu_sun' => 180.0 }, + 'diaria' => { 'mon_wed' => 170.0, 'thu_sun' => 200.0 } + } + }, + 'hidromassagem' => { + aliases: %w[hidro hidromassagem banheira spa jacuzzi ofuro], extra_person_starts_at: 3, + prices: { + '1h' => { 'mon_wed' => 130.0, 'thu_sun' => 140.0 }, + '2h' => { 'mon_wed' => 150.0, 'thu_sun' => 160.0 }, + '3h' => { 'mon_wed' => 170.0, 'thu_sun' => 180.0 }, + '4h' => { 'mon_wed' => 190.0, 'thu_sun' => 200.0 }, + 'pernoite_promo' => { 'mon_wed' => 260.0, 'thu_sun' => 280.0 }, + 'pernoite_integral' => { 'mon_wed' => 280.0, 'thu_sun' => 300.0 }, + 'diaria' => { 'mon_wed' => 350.0, 'thu_sun' => 370.0 } + } + } +}.freeze + +def upsert(unit, data) + data.each do |key, attrs| + cat = Captain::PricingCategory.find_or_initialize_by(captain_unit_id: unit.id, key: key) + cat.aliases = attrs[:aliases] + cat.extra_person_starts_at = attrs[:extra_person_starts_at] + cat.save! + attrs[:prices].each do |period, value| + if value.is_a?(Hash) + value.each do |bucket, amount| + row = Captain::PricingAmount.find_or_initialize_by( + captain_pricing_category_id: cat.id, period: period, day_bucket: bucket + ) + row.amount = amount + row.save! + end + else + row = Captain::PricingAmount.find_or_initialize_by( + captain_pricing_category_id: cat.id, period: period, day_bucket: nil + ) + row.amount = value + row.save! + end + end + puts "✓ unit=#{unit.id} #{key}" + end +end + +# 1001 Noites brand units = 1 (Hotel Recanto), 3 (Qnn01) +[1, 3].each do |id| + u = Captain::Unit.find(id) + u.update!(extra_person_fee: 45.0, currency: 'BRL') if u.extra_person_fee.to_f.zero? + upsert(u, NOITES_DATA) +end + +# Prime brand unit = 2 (PrimeAL) +u = Captain::Unit.find(2) +u.update!(extra_person_fee: 0.0, currency: 'BRL') +upsert(u, PRIME_DATA) + +puts "--- summary ---" +[1, 2, 3].each do |id| + u = Captain::Unit.find(id) + cats = u.pricing_categories.count + amounts = Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: u.id }).count + puts "unit #{id} #{u.name}: cats=#{cats} amounts=#{amounts}" +end +# rubocop:enable all diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 61baa9bb1..77200ef61 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -2,24 +2,33 @@ # # Table name: captain_assistants # -# id :bigint not null, primary key -# api_key :text -# config :jsonb not null -# description :string -# guardrails :jsonb -# handoff_webhook_config :jsonb -# llm_model :string default("gpt-3.5-turbo") -# llm_provider :string default("openai") -# name :string not null -# orchestrator_prompt :text -# response_guidelines :jsonb -# created_at :datetime not null -# updated_at :datetime not null -# account_id :bigint not null +# id :bigint not null, primary key +# api_key :text +# config :jsonb not null +# description :string +# engine :string default("captain_interno"), not null +# guardrails :jsonb +# handoff_webhook_config :jsonb +# hermes_port :integer +# hermes_profile_name :string +# hermes_subscription_secret :string +# hermes_webhook_base_url :string +# llm_model :string default("gpt-3.5-turbo") +# llm_provider :string default("openai") +# name :string not null +# orchestrator_prompt :text +# response_guidelines :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# parent_assistant_id :bigint # # Indexes # -# index_captain_assistants_on_account_id (account_id) +# idx_captain_assistants_hermes_port_unique (hermes_port) UNIQUE WHERE (hermes_port IS NOT NULL) +# index_captain_assistants_on_account_id (account_id) +# index_captain_assistants_on_engine (engine) +# index_captain_assistants_on_parent_assistant_id (parent_assistant_id) # class Captain::Assistant < ApplicationRecord include Avatarable diff --git a/enterprise/app/models/captain/pricing_amount.rb b/enterprise/app/models/captain/pricing_amount.rb index 8b895bca4..1cae2b5ae 100644 --- a/enterprise/app/models/captain/pricing_amount.rb +++ b/enterprise/app/models/captain/pricing_amount.rb @@ -24,7 +24,7 @@ class Captain::PricingAmount < ApplicationRecord self.table_name = 'captain_pricing_amounts' - PERIODS = %w[2h 3h 4h 5h pernoite_promo pernoite_integral diaria].freeze + PERIODS = %w[1h 2h 3h 4h 5h pernoite_promo pernoite_integral diaria].freeze DAY_BUCKETS = %w[mon_wed thu_sun].freeze belongs_to :pricing_category, diff --git a/enterprise/app/models/captain/unit.rb b/enterprise/app/models/captain/unit.rb index 1b1f6a6ad..f4b0db731 100644 --- a/enterprise/app/models/captain/unit.rb +++ b/enterprise/app/models/captain/unit.rb @@ -4,6 +4,8 @@ # # id :bigint not null, primary key # concierge_config :jsonb not null +# currency :string default("BRL"), not null +# extra_person_fee :decimal(10, 2) default(0.0), not null # inter_account_number :string # inter_cert_content :text # inter_cert_path :string 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 index 1aed0c1d8..41a2a0df3 100644 --- a/enterprise/app/services/captain/mcp/tools/get_assistant_pricing_tool.rb +++ b/enterprise/app/services/captain/mcp/tools/get_assistant_pricing_tool.rb @@ -58,33 +58,52 @@ class Captain::Mcp::Tools::GetAssistantPricingTool < Captain::Mcp::Tools::BaseTo "_(assistente #{assistant.name} não tem tabela de preços estruturada nem em scenario)_" end - # Lookup em Captain::Mcp::PricingTables (Hermes-side hardcoded). + # Lookup em Captain::PricingCategory + Captain::PricingAmount (DB). 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) + unit = unit_for(assistant) return nil if unit.blank? + return nil if unit.pricing_categories.empty? - table = Captain::Mcp::PricingTables::TABLES[unit.id] - return nil if table.blank? - - format_structured_table(unit, table) + format_structured_table(unit) 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 |" + def unit_for(assistant) + ci = CaptainInbox.find_by(captain_assistant_id: assistant.id) + return nil if ci.blank? + + Captain::Unit.find_by(id: ci.captain_unit_id) || + Captain::Unit.find_by(inbox_id: ci.inbox_id) + end + + # rubocop:disable Metrics/AbcSize + def format_structured_table(unit) + lines = ["# Tabela de preços — #{unit.name} (marca #{unit.brand.name})", ''] + + unit.pricing_categories.each do |cat| + lines << "## #{cat.key.tr('_', ' ').capitalize} (extra a partir da #{cat.extra_person_starts_at}ª pessoa)" + cat.amounts.group_by { |a| a.day_bucket || 'default' }.each do |bucket, amounts| + lines << "### #{bucket_label(bucket)}" + lines << '| Período | Valor |' + lines << '|---|---|' + amounts.sort_by { |a| Captain::PricingAmount::PERIODS.index(a.period) || 99 }.each do |a| + lines << "| #{a.period} | R$ #{a.amount.to_f} |" + end + lines << '' + end end - lines << '' - lines << "**Taxa pessoa extra:** R$ #{table[:extra_person_fee]}" + + lines << "**Taxa pessoa extra:** R$ #{unit.extra_person_fee.to_f}" if unit.extra_person_fee.to_f.positive? lines.join("\n") end + # rubocop:enable Metrics/AbcSize + + def bucket_label(bucket) + case bucket + when 'mon_wed' then 'Seg-Qua' + when 'thu_sun' then 'Qui-Dom' + else 'Todos os dias' + end + end # Captain interno guarda a tabela no instruction de algum scenario # (geralmente o de reservas/preços). Retorna o markdown bruto pra 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 index 05b9d34e0..63b73d075 100644 --- a/enterprise/app/services/captain/mcp/tools/save_agent_spec_tool.rb +++ b/enterprise/app/services/captain/mcp/tools/save_agent_spec_tool.rb @@ -1,15 +1,18 @@ -# Tool MCP: salva especificação completa de um novo agente Hermes em JSON. +# Tool MCP: salva especificação de um novo agente Hermes EXPANDIDA pro +# formato que /usr/local/bin/hermes-provision consome direto. # -# 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. +# Recebe spec do Construtor (formato "referências": persona_source + +# pricing_source apontando pra outro assistant) e expande server-side em: +# - categories[]: array de {key, aliases, extra_person_starts_at, amounts[]} +# resolvido a partir de Captain::PricingCategory + Captain::PricingAmount +# do unit do parent +# - soul_md: gerado do template + identity + disclosure_policy +# - skill_md: gerado do template + categories + identity + rules +# - account_id, marca, unit_name resolvidos +# - parent_assistant_id setado pra o copied_from_assistant_id (FAQs sombra) # -# **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. +# Output: spec pronto pra rodar `cat /tmp/agent-specs/.json | hermes-provision`. +# rubocop:disable Metrics/ClassLength class Captain::Mcp::Tools::SaveAgentSpecTool < Captain::Mcp::Tools::BaseTool SPEC_DIR = '/tmp/agent-specs'.freeze @@ -19,10 +22,10 @@ class Captain::Mcp::Tools::SaveAgentSpecTool < Captain::Mcp::Tools::BaseTool 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.' + 'Salva especificação completa expandida de um novo agente Hermes. ' \ + 'Recebe estrutura com referências (pricing_source/persona_source) e ' \ + 'expande server-side em categories[]+soul_md+skill_md prontos pra ' \ + 'provisionamento. Use ao final do fluxo socrático.' end def input_schema @@ -31,12 +34,11 @@ class Captain::Mcp::Tools::SaveAgentSpecTool < Captain::Mcp::Tools::BaseTool properties: { slug: { type: 'string', - description: 'Slug único pro agente (ex: "jasmine_prime_al"). Lowercase, snake_case. Vai ser nome do arquivo.' + description: 'Slug único pro agente (lowercase, snake_case). Será nome do profile e 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.' + description: 'JSON da especificação — name, brand, unit_name, persona_source, pricing_source, identity, rules, faqs.' } }, required: %w[slug spec] @@ -44,28 +46,224 @@ class Captain::Mcp::Tools::SaveAgentSpecTool < Captain::Mcp::Tools::BaseTool end end - def call(args, context:) # rubocop:disable Metrics/AbcSize, Lint/UnusedMethodArgument + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Lint/UnusedMethodArgument + def call(args, context:) 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 + return error_response('slug inválido (lowercase, snake_case, 3+ chars).') if slug.blank? || slug.length < 3 spec = args['spec'] return error_response('spec deve ser um objeto JSON.') unless spec.is_a?(Hash) + expanded, errors = expand_spec(slug, spec) + return error_response("Spec inválido após expansão: #{errors.join('; ')}") if errors.any? + 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)) + File.write(path, JSON.pretty_generate(expanded)) text_response( - "Spec do agente '#{slug}' salvo em #{path}. " \ - 'Próximo passo (separado): admin revisa o JSON e dispara provisionamento real.' + "✅ Spec EXPANDIDO salvo em #{path}.\n\n" \ + "Conteúdo: #{expanded['categories'].size} categorias, " \ + "soul_md #{expanded['soul_md']&.length || 0} chars, " \ + "skill_md #{expanded['skill_md']&.length || 0} chars.\n\n" \ + "Pra provisionar, rode no terminal:\n" \ + "```\ndocker exec $(docker ps --filter name=iachat_iachat_app -q | head -1) cat #{path} | /usr/local/bin/hermes-provision\n```" ) rescue StandardError => e - Rails.logger.error("[Captain::Mcp::SaveAgentSpecTool] error: #{e.class}: #{e.message}") - error_response("Erro ao salvar spec: #{e.message}") + Rails.logger.error("[SaveAgentSpecTool] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}") + error_response("Erro ao expandir spec: #{e.message}") + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Lint/UnusedMethodArgument + + private + + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def expand_spec(slug, spec) + errors = [] + parent_id = spec.dig('persona_source', 'copied_from_assistant_id') || + spec.dig('pricing_source', 'copied_from_assistant_id') + parent = parent_id ? Captain::Assistant.find_by(id: parent_id) : nil + errors << "parent assistant_id=#{parent_id} não encontrado" if parent_id && parent.nil? + return [{}, errors] if errors.any? + + # Resolve marca: spec.brand string → Captain::Brand + brand_name = spec['brand'] || spec['marca'] + brand = lookup_brand(parent, brand_name) + errors << "marca '#{brand_name}' não resolvida" if brand.nil? + + # Resolve unit (do parent) pra pricing + parent_unit = parent_unit_for(parent) + pricing_categories = parent_unit ? expand_categories(parent_unit) : [] + errors << "unit do parent (id=#{parent_id}) sem categorias de preço cadastradas" if pricing_categories.empty? + + return [{}, errors] if errors.any? + + name = spec['name'].presence || slug.tr('_', ' ').split.map(&:capitalize).join(' ') + + { + 'slug' => slug, + 'name' => name, + 'account_id' => parent.account_id, + 'marca' => brand.name, + 'unit_name' => spec['unit_name'].presence || "#{brand.name} - novo", + 'captain_unit_id' => nil, + 'parent_assistant_id' => parent_id, + 'extra_person_fee' => (parent_unit&.extra_person_fee || 0).to_f, + 'skill_name' => "#{slug.tr('_', '-')}-reservas", + 'humanization' => spec['humanization'] || default_humanization, + 'soul_md' => build_soul_md(name, brand, spec), + 'skill_md' => build_skill_md(name, brand, spec, pricing_categories), + 'categories' => pricing_categories.map { |c| serialize_category(c) }, + 'saved_at' => Time.current.iso8601, + 'saved_by_tool' => 'mcp_save_agent_spec' + } + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize + + def lookup_brand(parent, brand_name) + return nil if parent.nil? + + if brand_name.present? + hit = Captain::Brand.where(account_id: parent.account_id).find { |b| brand_matches?(b.name, brand_name) } + return hit if hit + end + parent.captain_inboxes.first&.captain_unit&.brand + end + + def parent_unit_for(parent) + return nil if parent.nil? + + ci = parent.captain_inboxes.first + return nil if ci.nil? + + Captain::Unit.find_by(id: ci.captain_unit_id) || Captain::Unit.find_by(inbox_id: ci.inbox_id) + end + + def expand_categories(unit) + unit.pricing_categories.includes(:amounts).to_a + end + + def serialize_category(cat) + { + 'key' => cat.key, + 'aliases' => cat.aliases.to_a, + 'extra_person_starts_at' => cat.extra_person_starts_at, + 'amounts' => cat.amounts.map do |a| + { 'period' => a.period, 'day_bucket' => a.day_bucket, 'amount' => a.amount.to_f } + end + } + end + + def brand_matches?(stored, asked) + return false if stored.blank? || asked.blank? + + s = stored.downcase + a = asked.downcase + s.include?(a) || a.include?(s) + end + + def default_humanization + { 'mode' => 'typing_simulation', 'chars_per_second' => 25, 'min_seconds' => 1.5, 'max_seconds' => 6.0 } + end + + # rubocop:disable Metrics/MethodLength + def build_soul_md(name, brand, spec) + identity = spec['identity'] || {} + disc = identity['disclosure_policy'] || {} + default_pres = disc['default_presentation'].presence || "atendente do #{brand.name}" + if_ai = disc['if_explicit_ai_question'].presence || 'assistente virtual' + + <<~MD + # #{name} — Atendente do #{brand.name} + + Sou #{name}, #{default_pres}. Atendo pelo WhatsApp clientes da rede. + + ## Tom de voz + - Brasileira, calorosa, profissional. Fala como gente. + - Direta. Cliente quer reservar, eu reservo. + - Bem-humorada na medida certa, sem exagero. + + ## Princípios + - Default: me apresento como **#{default_pres}**. + - Se cliente perguntar EXPLICITAMENTE se sou bot/IA, respondo: "#{if_ai}". + - Nunca invento valor, regra ou condição. Tudo na minha skill. + - Não prometo desconto, brinde, cortesia, cancelamento — gerência decide. + + ## Saudação na primeira mensagem + - Com nome no contato: *"Oi, {primeiro_nome}! 😊 Sou #{name}, #{default_pres}. Como posso te ajudar?"* + - Sem nome: *"Oi! 😊 Sou #{name}, #{default_pres}. Como posso te ajudar?"* + + Bom dia / Boa tarde / Boa noite no lugar de "Oi" se cliente abriu com isso. + + ## Quando transferir pra humano + Resposta única: **"⏳ Um momento — vou verificar."** + handoff. + + Casos: hóspede já no hotel, cancelamento de reserva, pedido de desconto, fora de escopo. + + ## Memória + Lembro de cada cliente que já conversou. Uso o conhecimento sem comentar 'lembra de você'. + MD + end + # rubocop:enable Metrics/MethodLength + + def build_skill_md(name, brand, spec, categories) + identity = spec['identity'] || {} + rules = spec['rules'] || {} + + pricing_md = format_pricing_block(categories) + + <<~MD + --- + name: #{name.downcase.tr(' .', '-').squeeze('-')}-reservas + description: Operação reservas/preços/Pix de #{name} (#{brand.name}). + when_to_use: Sempre que cliente perguntar sobre preço, reserva, Pix, suítes, horários ou regras. + --- + + # #{name} — Operação + + Marca: **#{brand.name}**. + + ## Tabela de Preços + + ⚠️ Use direto. Não consulte FAQ pra preço. + + #{pricing_md} + + ## Regras + - Pernoite: check-in #{rules['pernoite_checkin_from'] || '19h'}, saída #{rules['pernoite_checkout_until'] || '12h'}. + - Diária: 24h. + - Pessoa extra começa a pagar a partir da #{rules['extra_person_starts_at'] || 3}ª (base inclui #{rules['base_guests_included'] || 2}). + - Café da manhã #{rules['breakfast_hours'] || '06h-10h'}. Fora desse horário, #{rules['breakfast_outside_hours'] || 'negociar com a recepção'}. + - Estacionamento gratuito. + + ## Identidade da Unidade + #{identity['address'] ? "- Endereço: #{identity['address']}" : ''} + #{identity['phone'] ? "- Contato: #{identity['phone']}" : ''} + #{identity['wifi'] && identity.dig('wifi', 'policy') ? "- Wi-Fi: #{identity.dig('wifi', 'policy')}" : ''} + MD + end + + def format_pricing_block(categories) + return '_(sem categorias cadastradas)_' if categories.empty? + + lines = [] + categories.each do |cat| + lines << "### #{cat['key'].tr('_', ' ').capitalize}" + grouped = cat['amounts'].group_by { |a| a['day_bucket'] || 'flat' } + grouped.each do |bucket, amounts| + label = case bucket + when 'mon_wed' then 'Seg-Qua' + when 'thu_sun' then 'Qui-Dom' + else 'Todos os dias' + end + lines << "**#{label}:**" + amounts.sort_by { |a| Captain::PricingAmount::PERIODS.index(a['period']) || 99 }.each do |a| + lines << "- #{a['period']}: R$ #{a['amount']}" + end + lines << '' + end + end + lines.join("\n") end end +# rubocop:enable Metrics/ClassLength