fix(captain/mcp): get_assistant_pricing lê do DB + save_agent_spec expande
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) <noreply@anthropic.com>
This commit is contained in:
parent
0f39945f43
commit
22eab86302
@ -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
|
||||
|
||||
37
db/schema.rb
37
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"
|
||||
|
||||
120
db/seed_pricing_units_1_2_3.rb
Normal file
120
db/seed_pricing_units_1_2_3.rb
Normal file
@ -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
|
||||
@ -6,8 +6,13 @@
|
||||
# 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
|
||||
@ -16,10 +21,14 @@
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# parent_assistant_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# 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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 << ''
|
||||
lines << "**Taxa pessoa extra:** R$ #{table[:extra_person_fee]}"
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@ -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/<slug>.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/<slug>.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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user