feat(captain): engine column + DB-driven Hermes routing + Express pricing

Marca cada Captain::Assistant com engine ('captain_interno' | 'hermes')
e move o roteamento Hermes do env var pro banco — admin troca engine
re-apontando a inbox no painel, sem deploy. Mantém fallback pras env
vars antigas (CAPTAIN_HERMES_INBOX_IDS etc) durante a migração gradual,
pra não quebrar Valentina antes da re-associação.

Frontend: badge "Hermes" (âmbar) ou "Interno" (cinza) ao lado de cada
assistant no dropdown switcher e no card da listagem, com chaves i18n
em en + pt_BR.

Tabela de preço (pricing_tables.rb): adiciona unit Express (id=5) e
estende a estrutura pra aceitar preço por dia da semana
(mon_wed/thu_sun) — necessário pro Express, retrocompatível com Dolce
Amore (preço único).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-02 07:54:01 -03:00
parent 5f6aed05c9
commit 3182002bd9
10 changed files with 244 additions and 48 deletions

View File

@ -26,6 +26,10 @@ const props = defineProps({
type: Number,
required: true,
},
engine: {
type: String,
default: 'captain_interno',
},
});
const emit = defineEmits(['action']);
@ -76,11 +80,27 @@ const handleAction = ({ action, value }) => {
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<h6
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
>
{{ name }}
</h6>
<div class="flex items-center gap-2 min-w-0">
<h6
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
>
{{ name }}
</h6>
<span
v-if="engine === 'hermes'"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11 shrink-0"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
</span>
<span
v-else
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11 shrink-0"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
</span>
</div>
<div class="flex items-center gap-2">
<div
v-on-clickaway="() => toggleDropdown(false)"

View File

@ -130,6 +130,20 @@ const openCreateAssistantDialog = () => {
<span class="text-sm font-medium truncate text-n-slate-12">
{{ assistant.name || '' }}
</span>
<span
v-if="assistant.engine === 'hermes'"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
</span>
<span
v-else
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
</span>
<Avatar
v-if="assistant"
:name="assistant.name"

View File

@ -385,7 +385,11 @@
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"EMPTY_LIST": "No assistants found, please create one to get started",
"ENGINE_HERMES": "Hermes",
"ENGINE_HERMES_TOOLTIP": "Assistant operated by the Hermes Agent (external LLM)",
"ENGINE_INTERNO": "Internal",
"ENGINE_INTERNO_TOOLTIP": "Assistant operated by the internal Captain orchestrator"
},
"COPILOT": {
"TITLE": "Copilot",

View File

@ -366,7 +366,11 @@
"ASSISTANTS": "Assistentes",
"SWITCH_ASSISTANT": "Alternar entre assistentes",
"NEW_ASSISTANT": "Criar Assistente",
"EMPTY_LIST": "Nenhum assistente encontrado, crie um para começar"
"EMPTY_LIST": "Nenhum assistente encontrado, crie um para começar",
"ENGINE_HERMES": "Hermes",
"ENGINE_HERMES_TOOLTIP": "Atendente operada pelo Hermes Agent (LLM externo)",
"ENGINE_INTERNO": "Interno",
"ENGINE_INTERNO_TOOLTIP": "Atendente operada pelo orquestrador interno do Captain"
},
"COPILOT": {
"TITLE": "Copiloto",

View File

@ -0,0 +1,8 @@
class AddEngineToCaptainAssistants < ActiveRecord::Migration[7.1]
def change
add_column :captain_assistants, :engine, :string, default: 'captain_interno', null: false
add_column :captain_assistants, :hermes_profile_name, :string
add_column :captain_assistants, :hermes_webhook_base_url, :string
add_index :captain_assistants, :engine
end
end

View File

@ -49,6 +49,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
def assistant_params
permitted = params.require(:assistant).permit(:name, :description, :orchestrator_prompt,
:engine, :hermes_profile_name, :hermes_webhook_base_url,
config: [
:product_name, :feature_faq, :feature_memory, :feature_citation,
:welcome_message, :handoff_message, :resolution_message,

View File

@ -43,9 +43,17 @@ class Captain::Assistant < ApplicationRecord
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
ENGINES = %w[captain_interno hermes].freeze
validates :name, presence: true
validates :description, presence: true
validates :account_id, presence: true
validates :engine, inclusion: { in: ENGINES }
validates :hermes_profile_name, presence: true, if: :hermes?
validates :hermes_webhook_base_url, presence: true, if: :hermes?
scope :hermes, -> { where(engine: 'hermes') }
scope :captain_interno, -> { where(engine: 'captain_interno') }
scope :ordered, -> { order(created_at: :desc) }
@ -55,6 +63,14 @@ class Captain::Assistant < ApplicationRecord
name
end
def hermes?
engine == 'hermes'
end
def captain_interno?
engine == 'captain_interno'
end
def available_agent_tools
tools = self.class.built_in_agent_tools.dup

View File

@ -7,24 +7,32 @@
# - Hermes invoca plugin captain-http-callback que POSTa de volta no Captain
# - Captain cria mensagem outgoing e envia pro WhatsApp
#
# A ativação é por inbox via env var. As 9 outras inboxes do Captain seguem
# usando o orquestrador interno (Daniela_Reservas, etc) sem mudança.
# A ativação preferencial é DATA-DRIVEN: cada Captain::Assistant tem coluna
# `engine` ('captain_interno' | 'hermes'). Inboxes apontam pra um assistant
# via CaptainInbox; o engine do assistant determina o roteamento. Trocar de
# engine = trocar a association no painel, sem deploy.
#
# Env vars:
# CAPTAIN_HERMES_INBOX_IDS CSV de inbox.id (ex: "1,5"). Se vazio,
# desativa em todas. Inboxes não listadas
# continuam no fluxo Captain interno.
# CAPTAIN_HERMES_WEBHOOK_BASE_URL Base URL do gateway Hermes
# (default http://172.17.0.1:8644).
# CAPTAIN_HERMES_CALLBACK_SECRET HMAC-SHA256 secret pra validar callback
# do Hermes (X-Hermes-Callback-Signature).
# Se vazio, validação é desabilitada (NÃO
# recomendado em prod).
# Por compatibilidade durante a migração (gradual), também respeitamos as
# env vars antigas: se uma inbox está em CAPTAIN_HERMES_INBOX_IDS mas o
# assistant ainda é 'captain_interno', tratamos como Hermes — assim Valentina
# continua funcionando antes do admin re-apontar a inbox no painel.
# Esse fallback deve ser removido depois que todos as inboxes migrarem.
#
# Env vars (apenas credenciais — config funcional vive no DB):
# CAPTAIN_HERMES_CALLBACK_SECRET HMAC-SHA256 secret pra validar
# callback do Hermes
# (X-Hermes-Callback-Signature).
# CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_<id>
# Per-inbox secret retornado pelo
# `hermes webhook subscribe`. Usado pra
# assinar o POST OUTGOING. Sem ele, o
# Hermes vai rejeitar o webhook.
# `hermes webhook subscribe`. Usado
# pra assinar o POST OUTGOING.
#
# Env vars LEGACY (em descontinuação — preferir DB):
# CAPTAIN_HERMES_INBOX_IDS CSV de inbox.id. Usado só como
# fallback até as inboxes terem
# assistant com engine='hermes'.
# CAPTAIN_HERMES_WEBHOOK_BASE_URL Base URL default. Idem.
# CAPTAIN_HERMES_BASE_URL_INBOX_<id> Per-inbox base URL. Idem.
module Captain::Hermes
DEFAULT_BASE_URL = 'http://172.17.0.1:8644'.freeze
@ -34,23 +42,27 @@ module Captain::Hermes
return false if inbox.blank?
return false unless inbox.respond_to?(:id)
inbox_ids.include?(inbox.id)
return true if assistant_for(inbox)&.hermes?
legacy_inbox_ids.include?(inbox.id)
end
def inbox_ids
@inbox_ids ||= ENV.fetch('CAPTAIN_HERMES_INBOX_IDS', '')
.split(',')
.map { |s| s.strip.to_i }
.reject(&:zero?)
.freeze
def assistant_for(inbox)
return nil if inbox.blank?
return nil unless inbox.respond_to?(:captain_inbox)
inbox.captain_inbox&.captain_assistant
end
def webhook_base_url
@webhook_base_url ||= (ENV['CAPTAIN_HERMES_WEBHOOK_BASE_URL'].presence || DEFAULT_BASE_URL).chomp('/')
def webhook_base_url(inbox = nil)
assistant = assistant_for(inbox)
return assistant.hermes_webhook_base_url.chomp('/') if assistant&.hermes? && assistant.hermes_webhook_base_url.present?
legacy_webhook_base_url(inbox)
end
def webhook_url_for(inbox)
"#{webhook_base_url}/webhooks/#{subscription_name_for(inbox)}"
"#{webhook_base_url(inbox)}/webhooks/#{subscription_name_for(inbox)}"
end
# Convenção de nome de subscription no Hermes: precisa bater com o que o
@ -67,9 +79,25 @@ module Captain::Hermes
ENV.fetch('CAPTAIN_HERMES_CALLBACK_SECRET', nil)
end
# Reseta caches. Útil em specs ou após reload de config.
def reset_cache!
@inbox_ids = nil
@webhook_base_url = nil
@legacy_inbox_ids = nil
end
# === Legacy (env var) fallbacks ===
def legacy_inbox_ids
@legacy_inbox_ids ||= ENV.fetch('CAPTAIN_HERMES_INBOX_IDS', '')
.split(',')
.map { |s| s.strip.to_i }
.reject(&:zero?)
.freeze
end
def legacy_webhook_base_url(inbox = nil)
if inbox && (per_inbox = ENV.fetch("CAPTAIN_HERMES_BASE_URL_INBOX_#{inbox.id}", nil)).present?
return per_inbox.chomp('/')
end
(ENV['CAPTAIN_HERMES_WEBHOOK_BASE_URL'].presence || DEFAULT_BASE_URL).chomp('/')
end
end

View File

@ -9,19 +9,25 @@
# Estrutura: TABLES[captain_unit_id] = {
# categories: {
# '<categoria_key>' => {
# prices: { '3h' => 85, 'pernoite_promo' => 110, ... },
# aliases: ['apto', 'standard', 'apartamento standard', ...]
# # Preço por período. Aceita 2 formatos:
# # 1) Valor único (motel sem variação por dia da semana):
# # prices: { '3h' => 85.0, 'pernoite_promo' => 110.0 }
# # 2) Hash por bucket de dia da semana (hotel: qui-dom caro):
# # prices: { '3h' => { 'mon_wed' => 50.0, 'thu_sun' => 65.0 } }
# # Buckets aceitos: 'mon_wed' (seg-qua) e 'thu_sun' (qui-dom).
# prices: { '3h' => 85, ... },
# aliases: ['apto', 'standard', ...]
# }
# },
# extra_person_fee: 45,
# extra_person_rules: { '<categoria_key>' => starts_at_guest_n }
# }
#
# Hoje só Dolce Amore (unit 4) está mapeado — Hermes só está ativo nele.
# Conforme outras unidades migrarem pra Hermes, expandir aqui.
# rubocop:disable Metrics/ModuleLength
module Captain::Mcp::PricingTables
PERIOD_KEYS = %w[3h pernoite_promo pernoite_integral diaria].freeze
PERIOD_KEYS = %w[2h 3h 4h 5h pernoite_promo pernoite_integral diaria].freeze
# mon_wed cobre wday 1,2,3 (seg-qua); thu_sun cobre wday 4,5,6,0 (qui-dom).
DAY_BUCKETS = %w[mon_wed thu_sun].freeze
DEFAULT_TZ = 'America/Sao_Paulo'.freeze
TABLES = {
# Motel Dolce Amore — Ponta Negra, Natal/RN (captain_unit_id=4)
@ -73,16 +79,74 @@ module Captain::Mcp::PricingTables
aliases: ['chale master', 'chalé master', 'master 4 suites', 'chalé master 4 suítes', 'chale_master', '4 suites']
}
}
},
# Hotel 1001 Noites Express — Águas Lindas/GO (captain_unit_id=5)
# Preço varia por dia da semana (mon_wed = seg-qua / thu_sun = qui-dom).
# Diária e Família são flat (mesmo preço todos os dias).
5 => {
currency: 'BRL',
extra_person_fee: 0.0,
categories: {
'standard' => {
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' => 120.0 },
'diaria' => 150.0
},
extra_person_starts_at: 3,
aliases: ['standard', 'comum', 'básica', 'basica', 'apartamento standard']
},
'master' => {
prices: {
'2h' => { 'mon_wed' => 50.0, 'thu_sun' => 60.0 },
'3h' => { 'mon_wed' => 60.0, 'thu_sun' => 75.0 },
'4h' => { 'mon_wed' => 70.0 },
'5h' => { 'thu_sun' => 85.0 },
'pernoite_promo' => { 'mon_wed' => 120.0, 'thu_sun' => 140.0 },
'diaria' => 160.0
},
extra_person_starts_at: 3,
aliases: ['master', 'melhor', 'suite master', 'suíte master']
},
'singles' => {
prices: {
'pernoite_promo' => { 'mon_wed' => 80.0, 'thu_sun' => 110.0 },
'diaria' => 130.0
},
extra_person_starts_at: 99,
aliases: %w[singles single sozinho]
},
'familia' => {
prices: {
'pernoite_promo' => 160.0,
'diaria' => 190.0
},
extra_person_starts_at: 99,
aliases: %w[familia família familiar]
},
'singles_duplo' => {
prices: {
'pernoite_promo' => { 'mon_wed' => 180.0, 'thu_sun' => 220.0 },
'diaria' => 250.0
},
extra_person_starts_at: 99,
aliases: ['singles duplo', 'singles_duplo', 'casal', 'duplo']
}
}
}
}.freeze
class << self
# Retorna {amount:, breakdown:} ou erro {error:} pra uma cobrança.
# period: '3h' | 'pernoite_promo' | 'pernoite_integral' | 'diaria'
# extra_guests: número TOTAL de hóspedes (não só os "extras" — a função
# calcula extras baseado em extra_person_starts_at).
# rubocop:disable Metrics/MethodLength
def calculate(unit_id:, suite_category:, period:, total_guests: 2)
# check_in_at: Time/String ISO8601. Determina o bucket de dia da semana
# quando o preço varia (mon_wed/thu_sun). Default: agora.
# total_guests: número TOTAL de hóspedes — a função calcula extras
# baseado em extra_person_starts_at.
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
def calculate(unit_id:, suite_category:, period:, total_guests: 2, check_in_at: nil)
table = TABLES[unit_id]
return { error: "Unidade #{unit_id} não tem tabela de preços cadastrada." } if table.blank?
@ -92,8 +156,12 @@ module Captain::Mcp::PricingTables
period_key = normalize_period(period)
return { error: "Período '#{period}' inválido. Use: #{PERIOD_KEYS.join(', ')}." } if period_key.blank?
base = cat_data[:prices][period_key]
return { error: "Preço de '#{period_key}' não definido para '#{cat_key}'." } if base.blank?
raw = cat_data[:prices][period_key]
return { error: "Preço de '#{period_key}' não definido para '#{cat_key}'." } if raw.blank?
day_bucket = resolve_day_bucket(check_in_at)
base, used_bucket = resolve_price(raw, day_bucket, period_key, cat_key)
return { error: base } if base.is_a?(String)
starts_at = cat_data[:extra_person_starts_at] || 3
extra_guests = [total_guests.to_i - (starts_at - 1), 0].max
@ -106,6 +174,7 @@ module Captain::Mcp::PricingTables
unit_id: unit_id,
suite_category: cat_key,
period: period_key,
day_bucket: used_bucket,
base_price: base,
total_guests: total_guests,
extra_guests: extra_guests,
@ -121,6 +190,35 @@ module Captain::Mcp::PricingTables
private
# Recebe Numeric (preço único) ou Hash{bucket=>preço}. Retorna [valor, bucket]
# ou [erro_string, nil] se o bucket pedido não tiver preço cadastrado.
def resolve_price(raw, day_bucket, period_key, cat_key)
return [raw.to_f, nil] if raw.is_a?(Numeric)
return ["Estrutura de preço inválida pra '#{cat_key}/#{period_key}'.", nil] unless raw.is_a?(Hash)
price = raw[day_bucket] || raw[day_bucket.to_s]
if price.blank?
avail = raw.keys.map(&:to_s).join(', ')
return ["'#{cat_key}/#{period_key}' não tem preço pro dia escolhido (#{day_bucket}). Disponível: #{avail}.", nil]
end
[price.to_f, day_bucket]
end
# mon_wed: wday 1,2,3 (seg, ter, qua)
# thu_sun: wday 4,5,6,0 (qui, sex, sáb, dom)
def resolve_day_bucket(check_in_at)
time =
case check_in_at
when nil then Time.current.in_time_zone(DEFAULT_TZ)
when Time, ActiveSupport::TimeWithZone, DateTime then check_in_at.in_time_zone(DEFAULT_TZ)
else Time.zone.parse(check_in_at.to_s)&.in_time_zone(DEFAULT_TZ) || Time.current.in_time_zone(DEFAULT_TZ)
end
[1, 2, 3].include?(time.wday) ? 'mon_wed' : 'thu_sun'
end
def find_category(table, raw)
needle = raw.to_s.downcase.strip.tr('_', ' ').squeeze(' ')
return [nil, nil] if needle.blank?
@ -145,7 +243,7 @@ module Captain::Mcp::PricingTables
when 'diária' then 'diaria'
end
end
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize
end
end
# rubocop:enable Metrics/ModuleLength

View File

@ -3,7 +3,10 @@ json.config resource.config
json.created_at resource.created_at.to_i
json.default_orchestrator_prompt Captain::PromptRenderer.read_template('assistant')
json.description resource.description
json.engine resource.engine
json.guardrails resource.guardrails
json.hermes_profile_name resource.hermes_profile_name
json.hermes_webhook_base_url resource.hermes_webhook_base_url
json.id resource.id
json.name resource.name
json.orchestrator_prompt resource.orchestrator_prompt