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:
Rodribm10 2026-05-02 12:37:53 -03:00
parent 0f39945f43
commit 22eab86302
8 changed files with 450 additions and 65 deletions

View File

@ -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

View File

@ -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"

View 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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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 no hotel, cancelamento de reserva, pedido de desconto, fora de escopo.
## Memória
Lembro de cada cliente que 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