merge: align main with hermes production branch

This commit is contained in:
Rodribm10 2026-05-05 17:39:56 -03:00
commit c7d5bbff99
95 changed files with 7649 additions and 62 deletions

View File

@ -0,0 +1,43 @@
# Recebe callback do Hermes Construtor (plugin captain-http-callback).
#
# Construtor responde async via POST pra esta URL com:
# { content: "<resposta>", reply_to: ..., metadata: {...}, timestamp: ... }
#
# Este controller identifica a sessão do admin (por session_id no metadata
# OU pelo cache key derivado de account_id que veio na query string) e
# armazena a resposta no Rails.cache pra UI poder ler via polling.
class Webhooks::Captain::HermesBuilderCallbackController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
def process_payload
content = params[:content].to_s.strip
return head :bad_request if content.blank?
session_key = resolve_session_key
if session_key.blank?
Rails.logger.warn('[HermesBuilder::Callback] no session_key resolvable — ignorando')
return head :ok
end
HermesBuilder::Storage.append(session_key, role: 'construtor', content: content)
Rails.logger.info("[HermesBuilder::Callback] reply received for #{session_key} (#{content.length} chars)")
head :ok
rescue StandardError => e
Rails.logger.error("[HermesBuilder::Callback] error: #{e.class}: #{e.message}")
head :internal_server_error
end
private
# Hermes nao propaga chat_id no metadata da resposta de callback, entao
# usamos a ultima sessao ativa do account (gravada por
# HermesBuilder::Storage.remember_last_session no /start e /create).
# MVP-safe pra 1 admin por vez por conta.
def resolve_session_key
account_id = params[:account_id]
return nil if account_id.blank?
HermesBuilder::Storage.last_session_for(account_id)
end
end

View File

@ -0,0 +1,226 @@
# Recebe o callback do Hermes Agent via plugin captain-http-callback.
#
# Fluxo:
# 1. Captain::Hermes::Client dispara mensagem do cliente pro Hermes
# (POST /webhooks/captain-inbox-<id> no gateway do Hermes).
# 2. Hermes processa via subscription Codex/etc dele.
# 3. Hermes invoca o plugin captain-http-callback que POSTa nesta URL:
# POST /webhooks/captain/hermes_callback?inbox_id=<id>
# Body: { "content": "<resposta>", "reply_to": ..., "metadata": {...}, "timestamp": ... }
# 4. Este controller cria a mensagem outgoing na conversation correta.
#
# Identificação da conversation: como o Hermes não preserva metadata customizado
# de forma confiável, identificamos pela ÚLTIMA conversation pending da inbox
# que recebeu mensagem nos últimos 5 minutos. Aceitável pra PoC com 1 conversa
# de teste por vez. Pra produção, melhorar com Redis: delivery_id → conversation_id.
class Webhooks::Captain::HermesCallbackController < ApplicationController
RECENT_WINDOW = 5.minutes
# "Um momento — vou verificar" é a frase-âncora de handoff intencional
# (quando o agente não sabe responder e quer escalar pra humano). NÃO
# bloqueamos — entregamos pro cliente e marcamos triagem_humana pra
# próximas msgs não dispararem Hermes.
HANDOFF_PATTERNS = [
/\A\s*[⏳⌛]?\s*um\s+momento.*verificar/i,
/\A\s*[⏳⌛]?\s*um\s+instante.*verificar/i,
/\A\s*aguarde\s+um\s+instante/i
].freeze
# Loop detection: 2 sinais combinados.
# 1. Jaccard de tokens >= 0.50 → resposta praticamente igual.
# 2. >= 3 palavras-chave em comum (sem stopwords) E ambas inquisitivas →
# repetiu pergunta sobre o mesmo tópico.
LOOP_SIMILARITY_THRESHOLD = 0.50
LOOP_TOPIC_KEYWORD_OVERLAP = 3
LOOP_STOPWORDS = %w[
voce voces para por pra como mas isso esse essa estou esta este aqui ali
eles elas tem ter tinha tendo era ser sou foi fui agora ainda ja muito mais
quer quero queria pode posso podia consegue consigo conseguia preciso precisar
sim nao não talvez bom boa olha veja oi ola ola tchau certo ok blz beleza
obrigado obrigada valeu vlw thanks por favor please
apenas somente algum alguma quem onde quando o a os as do da dos das no na nos nas
em com sem sob sobre antes apos depois entre meio tudo todo toda
perfeito otimo certinho confirma confirme
].freeze
skip_before_action :verify_authenticity_token, raise: false
before_action :verify_signature
before_action :fetch_inbox
def process_payload
content = extract_content
return head :bad_request if content.blank?
conversation = recent_conversation_for(@inbox)
return log_no_conversation_and_ack if conversation.blank?
log_reply(conversation, content)
detect_handoff_or_loop(conversation, content)
deliver_outgoing(conversation, content)
head :ok
rescue StandardError => e
Rails.logger.error "[Hermes::Callback] error: #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
head :internal_server_error
end
private
# Hermes mandou frase-âncora de handoff: entrega ao cliente normalmente,
# mas marca conv pra triagem humana — próximas msgs não disparam Hermes
# de novo (guard em OutgoingJob). OU: detectou loop (mesma resposta /
# pergunta reformulada) e escala.
def detect_handoff_or_loop(conversation, content)
if handoff_response?(content)
mark_for_human_triage(conversation, reason: 'handoff_intencional')
elsif looped_response?(conversation, content)
mark_for_human_triage(conversation, reason: 'loop_detectado')
end
end
def deliver_outgoing(conversation, content)
if defined?(Captain::Hermes::DelayedReplyJob)
Captain::Hermes::DelayedReplyJob.perform_later(conversation.id, content)
else
create_outgoing_message(conversation, content)
end
end
def handoff_response?(content)
return false if content.blank?
HANDOFF_PATTERNS.any? { |re| content.match?(re) }
end
# Detecta loop: a resposta atual do Hermes é muito parecida com a anterior
# outgoing dele na mesma conv (Jaccard de tokens >= 0.50). Sinaliza que o
# agente está repetindo pergunta/resposta sem progredir — geralmente
# cliente fora do escopo (operadora telefonia, banco, suporte de outro
# app, etc) OU fluxo travado.
def looped_response?(conversation, content)
prev = conversation.messages
.where(message_type: :outgoing)
.where("content_attributes ->> 'external_source' = ?", 'hermes_callback')
.reorder(created_at: :desc)
.limit(1)
.pick(:content)
return false if prev.blank?
return true if similarity(content, prev) >= LOOP_SIMILARITY_THRESHOLD
repeated_question?(content, prev)
end
def similarity(text_a, text_b)
set_a = tokenize(text_a)
set_b = tokenize(text_b)
return 0.0 if set_a.empty? || set_b.empty?
intersection = (set_a & set_b).size
union = (set_a | set_b).size
intersection.to_f / union
end
# Pergunta/confirmação reformulada sobre o mesmo tópico. Detecta tanto "?"
# quanto formas imperativas comuns ("me confirma", "qual", "quer").
def repeated_question?(text_a, text_b)
return false unless inquisitive?(text_a) && inquisitive?(text_b)
keywords_a = tokenize(text_a) - LOOP_STOPWORDS
keywords_b = tokenize(text_b) - LOOP_STOPWORDS
(keywords_a & keywords_b).size >= LOOP_TOPIC_KEYWORD_OVERLAP
end
INQUISITIVE_REGEX = /(\?|\bme\s+confirm|\bvoce\s+(prefere|quer)|\bqual\s+(prefere|deseja|seria)|\bquer\s+(que|saber|ver|um|uma))/i
def inquisitive?(text)
INQUISITIVE_REGEX.match?(ActiveSupport::Inflector.transliterate(text.to_s))
end
def tokenize(text)
normalized = ActiveSupport::Inflector.transliterate(text.to_s.downcase)
normalized.scan(/[a-z0-9]+/).reject { |w| w.length < 3 }.to_set
end
def mark_for_human_triage(conversation, reason: nil)
current = conversation.label_list
conversation.update_labels((current + %w[triagem_humana]).uniq)
Rails.logger.info("[Hermes::Callback] conv #{conversation.display_id} → triagem_humana (#{reason})")
end
def fetch_inbox
inbox_id = params[:inbox_id].presence || params.dig(:metadata, :inbox_id).presence
if inbox_id.present?
@inbox = Inbox.find_by(id: inbox_id)
elsif (slug = params[:slug].presence)
# Resolve via slug (hermes_profile_name) — admin pode re-apontar a
# inbox pra qualquer agente Hermes sem mexer em URL de callback.
asst = Captain::Assistant.find_by(hermes_profile_name: slug, engine: 'hermes')
ci = asst&.captain_inboxes&.first
@inbox = ci&.inbox
end
head :not_found if @inbox.blank?
end
def verify_signature
secret = Captain::Hermes.callback_signing_secret
return true if secret.blank? # validação desabilitada (PoC sem secret)
signature = request.headers['X-Hermes-Callback-Signature'].to_s
return head :unauthorized if signature.blank?
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
true
end
def recent_conversation_for(inbox)
inbox.conversations
.where('updated_at >= ?', RECENT_WINDOW.ago)
.where(status: %w[pending open])
.reorder(updated_at: :desc)
.first
end
def log_no_conversation_and_ack
Rails.logger.warn "[Hermes::Callback] no recent conversation for inbox #{@inbox.id} — ignorando callback"
head :ok
end
def extract_content
normalize_for_whatsapp(params[:content].to_s.strip)
end
# Converte markdown padrão (que LLMs default usam) pra formato WhatsApp:
# **negrito** -> *negrito*
# WhatsApp usa single asterisk pra bold; double asterisk aparece literal
# pro cliente, parecendo bug. Defesa caso o SOUL.md não convença o LLM.
def normalize_for_whatsapp(content)
return content if content.blank?
content.gsub(/\*\*([^*\n]+?)\*\*/, '*\1*')
end
def log_reply(conversation, content)
Rails.logger.info(
"[Hermes::Callback] reply received for conv #{conversation.display_id} (#{content.length} chars)"
)
end
def create_outgoing_message(conversation, content)
assistant = conversation.inbox.captain_assistant
sender = assistant.presence || User.find_by(id: conversation.assignee_id)
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
sender: sender,
content: content,
content_attributes: {
external_source: 'hermes_callback'
}
)
end
end

View File

@ -0,0 +1,110 @@
# Endpoint MCP (Model Context Protocol) HTTP do Captain.
#
# POST /webhooks/captain/mcp
#
# Hermes Agent (e qualquer cliente MCP) conecta aqui pra invocar tools do
# Captain (add_label, faq_lookup, generate_pix, etc).
#
# Conexão pelo Hermes:
# hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp
#
# Auth: aceita 2 modos (qualquer um basta):
# - Bearer token (padrão MCP, recomendado): `Authorization: Bearer <CAPTAIN_MCP_SECRET>`
# É o que `hermes mcp add --auth header` usa nativamente.
# - HMAC-SHA256 do body: `X-Hub-Signature-256: sha256=<hex>`
# Para clientes que preferem assinar o body inteiro.
# Secret compartilhado via env var `CAPTAIN_MCP_SECRET`. Quando vazio,
# validação é desabilitada (PoC/dev).
#
# Multi-tenant: o cliente MCP pode mandar contexto (conversation_id,
# inbox_id, account_id) num campo de extensão chamado `_captain_context`
# dentro de `params` do JSON-RPC. Tools que precisam (add_label etc) leem
# esse contexto pra resolver a conversa correta.
class Webhooks::Captain::McpController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
before_action :verify_signature
def process_payload
request_body = parse_request_body
return head :bad_request if request_body.blank?
response = Captain::Mcp::Server.handle(
request_body,
context: extract_context(request_body)
)
return head :ok if response.nil? # MCP notifications
render json: response
rescue StandardError => e
Rails.logger.error "[Captain::Mcp] error: #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
render json: { jsonrpc: '2.0', error: { code: -32_603, message: 'Internal error' } }, status: :internal_server_error
end
private
def parse_request_body
JSON.parse(request.raw_post)
rescue JSON::ParserError
nil
end
def verify_signature
secret = ENV.fetch('CAPTAIN_MCP_SECRET', nil)
return true if secret.blank?
return true if bearer_token_matches?(secret)
return true if hmac_signature_matches?(secret)
head :unauthorized
end
def bearer_token_matches?(secret)
auth_header = request.headers['Authorization'].to_s
return false unless auth_header.start_with?('Bearer ')
token = auth_header.delete_prefix('Bearer ').strip
ActiveSupport::SecurityUtils.secure_compare(token, secret)
end
def hmac_signature_matches?(secret)
signature = request.headers['X-Hub-Signature-256'].to_s
return false if signature.blank?
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
ActiveSupport::SecurityUtils.secure_compare(signature, expected)
end
# Cliente MCP pode mandar contexto multi-tenant em params._captain_context.
# Hermes inclui isso quando chama uma tool, pra Captain saber qual conversation
# é (já que MCP em si é stateless entre client/server).
#
# Fallback: cada profile do Hermes está atrelado a uma unidade
# (Valentina → Dolce Amore, Jasmine → Prime AL, etc), então também aceitamos
# contexto via headers HTTP fixos no config.yaml do profile:
# X-Captain-Account-Id, X-Captain-Assistant-Id, X-Captain-Inbox-Id.
# Body wins se houver conflito (override por chamada).
def extract_context(request_body)
params = request_body['params'] || {}
body_ctx = params['_captain_context'] || {}
body_ctx = {} unless body_ctx.is_a?(Hash)
extract_header_context.merge(body_ctx.symbolize_keys)
end
def extract_header_context
{
account_id: header_int('X-Captain-Account-Id'),
assistant_id: header_int('X-Captain-Assistant-Id'),
inbox_id: header_int('X-Captain-Inbox-Id')
}.compact
end
def header_int(name)
value = request.headers[name].to_s
return nil if value.blank?
value.to_i
end
end

View File

@ -0,0 +1,38 @@
/* global axios */
import ApiClient from '../ApiClient';
class HermesBuilder extends ApiClient {
constructor() {
super('captain/hermes_builder', { accountScoped: true });
}
fetchMessages() {
return axios.get(this.url);
}
sendMessage(text) {
return axios.post(this.url, { text });
}
start() {
return axios.post(`${this.url}/start`);
}
reset() {
return axios.delete(`${this.url}/reset`);
}
fetchAssistants() {
return axios.get(`${this.url}/assistants`);
}
validate(slug) {
return axios.get(`${this.url}/validate`, { params: { slug } });
}
repair(slug, repairId) {
return axios.post(`${this.url}/repair`, { slug, repair_id: repairId });
}
}
export default new HermesBuilder();

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

@ -424,6 +424,12 @@ const menuItems = computed(() => {
activeOn: ['captain_roleta_index'],
to: accountScopedRoute('captain_roleta_index'),
},
{
name: 'HermesBuilder',
label: t('SIDEBAR.CAPTAIN_HERMES_BUILDER'),
activeOn: ['captain_hermes_builder_index'],
to: accountScopedRoute('captain_hermes_builder_index'),
},
{
name: 'Funnel',
label: t('SIDEBAR.CAPTAIN_FUNNEL'),

View File

@ -119,11 +119,17 @@ export default {
openConversation(alert) {
// Clica no item abre conversa E esconde o alerta dela (mas se
// não responder, volta a aparecer no próximo threshold).
// Param tem que ser `conversation_id` (snake_case, como
// declarado no path da rota); camelCase faz Vue Router não casar
// e cair em "selecione uma conversa".
aggressiveAlert.dismiss(alert.id);
if (!this.currentAccountId) return;
this.$router.push({
name: 'inbox_conversation',
params: { accountId: this.currentAccountId, conversationId: alert.id },
params: {
accountId: this.currentAccountId,
conversation_id: alert.id,
},
});
},
dismissOne(alert) {

View File

@ -891,5 +891,49 @@
"RESERVATION_ID": "Reservation #"
}
}
},
"CAPTAIN_HERMES_BUILDER": {
"TITLE": "Agent Builder",
"DESCRIPTION": "Create new Hermes agents through a guided chat with the Builder.",
"HEADER_TITLE": "Agent Builder",
"HEADER_DESCRIPTION": "Chat with the Builder to create a new Hermes agent. It asks questions and saves the spec as JSON for review at the end.",
"RESET": "Clear conversation",
"RESET_CONFIRM": "Clear current conversation with the Builder?",
"EMPTY_STATE": "Ready to create a new Hermes agent? Click \"Start creation\" and the Builder will guide you.",
"PLACEHOLDER": "Type and press Enter to send (Shift+Enter for new line)",
"SEND": "Send",
"SESSION_LABEL": "Session:",
"SEND_FAILED": "Send failed: {message}",
"RESET_FAILED": "Failed to clear session.",
"START": "Start creation",
"TAB_CHAT": "Chat (Builder)",
"TAB_VERIFY": "Verification",
"VERIFY": {
"TITLE": "Agent verification",
"DESCRIPTION": "Runs health checks (database, routing, pricing, MCP) for a Hermes agent. For each failure with a Repair button, the UI attempts an automatic fix. Other failures need hermes-provision on the VPS.",
"NO_ASSISTANTS": "No Hermes agents registered",
"RUN": "Run check",
"RUNNING": "Checking...",
"REPAIR": "Repair",
"REPAIRING": "Repairing...",
"OK_LABEL": "OK",
"FAILS_LABEL": "failures",
"WARN_LABEL": "warnings",
"OF_TOTAL": "of {total} checks",
"VERDICT_PASS": "Ready to ship",
"VERDICT_FAIL": "Critical failures — fix first",
"EMPTY": "Select an agent and click Run check to start verification.",
"EMPTY_RESULTS": "No checks returned — agent removed?",
"REPAIR_FAILED": "Failed: {message}",
"REPAIR_OK": "Repaired: {message}",
"FETCH_FAILED": "Error loading assistants: {message}",
"VALIDATE_FAILED": "Validation failed: {message}",
"CATEGORY_DB": "Database",
"CATEGORY_PRICING": "Pricing",
"CATEGORY_ROUTING": "Captain → Hermes routing",
"CATEGORY_HUMANIZATION": "Humanization (typing/delay/gallery)",
"CATEGORY_MCP": "Registered MCP tools",
"CATEGORY_OTHER": "Other"
}
}
}
}

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

@ -121,6 +121,15 @@
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
},
"AGGRESSIVE_ALERT_SECTION": {
"TITLE": "Stalled conversation alert",
"NOTE": "Red banner that appears at the top of the panel when a conversation has been waiting for a reply for 5+ minutes.",
"DESCRIPTION": "Red banner shown when a conversation has no reply for 5+ minutes. Useful to avoid losing customers, but can be intrusive if you don't handle every inbox.",
"ENABLED": "Enable stalled conversation alert",
"APPLY_TO_ALL": "Apply to all inboxes",
"INBOX_HINT": "Pick the inboxes where you want to receive the alert:",
"NO_INBOXES": "No inboxes registered."
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Alerts",
"NOTE": "Enable audio alerts in dashboard for new messages and conversations.",
@ -350,6 +359,7 @@
"CAPTAIN_GALLERY": "Gallery",
"CAPTAIN_RESERVATIONS": "Reservations",
"CAPTAIN_ROLETA": "Roulette — Redeem",
"CAPTAIN_HERMES_BUILDER": "Builder (Hermes)",
"CAPTAIN_FUNNEL": "Conversion Funnel",
"CAPTAIN_LIFECYCLE": "Customer Journey",
"CAPTAIN_REPORTS": "AI Reports",

View File

@ -45,7 +45,7 @@
"HIDE": "Ocultar filtros"
},
"KPI": {
"TOTAL": "Total na página",
"TOTAL": "Total filtrado",
"PENDING_PIX": "Aguardando PIX",
"CHECKIN_TODAY": "Check-in hoje",
"REVENUE_TODAY": "Receita hoje"
@ -892,5 +892,49 @@
"RESERVATION_ID": "Reserva #"
}
}
},
"CAPTAIN_HERMES_BUILDER": {
"TITLE": "Construtor de Agentes",
"DESCRIPTION": "Crie novos agentes Hermes via chat guiado com o Construtor.",
"HEADER_TITLE": "Construtor de Agentes",
"HEADER_DESCRIPTION": "Converse com o Construtor pra criar um novo agente Hermes. Ele faz perguntas e ao final salva a especificação em JSON pra revisão.",
"RESET": "Limpar conversa",
"RESET_CONFIRM": "Limpar conversa atual com o Construtor?",
"EMPTY_STATE": "Pronto pra criar um novo agente Hermes? Clica em \"Iniciar criação\" e o Construtor te guia.",
"PLACEHOLDER": "Escreva e Enter pra enviar (Shift+Enter pula linha)",
"SEND": "Enviar",
"SESSION_LABEL": "Sessão:",
"SEND_FAILED": "Erro ao enviar: {message}",
"RESET_FAILED": "Falha ao limpar sessão.",
"START": "Iniciar criação",
"TAB_CHAT": "Chat (Construtor)",
"TAB_VERIFY": "Verificação",
"VERIFY": {
"TITLE": "Verificação de agente",
"DESCRIPTION": "Roda os checks de saúde (banco, roteamento, preços, MCP) de um agente Hermes. Para cada falha com botão Refazer, a UI tenta corrigir automaticamente. Demais falhas precisam de hermes-provision na VPS.",
"NO_ASSISTANTS": "Nenhum agente Hermes cadastrado",
"RUN": "Conferir agora",
"RUNNING": "Conferindo...",
"REPAIR": "Refazer",
"REPAIRING": "Reparando...",
"OK_LABEL": "OK",
"FAILS_LABEL": "falhas",
"WARN_LABEL": "atenção",
"OF_TOTAL": "de {total} checks",
"VERDICT_PASS": "Pode soltar",
"VERDICT_FAIL": "Há falhas críticas — corrija antes",
"EMPTY": "Selecione um agente e clique em Conferir agora pra rodar a verificação.",
"EMPTY_RESULTS": "Sem checks retornados — o agente foi removido?",
"REPAIR_FAILED": "Falha: {message}",
"REPAIR_OK": "Reparado: {message}",
"FETCH_FAILED": "Erro carregando assistentes: {message}",
"VALIDATE_FAILED": "Falha ao validar: {message}",
"CATEGORY_DB": "Banco de dados",
"CATEGORY_PRICING": "Preços",
"CATEGORY_ROUTING": "Roteamento Captain → Hermes",
"CATEGORY_HUMANIZATION": "Humanização (typing/delay/galeria)",
"CATEGORY_MCP": "Tools MCP registradas",
"CATEGORY_OTHER": "Outros"
}
}
}

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

@ -121,6 +121,15 @@
"RESET_SUCCESS": "Token de acesso gerado novamente com sucesso",
"RESET_ERROR": "Não foi possível regerar o token de acesso. Por favor, tente novamente"
},
"AGGRESSIVE_ALERT_SECTION": {
"TITLE": "Alerta de conversa parada",
"NOTE": "Banner vermelho que aparece no topo do painel quando uma conversa fica sem resposta há 5+ minutos.",
"DESCRIPTION": "Banner vermelho que aparece quando uma conversa fica sem resposta há 5+ minutos. Útil pra não perder cliente, mas pode ser intrusivo se você não atende todas as inboxes.",
"ENABLED": "Ativar alerta de conversa parada",
"APPLY_TO_ALL": "Aplicar em todas as caixas de entrada",
"INBOX_HINT": "Selecione as caixas onde você quer receber o alerta:",
"NO_INBOXES": "Nenhuma caixa de entrada cadastrada."
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Alertas de áudio",
"NOTE": "Habilitar notificações de áudio no painel para novas mensagens e conversas.",
@ -349,6 +358,7 @@
"CAPTAIN_GALLERY": "Galeria",
"CAPTAIN_RESERVATIONS": "Reservas",
"CAPTAIN_ROLETA": "Roleta — Resgate",
"CAPTAIN_HERMES_BUILDER": "Construtor (Hermes)",
"CAPTAIN_FUNNEL": "Funil de Conversão",
"CAPTAIN_LIFECYCLE": "Jornada do Cliente",
"CAPTAIN_REPORTS": "Relatórios IA",

View File

@ -0,0 +1,362 @@
<script setup>
import {
ref,
computed,
onMounted,
onBeforeUnmount,
nextTick,
watch,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
const { t } = useI18n();
const messages = ref([]);
const input = ref('');
const sending = ref(false);
const polling = ref(null);
const scrollContainer = ref(null);
const sessionId = ref(null);
const lastMessageRole = computed(() => messages.value.at(-1)?.role || null);
const isWaiting = computed(
() => sending.value || lastMessageRole.value === 'user'
);
const scrollToBottom = () => {
const el = scrollContainer.value;
if (el) el.scrollTop = el.scrollHeight;
};
const fetchMessages = async () => {
try {
const { data } = await hermesBuilderApi.fetchMessages();
messages.value = data.messages || [];
sessionId.value = data.session_id;
await nextTick();
scrollToBottom();
} catch (e) {
// silencioso polling repete
}
};
const sendMessage = async () => {
const text = input.value.trim();
if (!text || sending.value) return;
sending.value = true;
messages.value.push({
role: 'user',
content: text,
created_at: new Date().toISOString(),
});
input.value = '';
await nextTick();
scrollToBottom();
try {
await hermesBuilderApi.sendMessage(text);
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
sending.value = false;
}
};
const handleKeydown = e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const resetSession = async () => {
// eslint-disable-next-line no-alert
if (!window.confirm(t('CAPTAIN_HERMES_BUILDER.RESET_CONFIRM'))) return;
try {
await hermesBuilderApi.reset();
messages.value = [];
} catch (e) {
useAlert(t('CAPTAIN_HERMES_BUILDER.RESET_FAILED'));
}
};
const startSession = async () => {
if (sending.value) return;
sending.value = true;
try {
await hermesBuilderApi.start();
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
sending.value = false;
}
};
const formatTime = iso => {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
};
onMounted(() => {
fetchMessages();
polling.value = setInterval(fetchMessages, 2000);
});
onBeforeUnmount(() => {
if (polling.value) clearInterval(polling.value);
});
watch(messages, () => nextTick().then(scrollToBottom), { deep: true });
</script>
<template>
<div class="builder-wrapper">
<header class="builder-header">
<div>
<h2>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_TITLE') }}</h2>
<p>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_DESCRIPTION') }}</p>
</div>
<Button variant="ghost" size="sm" @click="resetSession">
{{ t('CAPTAIN_HERMES_BUILDER.RESET') }}
</Button>
</header>
<section ref="scrollContainer" class="messages">
<div v-if="!messages.length" class="empty-state">
<p>{{ t('CAPTAIN_HERMES_BUILDER.EMPTY_STATE') }}</p>
<button
type="button"
class="start-button"
:disabled="sending"
@click="startSession"
>
{{ t('CAPTAIN_HERMES_BUILDER.START') }}
</button>
</div>
<div
v-for="(msg, idx) in messages"
:key="idx"
class="msg"
:class="[`msg--${msg.role}`]"
>
<div class="msg__bubble">
<div class="msg__content">{{ msg.content }}</div>
<div class="msg__meta">{{ formatTime(msg.created_at) }}</div>
</div>
</div>
<div v-if="isWaiting" class="msg msg--construtor">
<div class="msg__bubble msg__bubble--typing">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
</div>
</section>
<footer class="composer">
<textarea
v-model="input"
rows="2"
:placeholder="t('CAPTAIN_HERMES_BUILDER.PLACEHOLDER')"
:disabled="sending"
@keydown="handleKeydown"
/>
<Button
variant="primary"
:disabled="!input.trim() || sending"
@click="sendMessage"
>
{{ t('CAPTAIN_HERMES_BUILDER.SEND') }}
</Button>
</footer>
<p v-if="sessionId" class="session-debug">
{{ t('CAPTAIN_HERMES_BUILDER.SESSION_LABEL') }} {{ sessionId }}
</p>
</div>
</template>
<style scoped lang="scss">
.builder-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
height: calc(100vh - 260px);
max-width: 900px;
margin: 0 auto;
}
.builder-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px 20px;
background: var(--color-background-light, #f7f8fa);
border-radius: 12px;
h2 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
}
p {
margin: 0;
color: var(--color-text-light, #6b7280);
font-size: 13px;
}
}
.messages {
flex: 1;
overflow-y: auto;
padding: 16px;
background: var(--color-background, #fff);
border-radius: 12px;
border: 1px solid var(--color-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 12px;
}
.empty-state {
margin: auto;
color: var(--color-text-light, #9ca3af);
font-size: 14px;
text-align: center;
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
p {
margin: 0;
}
}
.start-button {
background: var(--color-woot-500, #1f93ff);
color: #fff;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:hover:not(:disabled) {
background: var(--color-woot-600, #1976d2);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.msg {
display: flex;
&--user {
justify-content: flex-end;
}
&--construtor {
justify-content: flex-start;
}
}
.msg__bubble {
max-width: 70%;
padding: 10px 14px;
border-radius: 14px;
background: var(--color-background-light, #f3f4f6);
font-size: 14px;
.msg--user & {
background: var(--color-woot-500, #1f93ff);
color: #fff;
}
}
.msg__content {
white-space: pre-wrap;
word-break: break-word;
}
.msg__meta {
font-size: 11px;
margin-top: 4px;
opacity: 0.7;
}
.msg__bubble--typing {
display: flex;
gap: 4px;
padding: 12px 16px;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-text-light, #6b7280);
animation: typing 1.4s infinite ease-in-out;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes typing {
0%,
60%,
100% {
opacity: 0.3;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-3px);
}
}
.composer {
display: flex;
gap: 8px;
padding: 12px;
background: var(--color-background, #fff);
border-radius: 12px;
border: 1px solid var(--color-border, #e5e7eb);
textarea {
flex: 1;
border: none;
resize: none;
outline: none;
font: inherit;
background: transparent;
color: inherit;
}
}
.session-debug {
font-size: 11px;
color: var(--color-text-light, #9ca3af);
text-align: right;
margin: 0;
}
</style>

View File

@ -0,0 +1,443 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
const { t } = useI18n();
const assistants = ref([]);
const selectedSlug = ref('');
const checks = ref([]);
const summary = ref(null);
const loading = ref(false);
const repairing = ref({});
const groupedChecks = computed(() => {
const groups = {};
checks.value.forEach(c => {
const cat = c.category || 'outros';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(c);
});
return groups;
});
const categoryLabel = cat => {
const map = {
db: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_DB',
pricing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_PRICING',
routing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_ROUTING',
humanization: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_HUMANIZATION',
mcp: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_MCP',
};
return t(map[cat] || 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_OTHER');
};
const fetchAssistants = async () => {
try {
const { data } = await hermesBuilderApi.fetchAssistants();
assistants.value = data.assistants || [];
if (assistants.value.length && !selectedSlug.value) {
selectedSlug.value = assistants.value[0].slug;
}
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.FETCH_FAILED', {
message: e.message || 'unknown',
})
);
}
};
const runValidation = async () => {
if (!selectedSlug.value || loading.value) return;
loading.value = true;
checks.value = [];
summary.value = null;
try {
const { data } = await hermesBuilderApi.validate(selectedSlug.value);
checks.value = data.results || [];
summary.value = data;
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.VALIDATE_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
loading.value = false;
}
};
const runRepair = async check => {
if (!check.repair_id) return;
repairing.value[check.repair_id] = true;
try {
const { data } = await hermesBuilderApi.repair(
selectedSlug.value,
check.repair_id
);
if (data.ok) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_OK', {
message: data.message || 'OK',
})
);
await runValidation();
} else {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
message: data.error || 'unknown',
})
);
}
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
repairing.value[check.repair_id] = false;
}
};
const statusIcon = status => {
if (status === 'PASS') return '✓';
if (status === 'FAIL') return '✗';
if (status === 'WARN') return '⚠';
return '?';
};
const statusClass = status => {
if (status === 'PASS') return 'badge--pass';
if (status === 'FAIL') return 'badge--fail';
if (status === 'WARN') return 'badge--warn';
return 'badge--unknown';
};
onMounted(() => {
fetchAssistants();
});
</script>
<template>
<div class="verification-wrapper">
<header class="verification-header">
<h2>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.TITLE') }}</h2>
<p>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.DESCRIPTION') }}</p>
</header>
<div class="controls">
<select
v-model="selectedSlug"
class="select"
:disabled="!assistants.length || loading"
>
<option v-if="!assistants.length" value="">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.NO_ASSISTANTS') }}
</option>
<option v-for="a in assistants" :key="a.id" :value="a.slug">
{{ a.name }} {{ a.slug }}
</option>
</select>
<Button
variant="primary"
:disabled="!selectedSlug || loading"
@click="runValidation"
>
{{
loading
? t('CAPTAIN_HERMES_BUILDER.VERIFY.RUNNING')
: t('CAPTAIN_HERMES_BUILDER.VERIFY.RUN')
}}
</Button>
</div>
<div v-if="summary" class="summary">
<span class="summary__item summary__item--pass">
{{ summary.pass }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.OK_LABEL') }}
</span>
<span v-if="summary.fail" class="summary__item summary__item--fail">
{{ summary.fail }}
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.FAILS_LABEL') }}
</span>
<span v-if="summary.warn" class="summary__item summary__item--warn">
{{ summary.warn }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.WARN_LABEL') }}
</span>
<span class="summary__total">
{{
t('CAPTAIN_HERMES_BUILDER.VERIFY.OF_TOTAL', { total: summary.total })
}}
</span>
<span v-if="summary.ok" class="summary__verdict summary__verdict--pass">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_PASS') }}
</span>
<span v-else class="summary__verdict summary__verdict--fail">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_FAIL') }}
</span>
</div>
<section v-if="checks.length" class="checks-section">
<div v-for="(items, cat) in groupedChecks" :key="cat" class="check-group">
<h3 class="check-group__title">
{{ categoryLabel(cat) }}
</h3>
<ul class="check-list">
<li
v-for="(check, idx) in items"
:key="idx"
class="check-item"
:class="`check-item--${check.status.toLowerCase()}`"
>
<span class="check-item__badge" :class="statusClass(check.status)">
{{ statusIcon(check.status) }}
</span>
<div class="check-item__body">
<div class="check-item__label">{{ check.label }}</div>
<div v-if="check.detail" class="check-item__detail">
{{ check.detail }}
</div>
</div>
<button
v-if="
check.repair_id &&
(check.status === 'FAIL' || check.status === 'WARN')
"
type="button"
class="repair-btn"
:disabled="repairing[check.repair_id]"
@click="runRepair(check)"
>
{{
repairing[check.repair_id]
? t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIRING')
: t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR')
}}
</button>
</li>
</ul>
</div>
</section>
<p v-else-if="!loading && summary" class="empty-state">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY_RESULTS') }}
</p>
<p v-else-if="!loading" class="empty-state">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY') }}
</p>
</div>
</template>
<style scoped lang="scss">
.verification-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 1000px;
margin: 0 auto;
height: calc(100vh - 260px);
overflow-y: auto;
padding-right: 8px;
}
.verification-header {
padding: 16px 20px;
background: var(--color-background-light, #f7f8fa);
border-radius: 12px;
h2 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
}
p {
margin: 0;
color: var(--color-text-light, #6b7280);
font-size: 13px;
line-height: 1.5;
}
}
.controls {
display: flex;
gap: 12px;
align-items: center;
.select {
flex: 1;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
background: var(--color-background, #fff);
font-size: 14px;
outline: none;
&:focus {
border-color: var(--color-woot-500, #1f93ff);
}
}
}
.summary {
display: flex;
gap: 16px;
align-items: center;
padding: 12px 16px;
background: var(--color-background, #fff);
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
font-size: 13px;
flex-wrap: wrap;
&__item {
font-weight: 600;
&--pass {
color: #16a34a;
}
&--fail {
color: #dc2626;
}
&--warn {
color: #d97706;
}
}
&__total {
color: var(--color-text-light, #6b7280);
}
&__verdict {
margin-left: auto;
font-weight: 600;
&--pass {
color: #16a34a;
}
&--fail {
color: #dc2626;
}
}
}
.checks-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.check-group {
background: var(--color-background, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 12px;
padding: 12px 16px;
&__title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-light, #6b7280);
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.check-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.check-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 4px;
border-radius: 6px;
font-size: 13px;
&--fail {
background: #fef2f2;
}
&--warn {
background: #fffbeb;
}
}
.check-item__badge {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
color: #fff;
&.badge--pass {
background: #16a34a;
}
&.badge--fail {
background: #dc2626;
}
&.badge--warn {
background: #d97706;
}
}
.check-item__body {
flex: 1;
min-width: 0;
}
.check-item__label {
font-weight: 500;
}
.check-item__detail {
margin-top: 2px;
color: var(--color-text-light, #6b7280);
font-size: 12px;
word-break: break-word;
}
.repair-btn {
flex-shrink: 0;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--color-woot-500, #1f93ff);
background: var(--color-background, #fff);
color: var(--color-woot-500, #1f93ff);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
background: var(--color-woot-500, #1f93ff);
color: #fff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.empty-state {
text-align: center;
color: var(--color-text-light, #9ca3af);
font-size: 14px;
padding: 32px;
margin: 0;
}
</style>

View File

@ -0,0 +1,51 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import BuilderChat from './BuilderChat.vue';
import BuilderVerification from './BuilderVerification.vue';
const { t } = useI18n();
const tabs = computed(() => [
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_CHAT'), key: 'chat' },
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_VERIFY'), key: 'verification' },
]);
const activeIndex = ref(0);
const handleTabChanged = tab => {
activeIndex.value = tabs.value.findIndex(item => item.key === tab.key);
};
</script>
<template>
<PageLayout
:title="t('CAPTAIN_HERMES_BUILDER.TITLE')"
:description="t('CAPTAIN_HERMES_BUILDER.DESCRIPTION')"
>
<div class="builder-tabs">
<TabBar
:tabs="tabs"
:initial-active-tab="activeIndex"
@tab-changed="handleTabChanged"
/>
</div>
<div class="builder-panels">
<BuilderChat v-show="activeIndex === 0" />
<BuilderVerification v-show="activeIndex === 1" />
</div>
</PageLayout>
</template>
<style scoped lang="scss">
.builder-tabs {
margin-bottom: 16px;
}
.builder-panels {
display: flex;
flex-direction: column;
}
</style>

View File

@ -18,6 +18,7 @@ import ResponsesPendingIndex from './responses/Pending.vue';
import CustomToolsIndex from './tools/Index.vue';
import ReservationsIndex from './reservations/Index.vue';
import RoletaIndex from './roleta/Index.vue';
import HermesBuilderIndex from './builder/Index.vue';
import FunnelIndex from './funnel/Index.vue';
import LifecycleIndex from './lifecycle/Index.vue';
import LifecycleRules from './lifecycle/Rules.vue';
@ -149,6 +150,19 @@ export const routes = [
name: 'captain_roleta_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/hermes-builder'),
component: HermesBuilderIndex,
name: 'captain_hermes_builder_index',
meta: {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL('accounts/:accountId/captain/funnel'),
component: FunnelIndex,

View File

@ -102,7 +102,7 @@ const groupedReservations = computed(() => {
return groups;
});
const statusCounts = computed(() => {
const pageStatusCounts = computed(() => {
const counts = {
all: reservations.value.length,
draft: 0,
@ -117,6 +117,23 @@ const statusCounts = computed(() => {
return counts;
});
const statusCounts = computed(() => {
const metaCounts = reservationsMeta.value.statusCounts || {};
return {
all: Number(
metaCounts.all ??
reservationsMeta.value.totalCount ??
pageStatusCounts.value.all
),
draft: Number(metaCounts.draft ?? pageStatusCounts.value.draft),
pending_payment: Number(
metaCounts.pending_payment ?? pageStatusCounts.value.pending_payment
),
confirmed: Number(metaCounts.confirmed ?? pageStatusCounts.value.confirmed),
cancelled: Number(metaCounts.cancelled ?? pageStatusCounts.value.cancelled),
};
});
const todayRevenue = computed(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);

View File

@ -58,7 +58,7 @@ export default {
name: 'captain_settings_gallery',
component: GalleryIndex,
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'agent'],
},
},
{
@ -66,7 +66,7 @@ export default {
name: 'captain_settings_gallery_edit',
component: GalleryEdit,
meta: {
permissions: ['administrator'],
permissions: ['administrator', 'agent'],
},
},
{

View File

@ -0,0 +1,233 @@
<script setup>
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed, ref, watch } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const getters = useStoreGetters();
const { uiSettings, updateUISettings } = useUISettings();
const inboxes = computed(() => getters['inboxes/getInboxes'].value || []);
// Modelo: ui_settings.aggressive_alert_inbox_ids
// undefined / null todas as inboxes (default histórico)
// [] desligado pra esse usuário
// [id, id, ...] apenas essas inboxes
const enabled = ref(true);
const selectedInboxIds = ref([]);
const applyToAll = ref(true);
const initFromSettings = settings => {
const raw = settings?.aggressive_alert_inbox_ids;
if (Array.isArray(raw)) {
if (raw.length === 0) {
enabled.value = false;
applyToAll.value = true;
selectedInboxIds.value = [];
} else {
enabled.value = true;
applyToAll.value = false;
selectedInboxIds.value = raw.map(id => Number(id));
}
} else {
enabled.value = true;
applyToAll.value = true;
selectedInboxIds.value = [];
}
};
watch(
uiSettings,
value => {
initFromSettings(value);
},
{ immediate: true }
);
const persist = async () => {
let value;
if (!enabled.value) {
value = [];
} else if (applyToAll.value) {
value = null;
} else {
value = selectedInboxIds.value.map(id => Number(id));
}
try {
await updateUISettings({ aggressive_alert_inbox_ids: value });
useAlert(t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
} catch (e) {
useAlert(t('PROFILE_SETTINGS.FORM.API.UPDATE_ERROR'));
}
};
const handleEnabledChange = event => {
enabled.value = event.target.checked;
persist();
};
const handleApplyToAllChange = event => {
applyToAll.value = event.target.checked;
if (applyToAll.value) {
selectedInboxIds.value = [];
}
persist();
};
const handleInboxToggle = inboxId => {
const id = Number(inboxId);
if (selectedInboxIds.value.includes(id)) {
selectedInboxIds.value = selectedInboxIds.value.filter(i => i !== id);
} else {
selectedInboxIds.value = [...selectedInboxIds.value, id];
}
persist();
};
const isInboxSelected = inboxId =>
selectedInboxIds.value.includes(Number(inboxId));
</script>
<template>
<div class="aggressive-alert-settings flex flex-col gap-4">
<p class="description">
{{
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.DESCRIPTION',
'Banner vermelho que aparece quando uma conversa fica sem resposta há 5+ minutos. Útil pra não perder cliente, mas pode ser intrusivo se você não atende todas as inboxes.'
)
}}
</p>
<label class="toggle-row">
<input
type="checkbox"
:checked="enabled"
class="toggle-input"
@change="handleEnabledChange"
/>
<span class="toggle-label">
{{
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.ENABLED',
'Ativar alerta de conversa parada'
)
}}
</span>
</label>
<div v-if="enabled" class="scope-section">
<label class="toggle-row">
<input
type="checkbox"
:checked="applyToAll"
class="toggle-input"
@change="handleApplyToAllChange"
/>
<span class="toggle-label">
{{
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.APPLY_TO_ALL',
'Aplicar em todas as caixas de entrada'
)
}}
</span>
</label>
<div v-if="!applyToAll" class="inbox-list">
<p class="hint">
{{
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.INBOX_HINT',
'Selecione as caixas onde você quer receber o alerta:'
)
}}
</p>
<label v-for="inbox in inboxes" :key="inbox.id" class="inbox-row">
<input
type="checkbox"
:checked="isInboxSelected(inbox.id)"
class="toggle-input"
@change="handleInboxToggle(inbox.id)"
/>
<span>{{ inbox.name }}</span>
</label>
<p v-if="!inboxes.length" class="empty">
{{
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.NO_INBOXES',
'Nenhuma caixa de entrada cadastrada.'
)
}}
</p>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.aggressive-alert-settings {
max-width: 480px;
}
.description {
color: var(--color-text-light, #6b7280);
font-size: 13px;
line-height: 1.5;
margin: 0;
}
.toggle-row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.toggle-input {
cursor: pointer;
}
.toggle-label {
font-size: 14px;
font-weight: 500;
}
.scope-section {
margin-left: 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.inbox-list {
margin-left: 24px;
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 4px;
}
.inbox-row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
}
.hint {
font-size: 12px;
color: var(--color-text-light, #6b7280);
margin: 0;
}
.empty {
font-size: 12px;
color: var(--color-text-light, #9ca3af);
font-style: italic;
}
</style>

View File

@ -17,6 +17,7 @@ import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import AggressiveAlertSettings from './AggressiveAlertSettings.vue';
import FormSection from 'dashboard/components/FormSection.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
@ -40,6 +41,7 @@ export default {
ChangePassword,
NotificationPreferences,
AudioNotifications,
AggressiveAlertSettings,
AccessToken,
MfaSettingsCard,
AggressiveAlertProfileSetting,
@ -336,6 +338,22 @@ export default {
<AudioNotifications />
</FormSection>
</Policy>
<FormSection
:title="
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.TITLE',
'Alerta de conversa parada'
)
"
:description="
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.NOTE',
'Banner vermelho que aparece no topo do painel quando uma conversa fica sem resposta há 5+ minutos.'
)
"
>
<AggressiveAlertSettings />
</FormSection>
<Policy :permissions="notificationPermissions">
<FormSection :title="$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TITLE')">
<NotificationPreferences />

View File

@ -5,6 +5,16 @@ import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainReservation',
API: CaptainReservationsAPI,
mutations: {
SET_CAPTAINRESERVATION_META(state, meta) {
state.meta = {
...state.meta,
totalCount: Number(meta.total_count),
page: Number(meta.page),
statusCounts: meta.status_counts || meta.statusCounts || {},
};
},
},
actions: mutations => ({
fetchRevenue: async function fetchRevenue(_, params = {}) {
try {

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

401
bin/hermes-provision Executable file
View File

@ -0,0 +1,401 @@
#!/usr/bin/env bash
# hermes-provision — provisiona um novo agente Hermes ponta-a-ponta.
#
# Uso:
# hermes-provision [--dry-run] [--rollback <slug>] < spec.json
#
# Spec JSON esperado (stdin):
# {
# "slug": "lara",
# "name": "Lara",
# "account_id": 1,
# "marca": "Hotel 1001 Noites Prime",
# "unit_name": "PrimeVL",
# "city": "Brasília/DF",
# "captain_unit_id": null, // opcional — se null, cria nova Unit
# "parent_assistant_id": null, // opcional — se setado, MCP usa data do parent
# "soul_md": "<conteúdo SOUL.md inteiro>",
# "skill_name": "primevl-reservas",
# "skill_md": "<conteúdo SKILL.md>",
# "categories": [
# {
# "key": "standard",
# "aliases": ["standard", "comum"],
# "extra_person_starts_at": 3,
# "amounts": [
# {"period": "3h", "day_bucket": null, "amount": 50.0},
# {"period": "pernoite_promo", "day_bucket": "mon_wed", "amount": 100.0}
# ]
# }
# ],
# "extra_person_fee": 0.0,
# "humanization": {
# "mode": "typing_simulation",
# "chars_per_second": 25,
# "min_seconds": 1.5,
# "max_seconds": 6.0
# }
# }
#
# Saída: JSON com {ok, assistant_id, port, secret, errors}
#
# Quem chama: Construtor Hermes via terminal skill nativa.
# Pré-requisitos na VPS: jq, openssl, docker, systemctl, git, hermes binary.
set -uo pipefail
PROFILES_DIR="/root/.hermes/profiles"
TEMPLATE_PROFILE="$PROFILES_DIR/valentina"
PORT_RANGE_START=8650
PORT_RANGE_END=8699
SYSTEMD_DIR="/etc/systemd/system"
GIT_BACKUP_REPO="/root/hermes_profiles_backup"
DOCKER_APP_FILTER="iachat_iachat_app"
DRY_RUN=0
ROLLBACK_SLUG=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--rollback) ROLLBACK_SLUG="$2"; shift 2 ;;
*) echo "{\"ok\":false,\"error\":\"unknown flag: $1\"}" >&2; exit 1 ;;
esac
done
log() { echo "[$(date -u +%H:%M:%S)] $*" >&2; }
fail() { echo "{\"ok\":false,\"error\":\"$1\"}"; exit 1; }
require_cmd() { command -v "$1" >/dev/null || fail "missing required command: $1"; }
require_cmd jq
require_cmd openssl
require_cmd docker
require_cmd systemctl
require_cmd hermes
# === ROLLBACK ===
if [[ -n "$ROLLBACK_SLUG" ]]; then
log "rolling back $ROLLBACK_SLUG"
systemctl stop "hermes@$ROLLBACK_SLUG.service" 2>/dev/null || true
systemctl disable "hermes@$ROLLBACK_SLUG.service" 2>/dev/null || true
rm -rf "${PROFILES_DIR:?}/$ROLLBACK_SLUG"
CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1)
docker exec "$CID" bundle exec rails runner "
asst = Captain::Assistant.find_by(hermes_profile_name: '$ROLLBACK_SLUG')
if asst
asst.captain_inboxes.destroy_all
asst.destroy!
puts 'destroyed assistant ' + asst.id.to_s
else
puts 'no assistant for slug $ROLLBACK_SLUG'
end
" 2>&1 | grep -v RubyLLM | tail -3
echo "{\"ok\":true,\"action\":\"rollback\",\"slug\":\"$ROLLBACK_SLUG\"}"
exit 0
fi
# === READ + VALIDATE SPEC ===
SPEC=$(cat)
[[ -z "$SPEC" ]] && fail "empty spec on stdin"
echo "$SPEC" | jq empty 2>/dev/null || fail "invalid JSON"
SLUG=$(echo "$SPEC" | jq -r '.slug // empty')
NAME=$(echo "$SPEC" | jq -r '.name // empty')
ACCOUNT_ID=$(echo "$SPEC" | jq -r '.account_id // empty')
MARCA=$(echo "$SPEC" | jq -r '.marca // empty')
UNIT_NAME=$(echo "$SPEC" | jq -r '.unit_name // empty')
CAPTAIN_UNIT_ID=$(echo "$SPEC" | jq -r '.captain_unit_id // empty')
PARENT_ASSISTANT_ID=$(echo "$SPEC" | jq -r '.parent_assistant_id // empty')
SOUL_MD=$(echo "$SPEC" | jq -r '.soul_md // empty')
CITY=$(echo "$SPEC" | jq -r '.city // ""')
SKILL_NAME=$(echo "$SPEC" | jq -r '.skill_name // empty')
SKILL_MD=$(echo "$SPEC" | jq -r '.skill_md // empty')
EXTRA_PERSON_FEE=$(echo "$SPEC" | jq -r '.extra_person_fee // 0')
# Slug validation
[[ ! "$SLUG" =~ ^[a-z][a-z0-9_]{1,29}$ ]] && fail "invalid slug '$SLUG' (regex: ^[a-z][a-z0-9_]{1,29}\$)"
[[ -z "$NAME" ]] && fail "name required"
[[ -z "$ACCOUNT_ID" ]] && fail "account_id required"
[[ -z "$MARCA" ]] && fail "marca required"
[[ -z "$UNIT_NAME" ]] && fail "unit_name required"
[[ -z "$SOUL_MD" ]] && fail "soul_md content required"
[[ -z "$SKILL_NAME" ]] && fail "skill_name required"
[[ ! "$SKILL_NAME" =~ ^[a-z][a-z0-9_-]{1,40}$ ]] && fail "invalid skill_name '$SKILL_NAME'"
[[ -z "$SKILL_MD" ]] && fail "skill_md content required"
# Categories validation: structure + amount sanity
CATEGORIES_COUNT=$(echo "$SPEC" | jq '.categories | length')
[[ "$CATEGORIES_COUNT" -lt 1 ]] && fail "at least 1 category required"
INVALID_AMOUNTS=$(echo "$SPEC" | jq '
[.categories[] |
.amounts[] |
select(.amount <= 0 or .amount > 5000 or
(.period | IN("2h","3h","4h","5h","pernoite_promo","pernoite_integral","diaria") | not) or
(.day_bucket != null and (.day_bucket | IN("mon_wed","thu_sun") | not)))
] | length
')
[[ "$INVALID_AMOUNTS" -gt 0 ]] && fail "$INVALID_AMOUNTS amounts inválidos (preço fora 0..5000, período/bucket inválido)"
# Profile already exists?
if [[ -d "$PROFILES_DIR/$SLUG" ]]; then
log "profile $SLUG já existe, será re-validado mas não recriado (idempotente)"
fi
# === ALLOCATE PORT ===
allocate_port() {
for ((p=PORT_RANGE_START; p<=PORT_RANGE_END; p++)); do
if ! ss -tnlH "( sport = :$p )" | grep -q .; then
echo "$p"; return 0
fi
done
return 1
}
# Reuse port if profile exists, else allocate fresh
if [[ -f "$PROFILES_DIR/$SLUG/config.yaml" ]]; then
PORT=$(awk '/^ port:/ {print $2}' "$PROFILES_DIR/$SLUG/config.yaml" | head -1)
log "reusing existing port $PORT for $SLUG"
else
PORT=$(allocate_port) || fail "no free port in range $PORT_RANGE_START..$PORT_RANGE_END"
log "allocated port $PORT for $SLUG"
fi
# === GENERATE OR REUSE HMAC SECRET ===
if [[ -f "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" ]]; then
SECRET=$(jq -r ".\"captain-inbox-${SLUG}\".secret // empty" "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" 2>/dev/null)
[[ -z "$SECRET" ]] && SECRET=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-43)
else
SECRET=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-43)
fi
# === DRY RUN STOPS HERE ===
if [[ "$DRY_RUN" == "1" ]]; then
echo "$SPEC" | jq --arg port "$PORT" --arg secret "${SECRET:0:8}..." \
'{ok: true, dry_run: true, slug: .slug, name: .name, port: $port, secret_preview: $secret, categories: (.categories | length)}'
exit 0
fi
# === DB OPERATIONS via docker exec ===
CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1)
[[ -z "$CID" ]] && fail "iachat_iachat_app container not running"
# Persist spec to a tmp file inside container so the runner reads it back
TMP_SPEC="/tmp/hermes_spec_${SLUG}_$$.json"
echo "$SPEC" > "/tmp/hermes_spec_${SLUG}_$$.json"
docker cp "$TMP_SPEC" "$CID:$TMP_SPEC"
DB_RESULT=$(docker exec "$CID" bundle exec rails runner "
spec = JSON.parse(File.read('$TMP_SPEC'))
account_id = spec['account_id']
brand = Captain::Brand.find_by(account_id: account_id, name: spec['marca'])
raise \"brand not found: #{spec['marca']}\" if brand.nil?
unit = if spec['captain_unit_id']
Captain::Unit.find(spec['captain_unit_id'])
else
Captain::Unit.find_or_create_by!(account_id: account_id, captain_brand_id: brand.id, name: spec['unit_name']) do |u|
u.status = 'active'
u.extra_person_fee = (spec['extra_person_fee'] || 0).to_f
u.currency = 'BRL'
end
end
spec['categories'].each do |cat|
pricing_cat = Captain::PricingCategory.find_or_initialize_by(captain_unit_id: unit.id, key: cat['key'])
pricing_cat.aliases = cat['aliases'] || []
pricing_cat.extra_person_starts_at = cat['extra_person_starts_at'] || 3
pricing_cat.save!
cat['amounts'].each do |a|
row = Captain::PricingAmount.find_or_initialize_by(
captain_pricing_category_id: pricing_cat.id, period: a['period'], day_bucket: a['day_bucket']
)
row.amount = a['amount']
row.save!
end
end
# CHAVE = hermes_profile_name (slug). Nome é cosmético e PODE colidir com
# captain_interno do mesmo nome — nesse caso auto-renomeamos com sufixo
# ' · Hermes' pra evitar sobrescrever.
asst = Captain::Assistant.find_or_initialize_by(account_id: account_id, hermes_profile_name: spec['slug'])
desired_name = spec['name'].to_s.strip
if asst.new_record?
collision = Captain::Assistant.where(account_id: account_id, name: desired_name).where.not(hermes_profile_name: spec['slug']).exists?
desired_name = desired_name + ' · Hermes' if collision && !desired_name.include?('Hermes')
end
asst.name = desired_name
asst.description ||= 'Atendente Hermes ' + desired_name
asst.engine = 'hermes'
asst.hermes_profile_name = spec['slug']
asst.hermes_webhook_base_url = 'http://172.17.0.1:' + $PORT.to_s
asst.hermes_subscription_secret = '$SECRET'
asst.hermes_port = $PORT
asst.parent_assistant_id = spec['parent_assistant_id']
asst.captain_unit_id = unit.id
if spec['humanization']
asst.config['response_delay'] = spec['humanization']
end
asst.save!
puts 'OK ' + asst.id.to_s + ' ' + unit.id.to_s
" 2>&1 | grep -v 'RubyLLM\|ip_lookup\|WARN' | tail -3)
ASSISTANT_ID=$(echo "$DB_RESULT" | grep '^OK' | awk '{print $2}')
[[ -z "$ASSISTANT_ID" ]] && fail "DB step failed: $DB_RESULT"
log "DB step OK: assistant_id=$ASSISTANT_ID"
# === FILESYSTEM: profile directory ===
mkdir -p "$PROFILES_DIR/$SLUG/skills/$SKILL_NAME/references"
# Copy template files (config base, plugins, auth, generic skills)
if [[ -f "$TEMPLATE_PROFILE/config.yaml" ]]; then
cp "$TEMPLATE_PROFILE/config.yaml" "$PROFILES_DIR/$SLUG/config.yaml"
cp -r "$TEMPLATE_PROFILE/plugins" "$PROFILES_DIR/$SLUG/" 2>/dev/null || true
cp "$TEMPLATE_PROFILE/.env" "$PROFILES_DIR/$SLUG/.env" 2>/dev/null || true
cp "$TEMPLATE_PROFILE/auth.json" "$PROFILES_DIR/$SLUG/auth.json" 2>/dev/null || true
for s in "$TEMPLATE_PROFILE/skills"/*/; do
name=$(basename "$s")
[[ "$name" == "dolce-amore-reservas" ]] && continue
[[ "$name" == "$SKILL_NAME" ]] && continue
cp -r "$s" "$PROFILES_DIR/$SLUG/skills/" 2>/dev/null || true
done
fi
# Patch config.yaml: port + X-Captain-Assistant-Id + DESLIGA memória
# (Hermes-level memory_enabled e user_profile_enabled vazam contexto entre
# agentes que compartilham OAuth Codex; manter desligado pra evitar
# contaminação cross-unit).
#
# X-Captain-Assistant-Id usa o id PRÓPRIO do Hermes assistant (não do
# parent). Caso contrário tools como faq_lookup buscam dados do parent
# (Captain interno, com FAQs antigos) — vazou senha errada do Wi-Fi em
# 2026-05-02 porque parent.id=1 tinha "presencial" enquanto own.id=10
# tinha a senha real "Prime2025".
sed -i "s/port: 8645/port: $PORT/" "$PROFILES_DIR/$SLUG/config.yaml"
sed -i "s/X-Captain-Assistant-Id: '6'/X-Captain-Assistant-Id: '$ASSISTANT_ID'/" "$PROFILES_DIR/$SLUG/config.yaml"
# memory_enabled / user_profile_enabled ficam LIGADOS (default da Valentina
# template). Antes desligávamos achando que evitaria contaminação cross-unit
# — mas a contaminação real vinha do X-Captain-Assistant-Id apontando pro
# parent (já corrigido). Memória off mata UX (cliente repete nome/CPF a
# cada turn), e cada Hermes tem session isolada por chat_id, então memória
# de uma conv não vaza pra outra naturalmente.
# SOUL.md: clona a da Valentina (template canônico) e substitui identidade.
# Tudo que NÃO for identidade/marca/categoria — tom, formatação WhatsApp, [ctx],
# tools, regras de fluxo — vem direto da Valentina e fica em sync conforme
# ela evolui.
BRAND_NAME=$(echo "$SPEC" | jq -r '.marca')
UNIT_NAME=$(echo "$SPEC" | jq -r '.unit_name')
SKILL_NAME=$(echo "$SPEC" | jq -r '.skill_name')
CATEGORIAS_LISTA=$(echo "$SPEC" | jq -r '.categories | map(.key) | join(", ")')
cp "$TEMPLATE_PROFILE/SOUL.md" "$PROFILES_DIR/$SLUG/SOUL.md"
# Identity replacements (atenção: ordem importa pra strings que se sobrepõem).
sed -i "s|Dolce Amore Motel|$BRAND_NAME — $UNIT_NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md"
sed -i "s|Valentina|$NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md"
sed -i "s|dolce-amore-reservas|$SKILL_NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md"
# Substitui exemplos hardcoded de categorias Dolce Amore (Mini Chalé 45 etc) pelas
# 3 primeiras categorias da unidade nova. Sem isso, SOUL.md vaza Dolce Amore-isms
# em descrições de tools mesmo após sed de identidade.
EX_CATS_LIST=$(echo "$SPEC" | jq -r '[.categories[0:3] | .[] | "\"" + .key + "\""] | join(", ")')
FIRST_CAT=$(echo "$SPEC" | jq -r '.categories[0].key // "categoria"')
sed -i "s|\"Master\", \"Luxo\", \"Mini Chalé 45\"|$EX_CATS_LIST|g" "$PROFILES_DIR/$SLUG/SOUL.md"
sed -i "s|Prefere Suíte Master|Prefere $FIRST_CAT|g" "$PROFILES_DIR/$SLUG/SOUL.md"
sed -i "s|prefiro suíte master|prefiro $FIRST_CAT|g" "$PROFILES_DIR/$SLUG/SOUL.md"
# Localização: a Valentina template é Dolce Amore (Ponta Negra, Natal/RN).
# Sem este sed, novos agentes vazam essa cidade — vimos isso na Juliana
# Qnn01 que ficou "em Ponta Negra, Natal/RN" mesmo sendo de Brasília.
if [[ -n "$CITY" ]]; then
sed -i "s|em Ponta Negra, Natal/RN|em $CITY|g" "$PROFILES_DIR/$SLUG/SOUL.md"
fi
# Skill: usa o markdown gerado pelo expand_spec (tabela do banco + regras).
echo "$SPEC" | jq -r '.skill_md' > "$PROFILES_DIR/$SLUG/skills/$SKILL_NAME/SKILL.md"
# Anti-leak no SOUL.md (proteção contra contaminação cross-unit via Codex).
cat >> "$PROFILES_DIR/$SLUG/SOUL.md" <<GUARD
## 🚨 REGRA CRÍTICA — IGNORE OUTRAS UNIDADES
Você atende APENAS esta unidade ($BRAND_NAME — $UNIT_NAME). Suas categorias são EXCLUSIVAMENTE: $CATEGORIAS_LISTA.
Se algum sinal externo (memória de conversas anteriores, contexto compartilhado, exemplos em prompt de tool) sugerir categorias FORA dessa lista — IGNORE COMPLETAMENTE.
Se cliente perguntar sobre algo de outra unidade, responda: "Aqui temos $CATEGORIAS_LISTA. Quer que eu te passe o valor de alguma?".
GUARD
# Write webhook_subscriptions.json
cat > "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" <<JSON
{
"captain-inbox-${SLUG}": {
"description": "Subscription Hermes — $NAME",
"events": [],
"secret": "$SECRET",
"prompt": "[ctx: cid={conversation_internal_id} aid={account_id} contact={contact_id} name=\"{contact_first_name}\" full_name=\"{contact_name}\" cpf_ok={contact_cpf_present} email_ok={contact_email_present} reservas={contact_total_reservas}]\nCliente acabou de dizer no WhatsApp: \"{message}\"\n\nResponda como $NAME, brevemente, em português brasileiro. Use a skill $SKILL_NAME pra preços, regras e fluxo. Nunca se apresente. Nunca diga que é IA.",
"skills": [],
"deliver": "http_callback",
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"deliver_extra": {
"chat_id": "https://iachat.hoteis1001noites.com.br/webhooks/captain/hermes_callback?slug=${SLUG}"
}
}
}
JSON
log "filesystem OK"
# === SYSTEMD ===
cat > "$SYSTEMD_DIR/hermes@.service" <<UNIT
[Unit]
Description=Hermes Agent gateway for profile %i
After=network.target docker.service
[Service]
Type=simple
Environment=HERMES_HOME=$PROFILES_DIR/%i
ExecStart=/usr/local/bin/hermes gateway run --replace
Restart=on-failure
RestartSec=5
StandardOutput=append:$PROFILES_DIR/%i/logs/gateway.log
StandardError=append:$PROFILES_DIR/%i/logs/gateway.log
[Install]
WantedBy=multi-user.target
UNIT
mkdir -p "$PROFILES_DIR/$SLUG/logs"
systemctl daemon-reload
systemctl enable "hermes@$SLUG.service" >/dev/null 2>&1
systemctl restart "hermes@$SLUG.service"
sleep 2
if ! ss -tnlH "( sport = :$PORT )" | grep -q .; then
log "WARNING: daemon for $SLUG not listening on $PORT after 2s — check /root/.hermes/profiles/$SLUG/logs/gateway.log"
else
log "daemon listening on port $PORT"
fi
# === GIT BACKUP ===
if [[ -d "$GIT_BACKUP_REPO/.git" ]]; then
cd "$GIT_BACKUP_REPO"
rsync -a --delete --exclude='logs/' --exclude='cache/' --exclude='sessions/' \
--exclude='state.db*' --exclude='memories/' --exclude='sandboxes/' \
--exclude='.skills_prompt_snapshot.json' --exclude='auth.json' \
--exclude='.env' --exclude='webhook_subscriptions.json' \
"$PROFILES_DIR/$SLUG/" "./profiles/$SLUG/"
git add "profiles/$SLUG"
git commit -m "provision: $SLUG ($NAME)" >/dev/null 2>&1 || true
git push origin main >/dev/null 2>&1 || log "git push failed (silent — backup local OK)"
fi
# === OUTPUT ===
jq -n --arg slug "$SLUG" --arg name "$NAME" --argjson aid "$ASSISTANT_ID" --argjson port "$PORT" \
'{ok: true, slug: $slug, name: $name, assistant_id: $aid, port: $port, listening: true}'

227
bin/hermes-validate Executable file
View File

@ -0,0 +1,227 @@
#!/usr/bin/env bash
# hermes-validate <slug> — auditoria completa de um agente Hermes
#
# Uso:
# hermes-validate juliana_qnn1
# hermes-validate valentina --json # output JSON-only pra parsing
#
# Roda 42 checks em DB / filesystem / daemon / routing / tripé humanização /
# tools MCP / pricing. Reporta PASS/FAIL/WARN por item + resumo final.
#
# Exit code 0 = sem FAIL. 1 = pelo menos um FAIL.
set -uo pipefail
SLUG="${1:-}"
JSON_MODE=0
[[ "${2:-}" == "--json" ]] && JSON_MODE=1
PROFILES_DIR="/root/.hermes/profiles"
DOCKER_APP_FILTER="iachat_iachat_app"
[[ -z "$SLUG" ]] && { echo "uso: hermes-validate <slug> [--json]"; exit 2; }
PASS=0; FAIL=0; WARN=0
RESULTS_JSON='[]'
green() { printf "\033[32m✓\033[0m %s\n" "$1"; }
red() { printf "\033[31m✗\033[0m %s\n" "$1"; }
yellow(){ printf "\033[33m⚠\033[0m %s\n" "$1"; }
check() {
local label="$1" status="$2" detail="${3:-}"
case "$status" in
PASS) PASS=$((PASS+1)); [[ $JSON_MODE -eq 0 ]] && green "$label${detail:+ — $detail}" ;;
FAIL) FAIL=$((FAIL+1)); [[ $JSON_MODE -eq 0 ]] && red "$label${detail:+ — $detail}" ;;
WARN) WARN=$((WARN+1)); [[ $JSON_MODE -eq 0 ]] && yellow "$label${detail:+ — $detail}" ;;
esac
RESULTS_JSON=$(echo "$RESULTS_JSON" | jq --arg l "$label" --arg s "$status" --arg d "$detail" '. + [{label: $l, status: $s, detail: $d}]')
}
CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1)
[[ -z "$CID" ]] && { echo "iachat_iachat_app container não rodando"; exit 2; }
PROFILE_DIR="$PROFILES_DIR/$SLUG"
# ============================================================
# Coleta dados do DB num único rails runner pra evitar 30 docker execs
# ============================================================
DB_DUMP=$(docker exec "$CID" bundle exec rails runner "
asst = Captain::Assistant.find_by(hermes_profile_name: '$SLUG', engine: 'hermes')
if asst.nil?
puts({error: 'no_assistant'}.to_json)
exit
end
unit = asst.captain_unit
brand = unit&.brand
ci = CaptainInbox.where(captain_assistant_id: asst.id).first
inbox = ci&.inbox
hum = asst.config['response_delay']
cats = unit&.pricing_categories&.includes(:amounts)&.to_a || []
galleria = unit&.gallery_items&.count || 0
ci_unit_id = ci&.captain_unit_id
inter_ok = unit && unit.respond_to?(:inter_credentials_present?) ? unit.inter_credentials_present? : false
pricing_dry_run = nil
if unit && cats.any?
first_cat_key = cats.first.key
res = Captain::Mcp::PricingTables.calculate(
unit_id: unit.id, suite_category: first_cat_key,
period: 'pernoite_promo', total_guests: 2
)
pricing_dry_run = res[:error] ? \"ERR: #{res[:error]}\" : \"OK R$ #{res[:amount]} (#{first_cat_key}/pernoite)\"
end
out = {
assistant_id: asst.id,
name: asst.name,
engine: asst.engine,
profile_name: asst.hermes_profile_name,
port: asst.hermes_port,
secret_present: !asst.hermes_subscription_secret.nil?,
base_url: asst.hermes_webhook_base_url,
parent_id: asst.parent_assistant_id,
unit_id: unit&.id,
unit_name: unit&.name,
brand_name: brand&.name,
cats_count: cats.size,
cats_keys: cats.map(&:key),
amounts_total: cats.flat_map { |c| c.amounts.to_a }.size,
inbox_id: inbox&.id,
inbox_name: inbox&.name,
inbox_typing_delay: inbox&.typing_delay,
response_delay: hum,
gallery_count: galleria,
enabled_for: inbox ? Captain::Hermes.enabled_for?(inbox) : nil,
webhook_url: inbox ? Captain::Hermes.webhook_url_for(inbox) : nil,
secret_via: inbox ? Captain::Hermes.subscription_signing_secret(inbox)&.first(8) : nil,
ci_unit_id: ci_unit_id,
inter_ok: inter_ok,
pricing_dry_run: pricing_dry_run
}
puts out.to_json
" 2>&1 | grep -v 'WARN\|RubyLLM\|ip_lookup' | tail -1)
if [[ -z "$DB_DUMP" ]] || ! echo "$DB_DUMP" | jq -e . >/dev/null 2>&1; then
echo "DB query falhou. Output: $DB_DUMP"
exit 2
fi
if echo "$DB_DUMP" | jq -e '.error' >/dev/null; then
red "Captain::Assistant com hermes_profile_name='$SLUG' não existe"
exit 1
fi
ASSISTANT_ID=$(echo "$DB_DUMP" | jq -r '.assistant_id')
PORT=$(echo "$DB_DUMP" | jq -r '.port')
INBOX_ID=$(echo "$DB_DUMP" | jq -r '.inbox_id // ""')
PARENT_ID=$(echo "$DB_DUMP" | jq -r '.parent_id // ""')
[[ $JSON_MODE -eq 0 ]] && echo "=== Validando $SLUG (assistant_id=$ASSISTANT_ID, port=$PORT) ==="
# ============================================================
# A. DB Captain
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- A. DB ---"
check "engine='hermes'" "$([[ $(echo "$DB_DUMP" | jq -r '.engine') == 'hermes' ]] && echo PASS || echo FAIL)"
check "hermes_profile_name setado" "$([[ $(echo "$DB_DUMP" | jq -r '.profile_name') != 'null' && $(echo "$DB_DUMP" | jq -r '.profile_name') != '' ]] && echo PASS || echo FAIL)"
check "hermes_port setado" "$([[ "$PORT" != 'null' && "$PORT" != '' ]] && echo PASS || echo FAIL)" "port=$PORT"
check "hermes_subscription_secret setado" "$([[ $(echo "$DB_DUMP" | jq -r '.secret_present') == 'true' ]] && echo PASS || echo FAIL)"
check "hermes_webhook_base_url" "$([[ $(echo "$DB_DUMP" | jq -r '.base_url') =~ ^http ]] && echo PASS || echo FAIL)"
check "parent_assistant_id setado" "$([[ "$PARENT_ID" != 'null' && "$PARENT_ID" != '' ]] && echo PASS || echo WARN)" "parent=$PARENT_ID"
check "captain_unit_id setado" "$([[ $(echo "$DB_DUMP" | jq -r '.unit_id') != 'null' ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.unit_name')"
ASSIST_UNIT=$(echo "$DB_DUMP" | jq -r '.unit_id // ""')
CI_UNIT=$(echo "$DB_DUMP" | jq -r '.ci_unit_id // ""')
check "CaptainInbox.unit == Assistant.unit (sem divergência)" "$([[ -n "$ASSIST_UNIT" && "$ASSIST_UNIT" == "$CI_UNIT" ]] && echo PASS || echo FAIL)" "asst=$ASSIST_UNIT ci=$CI_UNIT"
check "Brand resolvida" "$([[ $(echo "$DB_DUMP" | jq -r '.brand_name') != 'null' ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.brand_name')"
check "Pricing categorias > 0" "$([[ $(echo "$DB_DUMP" | jq -r '.cats_count') -gt 0 ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.cats_count') cats: $(echo "$DB_DUMP" | jq -r '.cats_keys | join(",")')"
check "Pricing amounts > 0" "$([[ $(echo "$DB_DUMP" | jq -r '.amounts_total') -gt 0 ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.amounts_total') amounts"
PRICING_DRY=$(echo "$DB_DUMP" | jq -r '.pricing_dry_run // ""')
check "Pricing dry-run (calcula sem erro)" "$([[ "$PRICING_DRY" == OK* ]] && echo PASS || echo FAIL)" "$PRICING_DRY"
INTER_OK=$(echo "$DB_DUMP" | jq -r '.inter_ok // false')
check "Credenciais Inter completas (cert+key+client_id)" "$([[ "$INTER_OK" == 'true' ]] && echo PASS || echo WARN)" "Sem isso generate_pix cai no fallback de link"
check "CaptainInbox mapeada" "$([[ "$INBOX_ID" != 'null' && "$INBOX_ID" != '' ]] && echo PASS || echo WARN)" "inbox=$INBOX_ID"
check "Inbox.typing_delay > 0 (debounce)" "$([[ $(echo "$DB_DUMP" | jq -r '.inbox_typing_delay // 0') -gt 0 ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.inbox_typing_delay // 0')s"
check "config.response_delay (typing simulation)" "$([[ $(echo "$DB_DUMP" | jq -r '.response_delay.mode // ""') == 'typing_simulation' ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.response_delay.mode // "none"')"
check "GalleryItem para fotos" "$([[ $(echo "$DB_DUMP" | jq -r '.gallery_count') -gt 0 ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.gallery_count') items (send_suite_images precisa)"
# ============================================================
# B. Filesystem
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- B. Filesystem ---"
check "Pasta do profile existe" "$([[ -d "$PROFILE_DIR" ]] && echo PASS || echo FAIL)" "$PROFILE_DIR"
if [[ -d "$PROFILE_DIR" ]]; then
SOUL_LINES=$(wc -l < "$PROFILE_DIR/SOUL.md" 2>/dev/null || echo 0)
check "SOUL.md ≥ 300 linhas" "$([[ $SOUL_LINES -ge 300 ]] && echo PASS || echo WARN)" "$SOUL_LINES linhas"
RESID=$(grep -c 'Dolce Amore Motel\|Mini Chalé 45\|Suíte Ouro\|Chalé 2 Suítes' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
RESID=${RESID:-0}
check "SOUL.md sem resíduo Dolce Amore" "$([[ $RESID -eq 0 ]] && echo PASS || echo FAIL)" "$RESID ocorrências"
IVR=$(grep -c 'RESPONDA ANTES DE PERGUNTAR\|Info vs Reserva' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
check "SOUL.md tem Info-vs-Reserva" "$([[ ${IVR:-0} -gt 0 ]] && echo PASS || echo WARN)"
ALG=$(grep -c 'IGNORE OUTRAS UNIDADES' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
check "SOUL.md tem anti-leak guard" "$([[ ${ALG:-0} -gt 0 ]] && echo PASS || echo WARN)"
check "skills/<skill_name>/SKILL.md existe" "$([[ -n "$(find "$PROFILE_DIR/skills" -mindepth 2 -name 'SKILL.md' | grep -v dogfood | head -1)" ]] && echo PASS || echo FAIL)"
check "dolce-amore-reservas NÃO está em skills/" "$([[ ! -d "$PROFILE_DIR/skills/dolce-amore-reservas" ]] && echo PASS || echo FAIL)"
SUB_KEY=$(jq -r 'keys[0] // ""' "$PROFILE_DIR/webhook_subscriptions.json" 2>/dev/null)
check "webhook_subscriptions.json com chave correta" "$([[ "$SUB_KEY" == "captain-inbox-$SLUG" ]] && echo PASS || echo FAIL)" "$SUB_KEY"
PORT_OK=$(grep -c "port: $PORT" "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
check "config.yaml port=$PORT" "$([[ ${PORT_OK:-0} -gt 0 ]] && echo PASS || echo FAIL)"
HDR_OK=$(grep -c "X-Captain-Assistant-Id: '${PARENT_ID:-$ASSISTANT_ID}'" "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
check "config.yaml X-Captain-Assistant-Id correto" "$([[ ${HDR_OK:-0} -gt 0 ]] && echo PASS || echo FAIL)"
MEM_OFF=$(grep -c 'memory_enabled: false' "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
check "config.yaml memory_enabled: false" "$([[ ${MEM_OFF:-0} -gt 0 ]] && echo PASS || echo WARN)"
check "auth.json existe" "$([[ -f "$PROFILE_DIR/auth.json" ]] && echo PASS || echo FAIL)"
check "plugins/captain-http-callback presente" "$([[ -d "$PROFILE_DIR/plugins/captain-http-callback" ]] && echo PASS || echo FAIL)"
check "plugins/captain-webhook presente" "$([[ -d "$PROFILE_DIR/plugins/captain-webhook" ]] && echo PASS || echo FAIL)"
fi
# ============================================================
# C. Daemon / systemd
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- C. Daemon ---"
check "systemd unit hermes@$SLUG ativa" "$([[ $(systemctl is-active "hermes@$SLUG.service" 2>/dev/null) == 'active' ]] && echo PASS || echo FAIL)"
check "systemd unit enabled" "$([[ $(systemctl is-enabled "hermes@$SLUG.service" 2>/dev/null) == 'enabled' ]] && echo PASS || echo WARN)"
check "Daemon escutando na porta $PORT" "$(ss -tnlH "( sport = :$PORT )" 2>/dev/null | grep -q . && echo PASS || echo FAIL)"
ERR_COUNT=$(journalctl -u "hermes@$SLUG.service" --since '10 minutes ago' --no-pager 2>/dev/null | grep -ciE 'error|fatal|critical' || true)
ERR_COUNT=${ERR_COUNT:-0}
check "Logs sem erro recente" "$([[ $ERR_COUNT -eq 0 ]] && echo PASS || echo WARN)" "$ERR_COUNT erros nos últimos 10min"
# ============================================================
# D. Roteamento Captain ↔ Hermes
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- D. Roteamento ---"
check "Captain::Hermes.enabled_for? = true" "$([[ $(echo "$DB_DUMP" | jq -r '.enabled_for') == 'true' ]] && echo PASS || echo FAIL)"
EXPECTED_URL="$(echo "$DB_DUMP" | jq -r '.base_url')/webhooks/captain-inbox-$SLUG"
ACTUAL_URL=$(echo "$DB_DUMP" | jq -r '.webhook_url // ""')
check "webhook_url aponta pra $SLUG" "$([[ "$ACTUAL_URL" == "$EXPECTED_URL" ]] && echo PASS || echo FAIL)" "$ACTUAL_URL"
check "subscription_signing_secret retorna do DB" "$([[ -n "$(echo "$DB_DUMP" | jq -r '.secret_via // ""')" && "$(echo "$DB_DUMP" | jq -r '.secret_via // ""')" != 'null' ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.secret_via // "nil"')..."
# ============================================================
# E. MCP tools list (daemon registra todas)
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- E. MCP tools (no Captain) ---"
EXPECTED_TOOLS=(generate_pix faq_lookup add_label send_suite_images react_to_message update_contact get_contact_history check_pix_payment reschedule_reservation)
TOOLS_REGISTRY=$(docker exec "$CID" bundle exec rails runner "puts Captain::Mcp::ToolRegistry::TOOLS.map(&:name).join(',')" 2>&1 | grep -v 'WARN\|RubyLLM\|ip_lookup' | tail -1)
for tool in "${EXPECTED_TOOLS[@]}"; do
check "MCP tool '$tool' registrado" "$([[ "$TOOLS_REGISTRY" == *"$tool"* ]] && echo PASS || echo FAIL)"
done
# ============================================================
# Resumo
# ============================================================
TOTAL=$((PASS+FAIL+WARN))
if [[ $JSON_MODE -eq 1 ]]; then
jq -n --arg slug "$SLUG" --argjson pass $PASS --argjson fail $FAIL --argjson warn $WARN --argjson total $TOTAL --argjson results "$RESULTS_JSON" \
'{slug: $slug, total: $total, pass: $pass, fail: $fail, warn: $warn, ok: ($fail == 0), results: $results}'
else
echo
echo "=== Resumo de $SLUG ==="
echo "Total: $TOTAL · ${PASS} PASS · ${FAIL} FAIL · ${WARN} WARN"
if [[ $FAIL -eq 0 ]]; then
echo "✅ Sem falhas críticas. Pode soltar."
else
echo "❌ $FAIL falha(s) — corrigir antes de soltar pro cliente."
fi
fi
[[ $FAIL -gt 0 ]] && exit 1
exit 0

View File

@ -184,7 +184,7 @@
# MARK: Captain Config
- name: CAPTAIN_LLM_PROVIDER
display_title: 'Captain LLM Provider'
description: 'Qual provider o Captain usa: openai_api (padrão, API key tradicional) ou openai_codex_oauth (assinatura ChatGPT Plus via proxy interno).'
description: 'Qual provider o Captain usa: openai_api (padrão, API key tradicional), openai_codex_oauth (assinatura ChatGPT Plus via proxy interno) ou openai_hermes_gateway (Hermes Agent rodando como gateway HTTP local — ele faz o roteamento multi-modelo via OAuth).'
value: 'openai_api'
locked: false
- name: CAPTAIN_CODEX_PROXY_URL
@ -192,6 +192,21 @@
description: 'URL base do proxy Codex interno quando CAPTAIN_LLM_PROVIDER=openai_codex_oauth. Default: http://localhost:3000/codex'
value: 'http://localhost:3000/codex'
locked: false
- name: CAPTAIN_HERMES_GATEWAY_URL
display_title: 'Captain Hermes Gateway URL'
description: 'URL base do Hermes Gateway quando CAPTAIN_LLM_PROVIDER=openai_hermes_gateway. Default: http://host.docker.internal:9877 (Hermes rodando no host, container alcança via host.docker.internal).'
value: 'http://host.docker.internal:9877'
locked: false
- name: CAPTAIN_HERMES_GATEWAY_MODEL
display_title: 'Captain Hermes Gateway Model'
description: 'Modelo a passar pro Hermes Gateway no formato <provider>/<model>. Default: anthropic/claude-opus-4-5. O Hermes faz o roteamento real e pode usar Codex/Anthropic/Gemini conforme config local em ~/.hermes/config.yaml.'
value: 'anthropic/claude-opus-4-5'
locked: false
- name: CAPTAIN_HERMES_GATEWAY_API_KEY
display_title: 'Captain Hermes Gateway API Key (optional)'
description: 'API key opcional pro Hermes Gateway. Geralmente vazio (gateway local não exige auth). Se setado, vai no Authorization header das requisições do Captain pro Hermes.'
locked: false
type: secret
- name: CAPTAIN_OPEN_AI_API_KEY
display_title: 'OpenAI API Key'
description: 'The API key used to authenticate requests to OpenAI services for Captain AI.'

View File

@ -58,6 +58,15 @@ Rails.application.routes.draw do
post :bulk_create, on: :collection
end
namespace :captain do
resources :hermes_builder, only: [:index, :create] do
collection do
post :start
delete :reset
get :assistants
get :validate
post :repair
end
end
resource :preferences, only: [:show, :update]
resources :assistants do
member do
@ -637,6 +646,9 @@ Rails.application.routes.draw do
post 'webhooks/tiktok', to: 'webhooks/tiktok#events'
post 'webhooks/shopify', to: 'webhooks/shopify#events'
post 'webhooks/wuzapi/:inbox_id', to: 'webhooks/wuzapi#process_payload'
post 'webhooks/captain/hermes_callback', to: 'webhooks/captain/hermes_callback#process_payload'
post 'webhooks/captain/builder_callback', to: 'webhooks/captain/hermes_builder_callback#process_payload'
post 'webhooks/captain/mcp', to: 'webhooks/captain/mcp#process_payload'
namespace :twitter do
resource :callback, only: [:show]

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

@ -0,0 +1,29 @@
class CreateCaptainPricingTables < ActiveRecord::Migration[7.1]
# rubocop:disable Metrics/MethodLength
def change
add_column :captain_units, :extra_person_fee, :decimal, precision: 10, scale: 2, default: 0.0, null: false
add_column :captain_units, :currency, :string, default: 'BRL', null: false
create_table :captain_pricing_categories do |t|
t.references :captain_unit, null: false, foreign_key: { to_table: :captain_units }
t.string :key, null: false
t.jsonb :aliases, null: false, default: []
t.integer :extra_person_starts_at, null: false, default: 3
t.timestamps
end
add_index :captain_pricing_categories, [:captain_unit_id, :key], unique: true
create_table :captain_pricing_amounts do |t|
t.references :captain_pricing_category, null: false, foreign_key: { to_table: :captain_pricing_categories }
t.string :period, null: false
t.string :day_bucket
t.decimal :amount, precision: 10, scale: 2, null: false
t.timestamps
end
add_index :captain_pricing_amounts,
[:captain_pricing_category_id, :period, :day_bucket],
unique: true,
name: 'idx_captain_pricing_amount_uniq'
end
# rubocop:enable Metrics/MethodLength
end

View File

@ -0,0 +1,14 @@
class AddProvisioningColumnsToCaptainAssistants < ActiveRecord::Migration[7.1]
def change
add_column :captain_assistants, :hermes_subscription_secret, :string
add_column :captain_assistants, :hermes_port, :integer
add_column :captain_assistants, :parent_assistant_id, :bigint
add_index :captain_assistants, :parent_assistant_id
add_index :captain_assistants,
:hermes_port,
unique: true,
where: 'hermes_port IS NOT NULL',
name: 'idx_captain_assistants_hermes_port_unique'
end
end

View File

@ -0,0 +1,9 @@
class AddCaptainUnitIdToCaptainAssistants < ActiveRecord::Migration[7.1]
def change
add_reference :captain_assistants,
:captain_unit,
foreign_key: { to_table: :captain_units, on_delete: :nullify },
null: true,
index: true
end
end

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_160000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@ -336,7 +336,18 @@ 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.bigint "captain_unit_id"
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
t.index ["captain_unit_id"], name: "index_captain_assistants_on_captain_unit_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 +684,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 +1000,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"
@ -2133,6 +2168,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_01_030000) do
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "captain_assets", "accounts"
add_foreign_key "captain_assets", "captain_suites"
add_foreign_key "captain_assistants", "captain_units", on_delete: :nullify
add_foreign_key "captain_brands", "accounts"
add_foreign_key "captain_configurations", "accounts"
add_foreign_key "captain_contact_memories", "accounts", on_delete: :cascade
@ -2163,6 +2199,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"

150
db/seed_pricing_tables.rb Normal file
View File

@ -0,0 +1,150 @@
# Backfill one-time das tabelas de preço pra Dolce Amore (unit 4) e
# Express (unit 5) — copia o que estava hardcoded no PricingTables.rb
# antes da migração pra DB.
#
# Idempotente: roda find_or_create_by em tudo. Pode rodar várias vezes sem
# criar duplicata.
#
# Uso:
# docker exec iachat_iachat_app bundle exec rails runner db/seed_pricing_tables.rb
#
# rubocop:disable all
# Garante extra_person_fee + currency configurados nas units
dolce = Captain::Unit.find(4)
dolce.update!(extra_person_fee: 45.0, currency: 'BRL') if dolce.extra_person_fee.to_f.zero?
express = Captain::Unit.find(5)
express.update!(extra_person_fee: 0.0, currency: 'BRL')
DOLCE_AMORE_DATA = {
'apartamento' => {
aliases: ['apto', 'standard', 'apartamento standard', 'apartamento_standard'],
extra_person_starts_at: 3,
prices: { '3h' => 85.0, 'pernoite_promo' => 110.0, 'pernoite_integral' => 155.0, 'diaria' => 290.0 }
},
'suite_master' => {
aliases: ['master', 'suite master', 'suíte master', '2 andares'],
extra_person_starts_at: 3,
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
},
'suite_luxo' => {
aliases: ['luxo', 'suite luxo', 'suíte luxo', 'classica', 'clássica'],
extra_person_starts_at: 3,
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
},
'suite_tematica' => {
aliases: ['tematica', 'temática', 'suite tematica', 'suíte temática'],
extra_person_starts_at: 3,
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
},
'mini_chale_45' => {
aliases: ['mini chale', 'mini chalé', 'chale 45', 'chalé 45', 'mini chalé 45', 'mini_chale'],
extra_person_starts_at: 3,
prices: { '3h' => 100.0, 'pernoite_promo' => 140.0, 'pernoite_integral' => 190.0, 'diaria' => 400.0 }
},
'chale_2_suites' => {
aliases: ['chale 2', 'chalé 2', 'chale 2 suites', 'chalé 2 suítes', 'chale_2', '2 suites'],
extra_person_starts_at: 4,
prices: { '3h' => 165.0, 'pernoite_promo' => 240.0, 'pernoite_integral' => 350.0, 'diaria' => 490.0 }
},
'suite_ouro' => {
aliases: ['ouro', 'suite ouro', 'suíte ouro'],
extra_person_starts_at: 4,
prices: { '3h' => 230.0, 'pernoite_promo' => 340.0, 'pernoite_integral' => 440.0, 'diaria' => 830.0 }
},
'chale_master_4_suites' => {
aliases: ['chale master', 'chalé master', 'master 4 suites', 'chalé master 4 suítes', 'chale_master', '4 suites'],
extra_person_starts_at: 8,
prices: { '3h' => 360.0, 'pernoite_promo' => 510.0, 'pernoite_integral' => 580.0, 'diaria' => 1240.0 }
}
}.freeze
EXPRESS_DATA = {
'standard' => {
aliases: %w[standard comum básica basica apartamento\ standard],
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' => 120.0 },
'diaria' => 150.0
}
},
'master' => {
aliases: ['master', 'melhor', 'suite master', 'suíte master'],
extra_person_starts_at: 3,
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
}
},
'singles' => {
aliases: %w[singles single sozinho],
extra_person_starts_at: 99,
prices: {
'pernoite_promo' => { 'mon_wed' => 80.0, 'thu_sun' => 110.0 },
'diaria' => 130.0
}
},
'familia' => {
aliases: %w[familia família familiar],
extra_person_starts_at: 99,
prices: {
'pernoite_promo' => 160.0,
'diaria' => 190.0
}
},
'singles_duplo' => {
aliases: ['singles duplo', 'singles_duplo', 'casal', 'duplo'],
extra_person_starts_at: 99,
prices: {
'pernoite_promo' => { 'mon_wed' => 180.0, 'thu_sun' => 220.0 },
'diaria' => 250.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} (#{attrs[:prices].size} periods)"
end
end
upsert(dolce, DOLCE_AMORE_DATA)
upsert(express, EXPRESS_DATA)
puts "--- summary ---"
puts "dolce categories: #{dolce.pricing_categories.count}"
puts "dolce amounts: #{Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: dolce.id }).count}"
puts "express categories: #{express.pricing_categories.count}"
puts "express amounts: #{Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: express.id }).count}"
# rubocop:enable all

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

@ -244,11 +244,16 @@ Também conta como intenção de reserva quando o cliente já dá dados concreto
| Suíte Ouro | 230 | 340 | 440 | 830 | 30 |
| Chalé Master 4 Suítes | 360 | 510 | 580 | 1.240 | 80 |
**Pessoa extra:** R$ 45,00 por pessoa adicional. Faixa varia por categoria:
- Apartamento, Suíte Master/Luxo/Temática, Mini Chalé 45 → cobra a partir da **2ª pessoa**.
**Pessoa extra:** R$ 45,00 por pessoa adicional. **A base do quarto JÁ INCLUI o casal (2 pessoas) — taxa extra começa na 3ª pessoa pra apartamento/suítes**. Faixa varia por categoria:
- Apartamento, Suíte Master/Luxo/Temática, Mini Chalé 45 → cobra a partir da **3ª pessoa** (2 pessoas já incluídas no valor base).
- Chalé 2 Suítes e Suíte Ouro → cobra a partir da **4ª pessoa**.
- Chalé Master 4 Suítes → cobra a partir da **8ª pessoa**.
**Exemplo de cálculo:** 4 pessoas na Suíte Master pernoite sex/sáb/feriado:
- Base da suíte: R$ 180 (já inclui 2 pessoas)
- Pessoas extras: 4 - 2 = 2 pessoas → 2 × R$ 45 = R$ 90
- Total: R$ 180 + R$ 90 = **R$ 270**
**Hora excedente** (após o tempo contratado):
- Apartamento: R$ 25/h
- Suíte Master/Luxo/Temática: R$ 30/h

View File

@ -1,6 +1,6 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action -> { check_authorization(Captain::AssistantResponse) }
before_action :set_current_page, only: [:index]
before_action :set_assistant, only: [:create]

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

@ -1,6 +1,6 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action -> { check_authorization(Captain::AssistantResponse) }
before_action :validate_params
before_action :type_matches?

View File

@ -0,0 +1,98 @@
# Endpoints da UI Hermes Builder no painel Captain.
#
# Fluxo:
# 1. UI faz POST /messages com texto do admin
# 2. Backend encaminha pro gateway Hermes Construtor (porta 8646 na VPS)
# 3. Construtor processa async e dispara http_callback (HermesBuilderCallbackController)
# 4. Callback armazena resposta no Rails.cache
# 5. UI faz GET /messages a cada 2s (polling) e renderiza
#
# Sessão é por account+user (1 admin → 1 sessão de builder por vez).
class Api::V1::Accounts::Captain::HermesBuilderController < Api::V1::Accounts::BaseController
before_action :authorize_admin
def index
msgs = HermesBuilder::Storage.messages_for(session_key)
render json: { messages: msgs, session_id: session_id }
end
def create
text = params[:text].to_s.strip
return render json: { error: 'Texto vazio' }, status: :bad_request if text.blank?
HermesBuilder::Storage.append(session_key, role: 'user', content: text)
HermesBuilder::Storage.remember_last_session(Current.account.id, session_key)
HermesBuilder::Dispatcher.send_to_construtor(session_id: session_id, message: text)
render json: { ok: true, session_id: session_id }, status: :accepted
rescue HermesBuilder::Dispatcher::DispatchError => e
Rails.logger.error("[HermesBuilder#create] dispatch failed: #{e.message}")
render json: { error: "Falha ao contatar Construtor: #{e.message}" }, status: :bad_gateway
end
# Inicia sessão limpa enviando comando-gatilho oculto pro Construtor pra
# ele começar o fluxo socrático de criação. UI chama este endpoint
# quando admin clica "Iniciar" — sem ter que digitar primeira msg.
def start
HermesBuilder::Storage.clear(session_key)
HermesBuilder::Storage.remember_last_session(Current.account.id, session_key)
HermesBuilder::Dispatcher.send_to_construtor(
session_id: session_id,
message: '__START__ Inicie o fluxo de criação de novo agente Hermes. Comece pela primeira pergunta do Bloco 1 (nome do agente).'
)
render json: { ok: true, session_id: session_id }, status: :accepted
rescue HermesBuilder::Dispatcher::DispatchError => e
Rails.logger.error("[HermesBuilder#start] dispatch failed: #{e.message}")
render json: { error: "Falha ao contatar Construtor: #{e.message}" }, status: :bad_gateway
end
def reset
HermesBuilder::Storage.clear(session_key)
render json: { ok: true }
end
# Lista assistentes Hermes da conta atual pra dropdown da aba Verificação.
def assistants
rows = ::Captain::Assistant.where(account_id: Current.account.id, engine: 'hermes')
.order(:name)
.pluck(:id, :name, :hermes_profile_name)
.map { |id, name, slug| { id: id, name: name, slug: slug } }
render json: { assistants: rows }
end
# Roda o validator (porta dos checks DB do CLI hermes-validate).
def validate
slug = params[:slug].to_s.strip
return render json: { error: 'slug required' }, status: :bad_request if slug.blank?
render json: HermesBuilder::Validator.run(slug)
end
# Aplica reparo automatizado pra um check FAIL/WARN específico.
def repair
slug = params[:slug].to_s.strip
repair_id = params[:repair_id].to_s.strip
return render json: { ok: false, error: 'slug e repair_id required' }, status: :bad_request if slug.blank? || repair_id.blank?
result = HermesBuilder::Repairer.repair(slug: slug, repair_id: repair_id)
render json: result, status: result[:ok] ? :ok : :unprocessable_entity
end
private
def authorize_admin
return if current_user&.administrator?
render json: { error: 'Apenas administradores podem usar o Builder' }, status: :forbidden
end
# Sessão é por (account, user). Hermes Construtor mantém memória estável
# graças ao plugin captain-webhook que rewrita chat_id usando hermes_session_id.
def session_id
"builder-#{Current.account.id}-#{current_user.id}"
end
def session_key
"hermes_builder:#{session_id}"
end
end

View File

@ -1,6 +1,6 @@
# rubocop:disable Metrics/ClassLength
class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::BaseController
CONFIRMED_STATUSES = %i[scheduled active completed].freeze
CONFIRMED_STATUSES = %i[scheduled active completed confirmed].freeze
RESULTS_PER_PAGE = 25
MAX_RESULTS_PER_PAGE = 100
SORTABLE_FIELDS = %w[check_in_at created_at updated_at].freeze
@ -13,7 +13,9 @@ class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::Ba
before_action :set_reservation, only: [:show, :pix, :cancel, :mark_as_paid, :regenerate_pix]
def index
scoped = apply_filters(@reservations_scope)
common_scoped = apply_common_filters(@reservations_scope)
@status_counts = status_counts_for(common_scoped)
scoped = apply_status_filter(common_scoped)
scoped = apply_sort(scoped)
@reservations_count = scoped.count
@reservations = scoped.page(@current_page).per(@per_page)
@ -178,6 +180,21 @@ class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::Ba
)
end
def status_counts_for(scope)
counts_by_status = scope.group(:status).count.transform_keys do |key|
Captain::Reservation.statuses.key(key) || key.to_s
end
confirmed_count = CONFIRMED_STATUSES.sum { |status| counts_by_status[status.to_s].to_i }
{
all: counts_by_status.values.sum,
draft: counts_by_status['draft'].to_i,
pending_payment: counts_by_status['pending_payment'].to_i,
confirmed: confirmed_count,
cancelled: counts_by_status['cancelled'].to_i
}
end
def apply_sort(scope)
return default_order(scope) if permitted_params[:sort].blank?
@ -192,11 +209,12 @@ class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::Ba
scope.order(
Arel.sql(
'CASE captain_reservations.status ' \
"WHEN #{Captain::Reservation.statuses[:pending_payment]} THEN 0 " \
"WHEN #{Captain::Reservation.statuses[:draft]} THEN 1 " \
"WHEN #{Captain::Reservation.statuses[:scheduled]} THEN 2 " \
"WHEN #{Captain::Reservation.statuses[:active]} THEN 2 " \
"WHEN #{Captain::Reservation.statuses[:completed]} THEN 2 " \
"WHEN #{Captain::Reservation.statuses[:scheduled]} THEN 0 " \
"WHEN #{Captain::Reservation.statuses[:active]} THEN 0 " \
"WHEN #{Captain::Reservation.statuses[:completed]} THEN 0 " \
"WHEN #{Captain::Reservation.statuses[:confirmed]} THEN 0 " \
"WHEN #{Captain::Reservation.statuses[:pending_payment]} THEN 1 " \
"WHEN #{Captain::Reservation.statuses[:draft]} THEN 2 " \
"WHEN #{Captain::Reservation.statuses[:cancelled]} THEN 3 " \
'ELSE 4 END ASC, captain_reservations.check_in_at ASC'
)

View File

@ -0,0 +1,85 @@
# Posta a resposta do Hermes na conversa simulando comportamento humano:
# 1. Liga indicador de "digitando..." (composing) via wuzapi
# 2. Aguarda delay configurado pelo assistant (typing_simulation, fixed ou none)
# 3. Posta a mensagem outgoing
#
# Config vive em `Captain::Assistant.config['response_delay']`:
# {
# "mode": "typing_simulation" | "fixed" | "none",
# "chars_per_second": 25, # apenas typing_simulation
# "seconds": 3, # apenas fixed
# "min_seconds": 1.5, # cap inferior pra typing_simulation
# "max_seconds": 8.0 # cap superior pra typing_simulation
# }
#
# Default: none (zero delay, igual antes — defensivo).
class Captain::Hermes::DelayedReplyJob < ApplicationJob
queue_as :default
DEFAULT_CONFIG = {
'mode' => 'none',
'chars_per_second' => 25,
'min_seconds' => 1.5,
'max_seconds' => 8.0
}.freeze
def perform(conversation_id, content)
conversation = Conversation.find_by(id: conversation_id)
if conversation.blank?
Rails.logger.warn("[Captain::Hermes::DelayedReplyJob] conv #{conversation_id} not found")
return
end
delay = compute_delay(conversation, content)
if delay.positive?
send_typing(conversation, 'typing_on')
sleep(delay)
end
create_outgoing_message(conversation, content)
# NÃO mandamos typing_off explícito — WhatsApp cancela o indicador
# automaticamente quando a msg chega no celular. Mandar paused agora
# quebraria visualmente: typing some -> gap de 2-5s ate msg ser
# entregue via SendReplyJob -> msg chega. Deixa o WhatsApp gerenciar.
end
private
def compute_delay(conversation, content)
cfg = DEFAULT_CONFIG.merge(conversation.inbox.captain_assistant&.config.to_h.fetch('response_delay', {}))
case cfg['mode']
when 'fixed' then cfg['seconds'].to_f
when 'typing_simulation'
cps = cfg['chars_per_second'].to_f
cps = 25 if cps <= 0
raw = content.to_s.length / cps
raw.clamp(cfg['min_seconds'].to_f, cfg['max_seconds'].to_f)
else 0.0
end
end
def send_typing(conversation, status)
return unless conversation.inbox.respond_to?(:channel)
return unless conversation.inbox.channel.respond_to?(:toggle_typing_status)
conversation.inbox.channel.toggle_typing_status(status, conversation: conversation)
rescue StandardError => e
Rails.logger.warn("[Captain::Hermes::DelayedReplyJob] toggle_typing_status #{status} failed: #{e.class} - #{e.message}")
end
def create_outgoing_message(conversation, content)
assistant = conversation.inbox.captain_assistant
sender = assistant.presence || User.find_by(id: conversation.assignee_id)
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
sender: sender,
content: content,
content_attributes: { external_source: 'hermes_callback' }
)
end
end

View File

@ -0,0 +1,56 @@
# Notifica o Hermes Agent sobre confirmação de pagamento de uma reserva, pra
# que o agente mande mensagem espontânea pro cliente celebrando (sem cliente
# precisar perguntar "já caiu?").
#
# Disparado por Captain::Payments::ConfirmationService (somente quando a
# inbox da reservation está em CAPTAIN_HERMES_INBOX_IDS — coexiste com o
# fluxo Captain interno).
class Captain::Hermes::NotifyPaymentConfirmedJob < ApplicationJob
queue_as :default
retry_on Captain::Hermes::Client::DispatchError, attempts: 3, wait: 5.seconds
def perform(reservation_id) # rubocop:disable Metrics/MethodLength
reservation = Captain::Reservation.find_by(id: reservation_id)
if reservation.blank?
Rails.logger.warn("[Captain::Hermes::NotifyPaymentConfirmedJob] reservation #{reservation_id} not found")
return
end
conversation = reservation.conversation
if conversation.blank?
Rails.logger.info("[Captain::Hermes::NotifyPaymentConfirmedJob] reservation #{reservation_id} has no conversation — skipping")
return
end
unless Captain::Hermes.enabled_for?(conversation.inbox)
Rails.logger.info(
"[Captain::Hermes::NotifyPaymentConfirmedJob] inbox #{conversation.inbox_id} " \
'not Hermes-enabled — skipping (Captain interno cuida)'
)
return
end
Captain::Hermes::Client.new(conversation.inbox).notify_event(
conversation: conversation,
event_type: 'payment_confirmed',
system_message: build_system_message(reservation)
)
end
private
def build_system_message(reservation)
deposit = reservation.metadata.to_h['deposit_amount'].to_f
total = reservation.total_amount.to_f
suite = reservation.suite_identifier.to_s
check_in = reservation.check_in_at&.strftime('%d/%m/%Y às %Hh%M')
[
'[SISTEMA: pagamento_confirmado]',
"Pix da reserva ##{reservation.id} acabou de cair pelo banco.",
"Sinal R$ #{format('%.2f', deposit)} de R$ #{format('%.2f', total)} (#{suite}, check-in #{check_in}).",
'Mande mensagem espontânea celebrando a reserva confirmada e dando próximos passos curtos. Tom íntimo, sem voltar a oferecer outras coisas.'
].join("\n")
end
end

View File

@ -0,0 +1,71 @@
# Dispara o webhook do Hermes Agent assincronamente quando uma mensagem
# do cliente chega numa inbox marcada como Hermes-enabled.
#
# Acionado pelo Enterprise::MessageTemplates::HookExecutionService no lugar do
# Captain::Conversation::ResponseBuilderJob padrão, quando
# Captain::Hermes.enabled_for?(inbox) retorna true.
class Captain::Hermes::OutgoingJob < ApplicationJob
queue_as :default
retry_on Captain::Hermes::Client::DispatchError, attempts: 3, wait: 5.seconds
HUMAN_TRIAGE_LABELS = %w[triagem_humana hermes_placeholder].freeze
def perform(conversation_id, message_id)
conversation = Conversation.find_by(id: conversation_id)
message = Message.find_by(id: message_id)
return if conversation.blank? || message.blank?
return unless Captain::Hermes.enabled_for?(conversation.inbox)
# Conv marcada pra triagem humana = Hermes não responde mais (até admin
# remover label). Evita gastar token e gerar loop em msgs claramente fora
# de escopo (operadora telefonia, banco, suporte de outro app, etc).
if conversation.label_list.intersect?(HUMAN_TRIAGE_LABELS)
Rails.logger.info("[Captain::Hermes::OutgoingJob] conv #{conversation.display_id} em triagem humana — pulando dispatch")
return
end
# Auto-react ANTES do dispatch — gesto chega <1s sem esperar Codex.
# Não bloqueia fluxo: se falhar, dispatch normal continua.
Captain::Hermes::AutoReactService.maybe_react!(message)
# Debounce: agrupa msgs incoming desde a última resposta real do
# agente. Quando inbox.typing_delay>0, schedule_hermes_response
# cancela jobs pendentes e enfileira só o último — aqui pegamos o
# texto agrupado pra Hermes ver o pensamento completo do cliente.
combined = combined_incoming_content(conversation, message)
Captain::Hermes::Client.new(conversation.inbox).dispatch(
message: message, conversation: conversation, content_override: combined
)
end
private
# Concatena texto de todas as msgs incoming entre a última resposta real
# (não-reaction) do agente e a msg âncora. Retorna nil se só tem 1 msg
# (pra dispatch usar message.content normal).
#
# Atenção: usa `reorder` em vez de `order` porque o model Message tem
# default_scope `order(created_at: :asc)` — sem reorder, a SQL final fica
# `ORDER BY created_at ASC, created_at DESC` e o ASC ganha. Resultado:
# last_real_outgoing virava a MAIS ANTIGA, agrupando msgs de turns
# passados (caso real: Hermes recebia "wifi+pet" colado mesmo em turns
# separados — visto em conv 6064 em 2026-05-02).
def combined_incoming_content(conversation, anchor_message)
last_real_outgoing = conversation.messages
.where(message_type: :outgoing)
.where("(content_attributes ->> 'is_reaction') IS NULL OR (content_attributes ->> 'is_reaction') != 'true'")
.reorder(created_at: :desc)
.first
scope = conversation.messages.where(message_type: :incoming).where('created_at <= ?', anchor_message.created_at)
scope = scope.where('created_at > ?', last_real_outgoing.created_at) if last_real_outgoing
texts = scope.reorder(created_at: :asc).pluck(:content).map(&:to_s).reject(&:blank?).uniq
return nil if texts.size <= 1
Rails.logger.info("[Captain::Hermes::Debounce] agrupando #{texts.size} msgs do cliente em conv #{conversation.id}")
texts.join("\n")
end
end

View File

@ -2,24 +2,39 @@
#
# 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
# captain_unit_id :bigint
# 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_captain_unit_id (captain_unit_id)
# index_captain_assistants_on_engine (engine)
# index_captain_assistants_on_parent_assistant_id (parent_assistant_id)
#
# Foreign Keys
#
# fk_rails_... (captain_unit_id => captain_units.id) ON DELETE => nullify
#
class Captain::Assistant < ApplicationRecord
include Avatarable
@ -29,6 +44,7 @@ class Captain::Assistant < ApplicationRecord
self.table_name = 'captain_assistants'
belongs_to :account
belongs_to :captain_unit, class_name: 'Captain::Unit', optional: true
has_many :documents, class_name: 'Captain::Document', dependent: :destroy_async
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy_async
has_many :captain_inboxes,
@ -43,9 +59,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 +79,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

@ -0,0 +1,40 @@
# == Schema Information
#
# Table name: captain_pricing_amounts
#
# id :bigint not null, primary key
# amount :decimal(10, 2) not null
# day_bucket :string
# period :string not null
# created_at :datetime not null
# updated_at :datetime not null
# captain_pricing_category_id :bigint not null
#
# Indexes
#
# idx_captain_pricing_amount_uniq (captain_pricing_category_id,period,day_bucket) UNIQUE
# index_captain_pricing_amounts_on_captain_pricing_category_id (captain_pricing_category_id)
#
# Foreign Keys
#
# fk_rails_... (captain_pricing_category_id => captain_pricing_categories.id)
#
# Valor por (categoria, período, dia da semana). day_bucket NULL = preço
# único todos os dias. day_bucket='mon_wed' = seg-qua. 'thu_sun' = qui-dom.
class Captain::PricingAmount < ApplicationRecord
self.table_name = 'captain_pricing_amounts'
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,
class_name: 'Captain::PricingCategory',
foreign_key: :captain_pricing_category_id,
inverse_of: :amounts
validates :period, inclusion: { in: PERIODS }
validates :day_bucket, inclusion: { in: DAY_BUCKETS, allow_nil: true }
validates :amount, numericality: { greater_than: 0 }
validates :captain_pricing_category_id,
uniqueness: { scope: %i[period day_bucket] }
end

View File

@ -0,0 +1,55 @@
# == Schema Information
#
# Table name: captain_pricing_categories
#
# id :bigint not null, primary key
# aliases :jsonb not null
# extra_person_starts_at :integer default(3), not null
# key :string not null
# created_at :datetime not null
# updated_at :datetime not null
# captain_unit_id :bigint not null
#
# Indexes
#
# index_captain_pricing_categories_on_captain_unit_id (captain_unit_id)
# index_captain_pricing_categories_on_captain_unit_id_and_key (captain_unit_id,key) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (captain_unit_id => captain_units.id)
#
# Categoria de preço por unidade — espelha o que tava em PricingTables.rb
# como Ruby hash. `key` é canônico ('apartamento', 'standard', etc),
# `aliases` é array de strings que o LLM pode digitar e bate via fuzzy
# match. `extra_person_starts_at` controla a partir de qual hóspede a taxa
# extra começa (default 3 = casal incluso).
class Captain::PricingCategory < ApplicationRecord
self.table_name = 'captain_pricing_categories'
belongs_to :captain_unit, class_name: 'Captain::Unit', inverse_of: :pricing_categories
has_many :amounts,
class_name: 'Captain::PricingAmount',
foreign_key: :captain_pricing_category_id,
inverse_of: :pricing_category,
dependent: :destroy
validates :key, presence: true, uniqueness: { scope: :captain_unit_id }
validates :extra_person_starts_at, numericality: { greater_than_or_equal_to: 1 }
validate :aliases_is_array
def matches?(needle)
return false if needle.blank?
candidates = ([key.to_s.tr('_', ' ')] + aliases.to_a).map { |s| s.to_s.downcase.strip }
candidates.include?(needle.to_s.downcase.strip.tr('_', ' ').squeeze(' '))
end
private
def aliases_is_array
return if aliases.is_a?(Array)
errors.add(:aliases, 'must be an array')
end
end

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
@ -63,6 +65,8 @@ class Captain::Unit < ApplicationRecord
has_many :pix_charges, class_name: 'Captain::PixCharge', dependent: :restrict_with_error
has_many :gallery_items, class_name: 'Captain::GalleryItem', foreign_key: :captain_unit_id, inverse_of: :captain_unit,
dependent: :destroy
has_many :pricing_categories, class_name: 'Captain::PricingCategory', foreign_key: :captain_unit_id,
inverse_of: :captain_unit, dependent: :destroy
encrypts :inter_client_secret
encrypts :inter_account_number

View File

@ -0,0 +1,33 @@
class Captain::AssistantResponsePolicy < ApplicationPolicy
def index?
@account_user.administrator? || @account_user.agent?
end
def show?
index?
end
def create?
manage?
end
def update?
manage?
end
def destroy?
manage?
end
private
def manage?
return true if @account_user.administrator?
if @account_user.custom_role.present?
return @account_user.custom_role.permissions.include?('knowledge_base_manage')
end
@account_user.agent?
end
end

View File

@ -0,0 +1,115 @@
# Configuração compartilhada da integração Captain ↔ Hermes Agent.
#
# A integração usa o Hermes como cérebro do atendimento (Nível 2):
# - Captain recebe msg WhatsApp
# - Dispara webhook do Hermes (POST /webhooks/captain-inbox-<id>)
# - Hermes processa via subscription Codex/etc dele
# - Hermes invoca plugin captain-http-callback que POSTa de volta no Captain
# - Captain cria mensagem outgoing e envia pro WhatsApp
#
# 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.
#
# 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.
#
# 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
module_function
def enabled_for?(inbox)
return false if inbox.blank?
return false unless inbox.respond_to?(:id)
return true if assistant_for(inbox)&.hermes?
legacy_inbox_ids.include?(inbox.id)
end
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(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(inbox)}/webhooks/#{subscription_name_for(inbox)}"
end
# Convenção de nome de subscription no Hermes:
# - Pra Hermes assistant criado pelo Construtor (tem hermes_profile_name):
# usa "captain-inbox-<slug>" (única por agente, independente de qual
# inbox o admin atrelou).
# - Pra agentes legados (Valentina, Nina) criados antes do Construtor:
# fallback pro padrão velho "captain-inbox-<inbox.id>".
def subscription_name_for(inbox)
assistant = assistant_for(inbox)
if assistant&.hermes_profile_name.present?
"captain-inbox-#{assistant.hermes_profile_name}"
else
"captain-inbox-#{inbox.id}"
end
end
def subscription_signing_secret(inbox)
assistant = assistant_for(inbox)
return assistant.hermes_subscription_secret if assistant&.hermes_subscription_secret.present?
ENV.fetch("CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_#{inbox.id}", nil)
end
def callback_signing_secret
ENV.fetch('CAPTAIN_HERMES_CALLBACK_SECRET', nil)
end
def reset_cache!
@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

@ -0,0 +1,167 @@
# Auto-react determinístico — dispara reaction antes do LLM processar.
#
# Por que existe: quando cliente manda "obrigado", "ok", foto, etc, ele
# espera o feedback IMEDIATO (gesto). Esperar 10-30s do LLM gerar
# resposta + decidir chamar tool é UX ruim. Captain detecta padrões
# comuns e reage <1s, em paralelo ao processamento normal.
#
# A resposta de texto continua vindo do Hermes normalmente — auto-react
# é COMPLEMENTAR, não substitui.
#
# Padrões:
# - Agradecimento/despedida → 🙏/❤️
# - Confirmação ("ok", "fechado", "perfeito", "blz", "show") → 👍
# - Imagem (sem texto explicativo) → 😍
# - Áudio sem ser "diferente" (não é dúvida complexa) → 🙏
#
# Conservative: só dispara em casos CLAROS. Em dúvida, deixa pro LLM
# decidir via react_to_message tool.
class Captain::Hermes::AutoReactService
THANKS_REGEX = /\b(muito\s+)?(obrigad[oa]|brigad[oa]|valeu|vlw|thanks|agrade[cç]o|agradecid[oa]|gratid[aã]o)\b/i
CONFIRMATION_REGEX = /\A(ok|okay|fechado|perfeit[oa]|blz|beleza|combinado|certo|certinho|[oó]tim[oa]|legal|show|maravilha|tranquilo|t[aá]\s*bom|pode\s*ser|isso\s*mesmo)[\s.!,]*\z/i
GREETING_REGEX = /\A(bom\s*dia|boa\s*tarde|boa\s*noite|oi|olá|ola|e\s*aí|hey|hi|hello)[\s.!,]*\z/i
FAREWELL_REGEX = /\b(tchau|at[eé]\s*(mais|logo|breve|a\s+pr[oó]xima)|falou|flw|abra[cç]os?|bjs|beijos?|boa\s+noite|bom\s+descanso|at[eé]\s+amanh[aã]|at[eé]\s+depois)\b/i
ENDING_CONTEXT_REGEX = /\b(encerr(ar|a|amos)|finaliz(ar|a|amos)|n[aã]o\s+preciso\s+mais|era\s+s[oó]\s+isso|s[oó]\s+isso|por\s+enquanto\s+[eé]\s+s[oó]|obrigad[oa]\s+pelo\s+atendimento)\b/i
EMOJI_ONLY_REGEX = /\A[\p{Emoji_Presentation}\p{Emoji}\uFE0F\s]+[\s.!,]*\z/
# Reação "ambiente" — em msgs neutras que não bateram nenhum padrão
# acima, rola dado e reage com emoji discreto. Cobre o gap entre
# saudação e despedida pra agente parecer mais vivo (~1 a cada 5 msgs).
# Filtros em ambient_eligible? evitam reagir em momentos de fluxo
# crítico (cliente mandando dados de reserva, fazendo pergunta).
AMBIENT_EMOJIS = %w[😊 💕 ✨ 💯 🤗].freeze
AMBIENT_PROBABILITY = 0.35
AMBIENT_RESERVATION_KEYWORDS = /cpf|reserv|pix|valor|preço|preco|quanto|hor[áa]rio|dia\b|data\b|categori|suite|quart|chal[ée]/i
def self.maybe_react!(message)
new(message).maybe_react!
end
def initialize(message)
@message = message
@conversation = message.conversation
end
def maybe_react!
return unless eligible?
return if already_reacted?
emoji = decide_emoji
return if emoji.blank?
create_reaction!(emoji)
Rails.logger.info("[Captain::Hermes::AutoReact] msg ##{@message.id} reagiu com #{emoji}")
rescue StandardError => e
Rails.logger.warn("[Captain::Hermes::AutoReact] failed for msg ##{@message&.id}: #{e.class} - #{e.message}")
end
private
def eligible?
return false if @message.blank? || @conversation.blank?
return false unless @message.message_type == 'incoming'
return false if @message.source_id.blank?
true
end
# Evita reaction duplicada quando OutgoingJob retentar (ex: dispatch
# retornou 401/5xx e Sidekiq reenfileirou). Sem essa guarda, cada retry
# cria uma reaction nova e cliente vê N emojis seguidos.
def already_reacted?
@conversation.messages
.where(message_type: :outgoing)
.where("content_attributes ->> 'external_source' = ?", 'hermes_auto_react')
.exists?(["(content_attributes ->> 'in_reply_to')::int = ?", @message.id])
end
def decide_emoji
text = @message.content.to_s.strip
return image_emoji if image_attachment?
return audio_emoji if audio_attachment? && text.length < 10
return '👋' if GREETING_REGEX.match?(text) && first_incoming_in_conversation?
return '❤️' if farewell?(text)
return '🙏' if THANKS_REGEX.match?(text)
return '👍' if CONFIRMATION_REGEX.match?(text)
return '❤️' if emoji_only?(text)
return ambient_emoji(text) if ambient_eligible?(text) && ambient_sampled?
nil
end
# Mensagens "neutras" elegíveis pra reação ambiente: nem curtas demais
# (provavelmente saudação que já pega regex), nem longas (narrativa
# pede atenção), sem termos de fluxo de reserva crítico (preço/cpf/data
# — cliente está esperando ação, não emoji). AS perguntas comuns
# (com "?") TAMBÉM elegíveis: WhatsApp de motel é majoritariamente
# interrogativo; se filtrar "?" o ambient nunca dispara em prod.
def ambient_eligible?(text)
return false if text.length < 6 || text.length > 180
return false if text.match?(AMBIENT_RESERVATION_KEYWORDS)
return false if text.match?(/\A\d/)
true
end
# Saudação só reage na PRIMEIRA mensagem da conversa pra não ficar
# forçado em conversa longa que retoma com "oi".
def first_incoming_in_conversation?
@conversation.messages
.where(message_type: :incoming)
.where('created_at <= ?', @message.created_at)
.count <= 1
end
def farewell?(text)
normalized = ActiveSupport::Inflector.transliterate(text.to_s.downcase)
FAREWELL_REGEX.match?(normalized) || ENDING_CONTEXT_REGEX.match?(normalized)
end
def emoji_only?(text)
text.present? && EMOJI_ONLY_REGEX.match?(text)
end
# Usa amostragem determinística por message.id para retries do Sidekiq
# não mudarem a decisão de reagir. O emoji também fica estável.
def ambient_sampled?
((@message.id.to_i * 1103515245 + 12_345) % 10_000) < (AMBIENT_PROBABILITY * 10_000)
end
def ambient_emoji(_text)
AMBIENT_EMOJIS[@message.id.to_i % AMBIENT_EMOJIS.length]
end
def image_attachment?
@message.attachments.exists?(file_type: :image)
end
def audio_attachment?
@message.attachments.exists?(file_type: :audio)
end
def image_emoji
'😍'
end
def audio_emoji
'🙏'
end
def create_reaction!(emoji)
assistant = @conversation.inbox.captain_assistant
@conversation.messages.create!(
message_type: :outgoing,
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
sender: assistant,
content: emoji,
content_attributes: {
is_reaction: true,
in_reply_to_external_id: @message.source_id,
in_reply_to: @message.id,
external_source: 'hermes_auto_react'
}
)
end
end

View File

@ -0,0 +1,209 @@
# Cliente HTTP que dispara mensagens do Captain pro webhook do Hermes Agent.
#
# Uso:
# Captain::Hermes::Client.new(inbox).dispatch(message: msg, conversation: conv)
#
# Resultado: POST autenticado via HMAC-SHA256 (X-Hub-Signature-256) no endpoint
# /webhooks/<subscription_name> do Hermes. O Hermes responde 202 imediato e
# processa em background. Quando terminar, invoca o plugin captain-http-callback
# que POSTa de volta no Captain (HermesCallbackController).
class Captain::Hermes::Client
TIMEOUT_SECONDS = 10
class DispatchError < StandardError; end
def initialize(inbox)
@inbox = inbox
end
def dispatch(message:, conversation:, content_override: nil)
payload = build_payload(message: message, conversation: conversation, content_override: content_override)
body = payload.to_json
headers = signed_headers(body)
Rails.logger.info "[Captain::Hermes::Client] dispatching msg #{message.id} (conv #{conversation.display_id}) → #{webhook_url}"
response = HTTParty.post(
webhook_url,
body: body,
headers: headers,
timeout: TIMEOUT_SECONDS
)
return response if response.success? || response.code == 202
raise DispatchError, "Hermes webhook returned HTTP #{response.code}: #{response.body.to_s.truncate(300)}"
rescue HTTParty::Error, Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED => e
raise DispatchError, "Network error contacting Hermes (#{e.class}): #{e.message}"
end
# Notificação proativa de evento do Captain pro Hermes — usado pra eventos
# do sistema (Pix pago, reserva expirando, etc) onde o agente deve mandar
# mensagem espontânea sem o cliente ter falado nada.
#
# `system_message` deve começar com `[SISTEMA: <event_type>]` pra Valentina
# diferenciar de fala real do cliente (ver regra correspondente em SOUL.md).
def notify_event(conversation:, event_type:, system_message:)
payload = build_event_payload(conversation, event_type, system_message)
body = payload.to_json
headers = signed_headers(body)
Rails.logger.info "[Captain::Hermes::Client] notifying event #{event_type} (conv #{conversation.display_id}) → #{webhook_url}"
response = HTTParty.post(webhook_url, body: body, headers: headers, timeout: TIMEOUT_SECONDS)
return response if response.success? || response.code == 202
raise DispatchError, "Hermes webhook returned HTTP #{response.code}: #{response.body.to_s.truncate(300)}"
rescue HTTParty::Error, Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED => e
raise DispatchError, "Network error contacting Hermes (#{e.class}): #{e.message}"
end
private
attr_reader :inbox
def webhook_url
Captain::Hermes.webhook_url_for(inbox)
end
def build_payload(message:, conversation:, content_override: nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
contact = conversation.contact
contact_attrs = contact&.custom_attributes.to_h.with_indifferent_access
cpf_digits = contact_attrs[:cpf].to_s.gsub(/\D/, '')
history = contact_history_snapshot(contact, conversation)
{
message: content_override.presence || text_for_hermes(message),
image_urls: image_urls_for_hermes(message),
contact_name: contact&.name,
contact_first_name: contact&.name.to_s.split.first,
contact_id: conversation.contact_id,
contact_cpf_present: cpf_digits.length == 11,
contact_email_present: contact&.email.to_s.include?('@'),
contact_total_reservas: contact_attrs[:total_reservas].to_i,
contact_ultima_suite: contact_attrs[:ultima_suite].to_s.presence,
last_reservation_date: history[:last_reservation_date],
last_reservation_status: history[:last_reservation_status],
last_reservation_amount: history[:last_reservation_amount],
last_reservation_suite: history[:last_reservation_suite],
last_conversation_at: history[:last_conversation_at],
total_conversations: history[:total_conversations],
conversation_id: conversation.display_id,
conversation_internal_id: conversation.id,
inbox_id: inbox.id,
inbox_name: inbox.name,
account_id: inbox.account_id,
message_id: message.id,
timestamp: Time.current.to_i
}
end
# Constroi payload pra notificação de evento sistema. Reusa todo o [ctx]
# do build_payload normal; só substitui `message` pelo system_message e
# marca `is_system_event=true` pra debug/logging.
def build_event_payload(conversation, event_type, system_message) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
contact = conversation.contact
contact_attrs = contact&.custom_attributes.to_h.with_indifferent_access
cpf_digits = contact_attrs[:cpf].to_s.gsub(/\D/, '')
history = contact_history_snapshot(contact, conversation)
{
message: system_message,
image_urls: [],
is_system_event: true,
event_type: event_type,
contact_name: contact&.name,
contact_first_name: contact&.name.to_s.split.first,
contact_id: conversation.contact_id,
contact_cpf_present: cpf_digits.length == 11,
contact_email_present: contact&.email.to_s.include?('@'),
contact_total_reservas: contact_attrs[:total_reservas].to_i,
contact_ultima_suite: contact_attrs[:ultima_suite].to_s.presence,
last_reservation_date: history[:last_reservation_date],
last_reservation_status: history[:last_reservation_status],
last_reservation_amount: history[:last_reservation_amount],
last_reservation_suite: history[:last_reservation_suite],
last_conversation_at: history[:last_conversation_at],
total_conversations: history[:total_conversations],
conversation_id: conversation.display_id,
conversation_internal_id: conversation.id,
inbox_id: inbox.id,
inbox_name: inbox.name,
account_id: inbox.account_id,
message_id: 0,
timestamp: Time.current.to_i
}
end
# Resolve texto da message pro Hermes consumir. Reusa
# Captain::OpenAiMessageBuilderService que JÁ implementa transcrição de
# áudio (Whisper) + placeholder pra outros attachments. Garante que
# mensagem nunca chega vazia mesmo quando cliente manda só áudio/foto.
# Imagens viram URL/base64 dentro do builder mas pra Hermes via texto, o
# generate_text_content normaliza pra "[image]" placeholder — ainda
# útil pro LLM saber que veio anexo visual.
def text_for_hermes(message)
raw = message.content.to_s
return raw if message.attachments.blank?
Captain::OpenAiMessageBuilderService.new(message: message).generate_text_content.presence || raw
rescue StandardError => e
Rails.logger.warn("[Captain::Hermes::Client] text_for_hermes fallback: #{e.class} - #{e.message}")
message.content.to_s
end
# URLs públicas das imagens que vieram nessa message. Plugin captain-webhook
# do Hermes baixa essas URLs localmente e popula event.media_urls — daí o
# gpt-5.3-codex (multimodal) consegue ler. Vídeo/PDF/etc ficam de fora por
# enquanto — só imagem é suportada pro LLM ver de fato.
def image_urls_for_hermes(message)
return [] if message.attachments.blank?
message.attachments.where(file_type: :image).filter_map do |att|
next nil unless att.file.attached?
att.download_url.presence || att.external_url.presence || att.file_url
end
rescue StandardError => e
Rails.logger.warn("[Captain::Hermes::Client] image_urls_for_hermes fallback: #{e.class} - #{e.message}")
[]
end
# Snapshot eager pra alimentar o [ctx]. Determinístico (lido do DB), só
# campos estruturados — pra detalhes livres o agente chama
# `get_contact_history` MCP. Limita a últimas reservation/conversation pra
# não estourar token budget.
def contact_history_snapshot(contact, current_conversation)
return {} if contact.blank?
last_res = Captain::Reservation
.where(contact_id: contact.id)
.where.not(status: 'draft')
.order(check_in_at: :desc)
.first
other_convs = contact.conversations.where.not(id: current_conversation.id)
{
last_reservation_date: last_res&.check_in_at&.to_date&.iso8601,
last_reservation_status: last_res&.status,
last_reservation_amount: last_res&.total_amount&.to_f,
last_reservation_suite: last_res&.suite_identifier,
last_conversation_at: other_convs.maximum(:last_activity_at)&.iso8601,
total_conversations: other_convs.count
}.compact
end
def signed_headers(body)
headers = { 'Content-Type' => 'application/json; charset=utf-8' }
secret = Captain::Hermes.subscription_signing_secret(inbox)
if secret.present?
sig = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
headers['X-Hub-Signature-256'] = "sha256=#{sig}"
else
Rails.logger.warn "[Captain::Hermes::Client] no signing secret for inbox #{inbox.id} — Hermes will reject"
end
headers
end
end

View File

@ -26,17 +26,58 @@ class Captain::Llm::EmbeddingService
private
# Embeddings sempre vão direto pra OpenAI tradicional — o endpoint Codex
# via ChatGPT OAuth não expõe /embeddings.
# Embeddings vão pra OpenAI tradicional por default (o endpoint Codex
# via ChatGPT OAuth não expõe /embeddings). Override opcional via env vars
# dedicadas — útil pra trocar provider de embedding (ex: Gemini
# OpenAI-compatible) sem alterar o provider de chat:
#
# CAPTAIN_EMBEDDING_API_KEY — sobrescreve API key
# CAPTAIN_EMBEDDING_ENDPOINT — sobrescreve base URL (sem /v1 no final)
# CAPTAIN_EMBEDDING_DIMENSIONS — força reduction (ex: 1536 pra Gemini
# bater com schema pgvector(1536))
def embed_with_legacy_openai(content, model)
legacy = Captain::Llm::ProviderConfig.legacy_openai_settings
api_base = legacy[:api_base].present? ? "#{legacy[:api_base]}/v1" : nil
settings = embedding_settings
api_base = settings[:api_base].present? ? "#{settings[:api_base]}/v1" : nil
embed_options = embed_extra_options
Llm::Config.with_api_key(legacy[:api_key], api_base: api_base) do |ctx|
ctx.embed(content, model: model).vectors
# Quando há config dedicada de embedding (CAPTAIN_EMBEDDING_API_KEY etc),
# forçamos provider :openai pra que o RubyLLM trate como OpenAI-compatible
# mesmo com modelos cujo nome auto-detectaria outro provider (ex:
# `gemini-embedding-001` apontado pro endpoint Gemini OpenAI-compat).
embed_options[:provider] = :openai if dedicated_embedding_config?
embed_options[:assume_model_exists] = true if dedicated_embedding_config?
Llm::Config.with_api_key(settings[:api_key], api_base: api_base) do |ctx|
ctx.embed(content, model: model, **embed_options).vectors
end
end
def dedicated_embedding_config?
installation_config_value('CAPTAIN_EMBEDDING_API_KEY').present?
end
def embedding_settings
custom_key = installation_config_value('CAPTAIN_EMBEDDING_API_KEY')
return Captain::Llm::ProviderConfig.legacy_openai_settings if custom_key.blank?
{
api_key: custom_key,
api_base: installation_config_value('CAPTAIN_EMBEDDING_ENDPOINT')&.chomp('/')
}
end
def embed_extra_options
dims = installation_config_value('CAPTAIN_EMBEDDING_DIMENSIONS')
return {} if dims.blank?
{ dimensions: dims.to_i }
end
def installation_config_value(name)
ENV.fetch(name, nil) ||
InstallationConfig.find_by(name: name)&.value
end
def instrumentation_params(content, model)
{
span_name: 'llm.captain.embedding',

View File

@ -9,9 +9,16 @@
# (CAPTAIN_CODEX_PROXY_URL, default http://localhost:3000/codex) e usa uma
# api_key dummy — o proxy ignora o Authorization header e usa OAuth interno.
#
# - openai_hermes_gateway: aponta para o Hermes Agent rodando em modo gateway
# (CAPTAIN_HERMES_GATEWAY_URL, default http://host.docker.internal:9877).
# O Hermes Gateway expõe API HTTP compatível com OpenAI e roteia internamente
# pra Codex/Anthropic/Gemini conforme sua config local em ~/.hermes/config.yaml.
# Auth: usa CAPTAIN_HERMES_GATEWAY_API_KEY se setado, senão dummy (gateway local).
#
# O "legacy" ruby-openai usado para PDF/Files API NÃO deve usar esse módulo:
# o endpoint Codex não expõe Files API, então esses serviços continuam
# apontando sempre para OpenAI tradicional.
# o endpoint Codex/Hermes não expõe Files API nem /embeddings, então esses
# serviços continuam apontando sempre para OpenAI tradicional via
# legacy_openai_settings.
class Captain::Llm::ProviderConfig
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
DEFAULT_OPENAI_ENDPOINT = 'https://api.openai.com'.freeze
@ -23,12 +30,20 @@ class Captain::Llm::ProviderConfig
# endpoint Codex da OpenAI via ChatGPT Plus.
DEFAULT_CODEX_MODEL = 'gpt-5.2'.freeze
# Hermes Gateway: defaults para o setup standard do Hermes Agent rodando
# como gateway HTTP local. O gateway escuta em 0.0.0.0:9877 por padrão e
# aceita o nome do modelo no formato `<provider>/<model>`.
DEFAULT_HERMES_GATEWAY_URL = 'http://host.docker.internal:9877'.freeze
DEFAULT_HERMES_GATEWAY_MODEL = 'anthropic/claude-opus-4-5'.freeze
HERMES_GATEWAY_DUMMY_KEY = 'hermes-gateway'.freeze
# Modelo leve pra tasks de background (extração de memória, verificação de
# contradição, traduções internas). Quando usamos Codex, reutilizamos o
# mesmo modelo do chat — o endpoint não expõe gpt-4o-mini.
# contradição, traduções internas). Quando usamos Codex/Hermes, reutilizamos
# o mesmo modelo do chat — esses endpoints não expõem gpt-4o-mini.
LIGHT_MODEL_DEFAULTS = {
'openai_api' => 'gpt-4o-mini',
'openai_codex_oauth' => DEFAULT_CODEX_MODEL
'openai_codex_oauth' => DEFAULT_CODEX_MODEL,
'openai_hermes_gateway' => DEFAULT_HERMES_GATEWAY_MODEL
}.freeze
class << self
@ -40,12 +55,16 @@ class Captain::Llm::ProviderConfig
provider == 'openai_codex_oauth'
end
def hermes_gateway?
provider == 'openai_hermes_gateway'
end
# Retorna { api_key:, api_base:, model: } para RubyLLM/Agents.
def settings
if codex_oauth?
codex_settings
else
openai_api_settings
case provider
when 'openai_codex_oauth' then codex_settings
when 'openai_hermes_gateway' then hermes_gateway_settings
else openai_api_settings
end
end
@ -88,6 +107,14 @@ class Captain::Llm::ProviderConfig
}
end
def hermes_gateway_settings
{
api_key: cfg('CAPTAIN_HERMES_GATEWAY_API_KEY').presence || HERMES_GATEWAY_DUMMY_KEY,
api_base: (cfg('CAPTAIN_HERMES_GATEWAY_URL').presence || DEFAULT_HERMES_GATEWAY_URL).chomp('/'),
model: cfg('CAPTAIN_HERMES_GATEWAY_MODEL').presence || DEFAULT_HERMES_GATEWAY_MODEL
}
end
def openai_api_settings
{
api_key: cfg('CAPTAIN_OPEN_AI_API_KEY'),

View File

@ -0,0 +1,111 @@
# Tabelas de preço por unidade do Captain — fonte de verdade backend pra
# tools MCP que precisam validar valor.
#
# Quando o LLM chama `generate_pix`, ele NÃO informa o valor; apenas
# categoria/período/data. Tool calcula via essa tabela no banco. Isso
# impede que o LLM invente um valor.
#
# Fonte de dados: tabelas Captain::PricingCategory + Captain::PricingAmount,
# escritas pelo admin via UI ou pelo Construtor via script `hermes-provision`.
# Antes de v147 esses dados viviam num arquivo Ruby hardcoded — migrado pra
# DB pra permitir cadastro dinâmico de unidades sem deploy.
module Captain::Mcp::PricingTables
PERIOD_KEYS = %w[2h 3h 4h 5h pernoite_promo pernoite_integral diaria].freeze
DAY_BUCKETS = %w[mon_wed thu_sun].freeze
DEFAULT_TZ = 'America/Sao_Paulo'.freeze
class << self
# Retorna {amount:, breakdown:} ou erro {error:} pra uma cobrança.
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
def calculate(unit_id:, suite_category:, period:, total_guests: 2, check_in_at: nil)
unit = Captain::Unit.find_by(id: unit_id)
return { error: "Unidade #{unit_id} não cadastrada." } if unit.blank?
category = find_category(unit, suite_category)
return { error: "Categoria '#{suite_category}' não reconhecida nesta unidade." } if category.blank?
period_key = normalize_period(period)
return { error: "Período '#{period}' inválido. Use: #{PERIOD_KEYS.join(', ')}." } if period_key.blank?
day_bucket = resolve_day_bucket(check_in_at)
base, used_bucket = resolve_amount(category, period_key, day_bucket)
return { error: base } if base.is_a?(String)
starts_at = category.extra_person_starts_at || 3
extra_guests = [total_guests.to_i - (starts_at - 1), 0].max
extra_total = extra_guests * unit.extra_person_fee.to_f
total = (base + extra_total).round(2)
{
amount: total,
breakdown: {
unit_id: unit.id,
suite_category: category.key,
period: period_key,
day_bucket: used_bucket,
base_price: base,
total_guests: total_guests,
extra_guests: extra_guests,
extra_person_fee: unit.extra_person_fee.to_f,
extra_total: extra_total
}
}
end
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize
def categories_for(unit_id)
Captain::PricingCategory.where(captain_unit_id: unit_id).order(:key).pluck(:key)
end
private
def find_category(unit, raw)
return nil if raw.blank?
needle = raw.to_s.downcase.strip.tr('_', ' ').squeeze(' ')
unit.pricing_categories.find { |cat| cat.matches?(needle) }
end
def resolve_amount(category, period_key, day_bucket)
amounts = category.amounts.where(period: period_key)
return ["Preço de '#{period_key}' não definido para '#{category.key}'.", nil] if amounts.empty?
# 1) Tenta match exato no day_bucket
hit = amounts.find_by(day_bucket: day_bucket)
return [hit.amount.to_f, day_bucket] if hit
# 2) Tenta preço flat (day_bucket NULL)
hit = amounts.find_by(day_bucket: nil)
return [hit.amount.to_f, nil] if hit
# 3) Bucket pedido não tem amount + não é flat
avail = amounts.pluck(:day_bucket).map(&:to_s).reject(&:blank?).uniq.join(', ').presence || 'flat'
["'#{category.key}/#{period_key}' não tem preço pro dia escolhido (#{day_bucket}). Disponível: #{avail}.", nil]
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 normalize_period(raw)
key = raw.to_s.downcase.strip.tr('-', '_')
return key if PERIOD_KEYS.include?(key)
case key
when 'pernoite', 'pernoite_normal', 'promocional' then 'pernoite_promo'
when 'feriado', 'pernoite_feriado', 'sex_sab', 'final_de_semana' then 'pernoite_integral'
when '3', '3 h', 'tres_horas', 'permanencia', 'permanencia_3h' then '3h'
when 'diária' then 'diaria'
end
end
end
end

View File

@ -0,0 +1,96 @@
# Servidor MCP (Model Context Protocol) HTTP do Captain.
#
# Implementa subset suficiente da spec MCP pra Hermes Agent consumir como
# cliente via `hermes mcp add captain-tools --url <url>`. Métodos
# implementados:
# - initialize — handshake (cliente apresenta info, server responde
# capabilities + serverInfo)
# - tools/list — devolve a lista de tools registradas
# - tools/call — executa uma tool específica e devolve o resultado
# - notifications/initialized — no-op (cliente confirma que está pronto)
# - ping — no-op (health check)
#
# Não suporta SSE/streaming ainda — modo POST/JSON síncrono basta pro
# caso de uso atual (tools que retornam rápido como add_label, faq_lookup).
#
# Auth/segurança ficam no controller (HMAC), aqui só roteia.
class Captain::Mcp::Server
PROTOCOL_VERSION = '2024-11-05'.freeze
SERVER_NAME = 'captain-mcp'.freeze
SERVER_VERSION = '0.1.0'.freeze
class << self
def handle(request, context: {})
new(context: context).handle(request)
end
end
def initialize(context: {})
@context = context || {}
end
def handle(request)
rid = request['id']
method = request['method'].to_s
params = request['params'] || {}
dispatch(method, rid, params)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::Server] error handling #{method}: #{e.class}: #{e.message}")
error_response(rid, -32_603, "Internal error: #{e.message}")
end
private
def dispatch(method, rid, params)
case method
when 'initialize' then respond(rid, initialize_result(params))
when 'tools/list' then respond(rid, { tools: Captain::Mcp::ToolRegistry.descriptors })
when 'tools/call' then respond(rid, tools_call(params))
when 'ping' then respond(rid, {})
when 'notifications/initialized', 'notifications/cancelled' then nil
else
error_response(rid, -32_601, "Method not found: #{method}")
end
end
attr_reader :context
def initialize_result(_params)
{
protocolVersion: PROTOCOL_VERSION,
capabilities: {
tools: { listChanged: false }
},
serverInfo: {
name: SERVER_NAME,
version: SERVER_VERSION
}
}
end
def tools_call(params)
name = params['name'].to_s
args = params['arguments'] || {}
Captain::Mcp::ToolRegistry.call(name, args, context: context)
end
def respond(id, result)
{
jsonrpc: '2.0',
id: id,
result: result
}
end
def error_response(id, code, message)
{
jsonrpc: '2.0',
id: id,
error: {
code: code,
message: message
}
}
end
end

View File

@ -0,0 +1,47 @@
# Registry centralizado das tools MCP do Captain.
#
# Adicionar uma tool nova = incluir a classe em TOOLS abaixo. Cada tool
# herda de Captain::Mcp::Tools::BaseTool e responde a .to_mcp_descriptor
# (pra `tools/list`) e #call(args, context:) (pra `tools/call`).
#
# Hermes consulta tools/list pra saber o que pode chamar e tools/call pra
# executar. Toda tool aqui está disponível pra qualquer profile do Hermes
# que se conecte ao MCP server do Captain via `hermes mcp add`.
class Captain::Mcp::ToolRegistry
TOOLS = [
Captain::Mcp::Tools::AddLabelTool,
Captain::Mcp::Tools::FaqLookupTool,
Captain::Mcp::Tools::GeneratePixTool,
Captain::Mcp::Tools::UpdateContactTool,
Captain::Mcp::Tools::GetContactHistoryTool,
Captain::Mcp::Tools::CheckPixPaymentTool,
Captain::Mcp::Tools::SendSuiteImagesTool,
Captain::Mcp::Tools::RescheduleReservationTool,
Captain::Mcp::Tools::ReactToMessageTool,
Captain::Mcp::Tools::CheckSuiteAvailabilityTool,
# Construtor (admin scope) — usadas pelo profile Hermes "construtor" pra criar novos agentes
Captain::Mcp::Tools::ListAssistantsTool,
Captain::Mcp::Tools::GetAssistantPricingTool,
Captain::Mcp::Tools::GetAssistantFaqsTool,
Captain::Mcp::Tools::GetAssistantScenarioTool,
Captain::Mcp::Tools::SaveAgentSpecTool
# Captain::Mcp::Tools::HandoffTool — fluxo via automation hoje, MCP futuro
].freeze
class << self
def descriptors
TOOLS.map(&:to_mcp_descriptor)
end
def find(name)
TOOLS.find { |klass| klass.name == name.to_s }
end
def call(name, args, context:)
klass = find(name)
raise ArgumentError, "Tool não registrada: #{name}" if klass.nil?
klass.new.call(args || {}, context: context || {})
end
end
end

View File

@ -0,0 +1,82 @@
# Tool MCP: adiciona uma etiqueta na conversation atual.
#
# Caso de uso: Hermes detecta cliente recorrente / VIP / situação especial
# e quer marcar a conversa pro time humano filtrar depois.
#
# Exemplos de uso pelo LLM:
# - "marca como cliente_recorrente"
# - "etiqueta como pedido_desconto"
class Captain::Mcp::Tools::AddLabelTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'add_label'
end
def description
'Adiciona uma etiqueta (label) à conversa atual do cliente. ' \
'Use pra marcar contexto importante: cliente_recorrente, pedido_desconto, ' \
'reclamacao, vip, etc. A etiqueta deve ser snake_case curto.'
end
def input_schema
{
type: 'object',
properties: {
label: {
type: 'string',
description: 'Nome da etiqueta em snake_case (ex: "cliente_recorrente").'
},
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid) que aparece em [ctx: cid=N] no início da mensagem do cliente. Obrigatório.'
}
},
required: %w[label conversation_id]
}
end
end
def call(args, context:)
label = args['label'].to_s.strip.downcase
return error_response('Argumento "label" é obrigatório.') if label.blank?
conversation = resolve_conversation(args, context)
return error_response('Conversation atual não encontrada. Passe conversation_id em arguments (cid do [ctx]).') if conversation.blank?
ensure_account_label!(conversation.account, label)
conversation.add_labels([label])
text_response("Etiqueta '#{label}' adicionada à conversa #{conversation.display_id}.")
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::AddLabelTool] error: #{e.class}: #{e.message}")
error_response("Falha ao adicionar etiqueta: #{e.message}")
end
private
# LLM passa conversation_id em arguments (lendo do [ctx: cid=N]).
# Context (header/body) fica como fallback caso algum dia o cliente MCP
# passe a propagar contexto automaticamente.
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
# Conversation#add_labels só salva a tag via acts_as_taggable. Pra a label
# aparecer no sidebar/dropdown da UI do Chatwoot, ela precisa existir como
# registro oficial em account.labels (model Label). Se não existir, criamos
# com cor neutra — gerência pode ajustar depois pelo painel.
def ensure_account_label!(account, title)
return if account.labels.exists?(title: title)
account.labels.create!(
title: title,
description: 'Criada automaticamente via MCP (Hermes Agent)',
color: '#5C7CFA',
show_on_sidebar: true
)
end
end

View File

@ -0,0 +1,56 @@
# Interface base pras tools MCP do Captain.
#
# Cada tool concreta herda desta classe e implementa:
# - .name — identificador (snake_case)
# - .description — texto pro LLM decidir quando chamar
# - .input_schema — JSON Schema (Draft 2020-12) dos argumentos
# - #call(args, context:) — execução real
#
# context é um hash com metadata da invocação (ex: conversation_id,
# inbox_id, account_id) extraído do request MCP. Tools usam isso pra
# resolver entidades do Captain (Conversation, Inbox, etc).
class Captain::Mcp::Tools::BaseTool
class ExecutionError < StandardError; end
class << self
def name
raise NotImplementedError, "#{self} must implement .name"
end
def description
raise NotImplementedError, "#{self} must implement .description"
end
def input_schema
raise NotImplementedError, "#{self} must implement .input_schema"
end
def to_mcp_descriptor
{
name: name,
description: description,
inputSchema: input_schema
}
end
end
def call(_args, context:)
raise NotImplementedError, "#{self.class} must implement #call"
end
protected
def text_response(text)
{
content: [{ type: 'text', text: text.to_s }],
isError: false
}
end
def error_response(message)
{
content: [{ type: 'text', text: message.to_s }],
isError: true
}
end
end

View File

@ -0,0 +1,116 @@
# Tool MCP: consulta status de pagamento Pix de uma reserva.
#
# Caso de uso: cliente diz "já paguei", "tá caindo?", "confirma aí". Tool
# consulta a cobrança mais recente da conversa diretamente no Banco Inter
# via Captain::Inter::CobStatusService. Se confirmado pago, atualiza
# Captain::PixCharge + Captain::Reservation + dispara
# Captain::Payments::ConfirmationService (que cuida de marcar reserva
# confirmada, postar mensagem de confirmação, mover labels, etc).
#
# Idempotente: chamadas repetidas com Pix já pago retornam mesmo resultado
# sem efeito colateral. Cliente pode perguntar várias vezes que tá tudo bem.
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
class Captain::Mcp::Tools::CheckPixPaymentTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'check_pix_payment'
end
def description
'Verifica se o Pix da reserva já foi pago no Banco Inter. Use quando o cliente ' \
'avisar que pagou ou perguntar status. Retorna: já pago / ainda pendente / não há cobrança. ' \
'Quando confirmar pago, dispara internamente confirmação da reserva (mensagem de ' \
'confirmação vai pro cliente automaticamente).'
end
def input_schema
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
txid: {
type: 'string',
description: 'Opcional. TXID específico da cobrança. Se vazio, pega a Pix mais recente da conversa.'
}
},
required: ['conversation_id']
}
end
end
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
charge = find_charge(conversation, args['txid'])
return text_response('Não há cobrança Pix vinculada a esta conversa. Você pode gerar uma nova com generate_pix.') if charge.blank?
if already_paid?(charge)
return text_response("Pagamento já confirmado para a reserva ##{charge.reservation_id} (R$ #{format('%.2f',
charge.original_value.to_f)}). Pode seguir os próximos passos.")
end
status_result = Captain::Inter::CobStatusService.new(charge).call
if status_result[:paid]
mark_charge_as_paid!(charge, status_result)
paid_amount = status_result[:paid_value].presence || charge.original_value
text_response("Pagamento confirmado no Inter para reserva ##{charge.reservation_id} (TXID #{charge.txid}, R$ #{format('%.2f',
paid_amount.to_f)}). Reserva atualizada.")
else
label = status_result[:status].presence || 'ATIVA'
text_response(
"Ainda não consta pago no Inter (status: #{label}). Pode levar alguns minutos pra cair — " \
'vale aguardar e tentar de novo em 1-2 min.'
)
end
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::CheckPixPaymentTool] error: #{e.class}: #{e.message}")
error_response("Erro ao consultar pagamento: #{e.message}")
end
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def find_charge(conversation, txid)
scope = Captain::PixCharge.joins(:reservation)
.where(captain_reservations: { conversation_id: conversation.id, account_id: conversation.account_id })
scope = scope.where(txid: txid.to_s.strip) if txid.present?
scope.order(created_at: :desc).first
end
def already_paid?(charge)
charge.respond_to?(:paid?) ? charge.paid? : charge.status.to_s == 'paid' || charge.reservation&.payment_status.to_s == 'paid'
end
def mark_charge_as_paid!(charge, status_result)
updates = {
status: 'paid',
raw_webhook_payload: status_result[:raw_payload]
}
updates[:e2eid] = status_result[:end_to_end_id] if charge.e2eid.blank? && status_result[:end_to_end_id].present?
updates[:paid_at] = Time.current if charge.paid_at.blank?
charge.update!(updates)
reservation = charge.reservation
return if reservation.blank? || reservation.payment_status.to_s == 'paid'
Captain::Payments::ConfirmationService.new(
reservation: reservation,
source: 'mcp_check_pix_payment',
payload: status_result[:raw_payload]
).perform
end
end
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength

View File

@ -0,0 +1,159 @@
# Tool MCP: consulta status real-time das suítes da unidade.
#
# Reusa as Captain::CustomTool já cadastradas no painel (Ferramentas →
# status_suites_<unidade>) que já apontam pro PlugPlay (oxpi.com.br) com
# auth headers PLUG-PLAY-ID + PLUG-PLAY-TOKEN específicos por unit.
#
# Caso de uso: cliente pediu pra reservar hidromassagem; antes de gerar
# Pix, agente confere se tem suíte livre ("Quer hidro pra hoje 23h? Tenho
# 2 livres no momento" vs "Hidro tá lotada hoje, posso te oferecer Luxo?").
#
# Resolve unit via Assistant.captain_unit_id (preferencial) ou CaptainInbox
# (fallback). Mapping unit → CustomTool é por substring no nome da unit.
class Captain::Mcp::Tools::CheckSuiteAvailabilityTool < Captain::Mcp::Tools::BaseTool
# Match unit name → sufixo do slug da Captain::CustomTool. Mantém em código
# porque são 8 unidades fixas; se virar dezenas, vira coluna em Captain::Unit.
UNIT_NAME_PATTERNS = {
/qnn|midhaus/i => 'qnn01',
/dolce/i => 'dolceamore',
/express/i => 'primeexpress',
/prime\s*al|águas\s*lindas/i => 'primeal',
/prime\s*vl|prime\s*ade|cei|asa\s*norte/i => 'primevl'
}.freeze
TIMEOUT_SECONDS = 8
class << self
def name
'check_suite_availability'
end
def description
'Consulta em tempo real quais suítes estão livres na unidade. Use ANTES de chamar generate_pix ' \
'quando o cliente quiser fechar — confirma se a categoria desejada tem disponibilidade. ' \
'Retorna lista por categoria com contagem livre/ocupada e número da suíte.'
end
def input_schema
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID da conversa (cid do [ctx]). Obrigatório pra resolver a unidade.'
},
suite_category: {
type: 'string',
description: 'Filtra por categoria (ex: "hidromassagem", "luxo", "standard"). Opcional — sem isso traz todas.'
}
},
required: ['conversation_id']
}
end
end
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
unit = resolve_unit(conversation, context)
return error_response('Unidade do Captain não vinculada à conversa.') if unit.blank?
tool = find_status_tool(unit)
return error_response("Unidade '#{unit.name}' não tem custom_status_suites cadastrado. Avise a gerência.") if tool.nil?
suites = fetch_suites(tool)
return error_response('Falha ao consultar status das suítes (timeout/auth). Cliente que aguarde.') if suites.nil?
summary = summarize(suites, args['suite_category'])
text_response(summary)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::CheckSuiteAvailabilityTool] error: #{e.class}: #{e.message}")
error_response("Erro: #{e.message}")
end
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def resolve_unit(conversation, context)
asst_id = context && (context[:assistant_id] || context['assistant_id'])
if asst_id
asst = Captain::Assistant.find_by(id: asst_id)
return asst.captain_unit if asst&.captain_unit_id.present?
end
ci = CaptainInbox.find_by(inbox_id: conversation.inbox_id)
ci&.captain_unit
end
def find_status_tool(unit)
suffix = UNIT_NAME_PATTERNS.find { |re, _| re.match?(unit.name.to_s) }&.last
return nil if suffix.blank?
Captain::CustomTool.find_by(account_id: unit.account_id,
slug: "custom_status_suites_#{suffix}",
enabled: true)
end
def fetch_suites(tool)
headers = headers_for(tool)
response = HTTParty.get(tool.endpoint_url, headers: headers, timeout: TIMEOUT_SECONDS)
return nil unless response.success?
parsed = JSON.parse(response.body)
return nil unless parsed.is_a?(Array)
parsed
rescue HTTParty::Error, Net::ReadTimeout, Net::OpenTimeout, JSON::ParserError => e
Rails.logger.warn("[Captain::Mcp::CheckSuiteAvailabilityTool] fetch falhou: #{e.class}: #{e.message}")
nil
end
def headers_for(tool)
return {} unless tool.auth_type == 'custom_headers'
list = tool.auth_config.to_h['headers'].to_a
list.each_with_object({}) { |h, acc| acc[h['name'].to_s] = h['value'].to_s }
end
# Agrupa por classe e mostra livre/ocupada + nº das suítes livres. Filtra
# por categoria se passado. Retorno é texto curto pro LLM repassar resumido.
def summarize(suites, filter_category)
grouped = group_filtered(suites, filter_category)
return "Nenhuma suíte da categoria '#{filter_category}' nesta unidade." if filter_category.present? && grouped.empty?
lines = grouped.map { |classe, list| summary_line(classe, list) }
header = filter_category.present? ? "Status #{filter_category} agora:" : 'Status das suítes agora:'
[header, *lines].join("\n")
end
def group_filtered(suites, filter_category)
grouped = suites.group_by { |s| s['classe'].to_s.upcase.strip }
return grouped if filter_category.blank?
grouped.select { |k, _| k.include?(filter_category.to_s.upcase) }
end
def summary_line(classe, list)
livres = list.select { |s| status_livre?(s) }
ocupadas = list.size - livres.size
refs = livres.map { |s| s['ref'].to_s }.reject(&:blank?).first(8).join(', ')
suffix = livres.any? ? "#{refs}" : ''
"- #{classe.titleize}: #{livres.size} livre(s) / #{ocupadas} ocupada(s)#{suffix}"
end
def status_livre?(suite)
return false if suite['isOcupado']
return true if suite['statusId'] == 1
return true if suite['status'].to_s.casecmp('Livre').zero?
false
end
end

View File

@ -0,0 +1,76 @@
# Tool MCP: busca semântica em FAQs/documentação aprovada pelas gerentes.
#
# Caso de uso típico: cliente pergunta algo que NÃO está na skill estruturada
# do Hermes (ex: aceita pet, formas de pagamento alternativo, política de
# alguma situação específica). Em vez de inventar, Hermes chama esta tool
# e responde com base no FAQ atualizado em tempo real pelo Captain UI.
#
# Reaproveita Captain::Tools::SearchReplyDocumentationService — exatamente
# o mesmo serviço que o orquestrador interno do Captain usava antes,
# garantindo que Hermes vê os mesmos FAQs que o caminho legado veria.
class Captain::Mcp::Tools::FaqLookupTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'faq_lookup'
end
def description
'Busca semântica em FAQs/documentação aprovada pelas gerentes do hotel. ' \
'Use quando o cliente perguntar algo que NÃO está na sua skill ' \
'(ex: política de pets, horários especiais, convênios, regras pontuais). ' \
'Retorna até 5 perguntas/respostas mais próximas semanticamente da query. ' \
'Se não encontrar nada relevante, prefira transferir pro humano em vez ' \
'de inventar.'
end
def input_schema
{
type: 'object',
properties: {
query: {
type: 'string',
description: 'Pergunta ou tema a buscar em linguagem natural ' \
'(ex: aceitam pets, estacionamento coberto, ' \
'forma de pagamento sem ser Pix).'
}
},
required: ['query']
}
end
end
def call(args, context:)
query = args['query'].to_s.strip
return error_response('Argumento "query" é obrigatório.') if query.blank?
account = resolve_account(context)
return error_response('Account não encontrada no contexto MCP.') if account.blank?
assistant = resolve_assistant(context, account)
result = ::Captain::Tools::SearchReplyDocumentationService.new(
account: account,
assistant: assistant
).execute(query: query)
text_response(result.presence || 'Nenhum FAQ relevante encontrado pra essa pergunta.')
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::FaqLookupTool] error: #{e.class}: #{e.message}")
error_response("Falha na busca de FAQ: #{e.message}")
end
private
def resolve_account(context)
account_id = context[:account_id]
return nil if account_id.blank?
Account.find_by(id: account_id)
end
def resolve_assistant(context, account)
assistant_id = context[:assistant_id]
return nil if assistant_id.blank?
account.captain_assistants.find_by(id: assistant_id)
end
end

View File

@ -0,0 +1,432 @@
# Tool MCP: gera cobrança Pix Inter pra reserva de uma suíte.
#
# Caso de uso: cliente confirmou reserva (categoria + dia + duração).
# Hermes invoca esta tool com os dados estruturados; o CAPTAIN calcula o
# valor (consultando Captain::Mcp::PricingTables — fonte de verdade
# backend) e dispara a cobrança Pix via integração Inter já existente.
#
# **NUNCA aceitamos `amount` do LLM** — isso evita que ele invente
# desconto VIP, cortesia ou erro de cálculo. O LLM só fornece os dados
# de classificação; a tabela hardcoded no Captain decide o valor.
#
# Fluxo:
# 1. Resolve Conversation → Inbox → Captain::Unit
# 2. Lookup pricing (Captain::Mcp::PricingTables.calculate)
# 3. Cria/reusa Captain::Reservation (status=draft)
# 4. Captain::Inter::CobService gera Pix (txid + copia-e-cola)
# 5. Posta mensagem outgoing na conversa com link curto do pagamento
# 6. Marca conversa com label `aguardando_pagamento`
# 7. Retorna resumo curto pro LLM (sem URL pra evitar reposta colada)
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
class Captain::Mcp::Tools::GeneratePixTool < Captain::Mcp::Tools::BaseTool
DEFAULT_DEPOSIT_RATIO = 0.5 # sinal padrão = 50% do total
HOURS_BY_PERIOD = {
'3h' => 3,
'pernoite_promo' => 13, # 21h → 10h
'pernoite_integral' => 13,
'diaria' => 24
}.freeze
class << self
def name
'generate_pix'
end
def description
'Gera cobrança Pix pro sinal da reserva (50% do total). Use quando o cliente ' \
'confirmou: categoria de suíte, dia/horário, e tem nome+CPF cadastrados. ' \
'O Captain CALCULA o valor pela tabela oficial — você só passa os dados de ' \
'classificação, NUNCA o valor. A tool envia o link do Pix direto pro cliente; ' \
'você só confirma que foi gerado.'
end
def input_schema
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
suite_category: {
type: 'string',
description: 'Categoria da suíte (ex: "suite_master", "apartamento", "mini_chale_45", "chale_2_suites", "suite_ouro", "chale_master_4_suites"). Aceita variações naturais.'
},
period: {
type: 'string',
enum: %w[3h pernoite_promo pernoite_integral diaria],
description: 'Tipo de permanência. "pernoite_promo" = Dom-Qui (mais barato). ' \
'"pernoite_integral" = Sex/Sáb/Feriado/Véspera (mais caro). "3h" = permanência curta. "diaria" = 24h.'
},
total_guests: {
type: 'integer',
description: 'Quantidade TOTAL de hóspedes (não só os extras). Default 2 (casal). A taxa de pessoa extra é calculada automaticamente conforme regra da categoria.',
default: 2
},
check_in_date: {
type: 'string',
description: 'Data de check-in (YYYY-MM-DD ou DD/MM/YYYY). Default: hoje no fuso da conta.'
}
},
required: %w[conversation_id suite_category period]
}
end
end
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]) em arguments.') if conversation.blank?
unit = resolve_unit(conversation, context)
return error_response('Unidade do Captain não vinculada à inbox dessa conversa.') if unit.blank?
# Sem credencial Inter: vai DIRETO pro fallback de página de reserva ao
# invés de retornar erro pro LLM (que ele ia transformar em "vou
# verificar" e travar). Cliente recebe link da página oficial pra
# finalizar manualmente — UX uniforme.
return dispatch_no_pricing_fallback!(conversation, unit, args, 'inter_credentials_missing') unless unit.inter_credentials_present?
contact = conversation.contact
hydrate_contact_from_recent_messages!(contact, conversation)
missing = identity_missing_fields(contact)
return error_response("Faltam dados do cliente pra gerar Pix: #{missing.join(', ')}. Peça ao cliente antes de chamar esta tool.") if missing.any?
pricing = Captain::Mcp::PricingTables.calculate(
unit_id: unit.id,
suite_category: args['suite_category'],
period: args['period'],
total_guests: (args['total_guests'] || 2).to_i
)
# Erro estrutural (categoria não existe nessa unit, período inválido,
# dia indisponível). Antes retornava error_response e o LLM travava em
# "Um momento" sem mandar nada. Agora despacha link da página de
# reserva pra cliente concluir lá — UX consistente, marca pra triagem.
if pricing[:error].present?
Rails.logger.warn("[Captain::Mcp::GeneratePixTool] pricing inválido — usando fallback: #{pricing[:error]}")
return dispatch_no_pricing_fallback!(conversation, unit, args, "pricing: #{pricing[:error]}")
end
total_amount = pricing[:amount]
deposit = (total_amount * DEFAULT_DEPOSIT_RATIO).round(2)
reservation = build_or_update_reservation!(conversation, unit, args, pricing, total_amount, deposit)
begin
charge = Captain::Inter::CobService.new(reservation, amount: deposit).call
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GeneratePixTool] Inter falhou — usando fallback: #{e.class}: #{e.message}")
return dispatch_fallback_link!(conversation, unit, reservation, pricing, total_amount, deposit)
end
# Move da fase 'draft' pra 'pending_payment' — agora a reservation
# aparece nas abas/Kanban "Aguardando PIX" do painel.
reservation.update!(status: :pending_payment)
dispatch_link_message(conversation, charge, deposit)
mark_awaiting_payment(conversation)
text_response(
"Pix gerado: sinal R$ #{format('%.2f',
deposit)} (50% de R$ #{format('%.2f',
total_amount)} #{pricing[:breakdown][:suite_category]} / #{pricing[:breakdown][:period]}). Link enviado em mensagem separada na conversa #{conversation.display_id}."
)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GeneratePixTool] error: #{e.class}: #{e.message}")
Rails.logger.error(e.backtrace.first(5).join("\n"))
error_response("Erro ao gerar Pix: #{e.message}")
end
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
# Resolve unit em 3 níveis (defesa em profundidade contra divergência
# entre Captain::Assistant.captain_unit_id e CaptainInbox.captain_unit_id):
# 1. Assistant.captain_unit (autoritativo — setado por hermes-provision
# e admin UI; não vaza entre agentes que compartilham inbox).
# 2. CaptainInbox legacy (fallback pré-engine column; só funciona se
# a inbox tem 1 agente único).
# 3. Captain::Unit.inbox_id legacy (fallback antigo, antes de CaptainInbox).
def resolve_unit(conversation, context = nil)
asst_id = context && (context[:assistant_id] || context['assistant_id'])
if asst_id
asst = Captain::Assistant.find_by(id: asst_id)
return asst.captain_unit if asst&.captain_unit_id.present?
end
captain_inbox = CaptainInbox.find_by(inbox_id: conversation.inbox_id)
return captain_inbox.captain_unit if captain_inbox&.captain_unit.present?
Captain::Unit.find_by(account_id: conversation.account_id, inbox_id: conversation.inbox_id)
end
def identity_missing_fields(contact)
missing = []
missing << 'nome completo' if contact&.name.to_s.squish.length < 3
cpf_digits = contact&.custom_attributes.to_h.with_indifferent_access[:cpf].to_s.gsub(/\D/, '')
missing << 'CPF' if cpf_digits.length != 11
missing
end
CPF_REGEX = /\b(\d{3}\.?\d{3}\.?\d{3}-?\d{2}|\d{11})\b/
NAME_RUN_REGEX = /\A([\p{L}'\-]{3,}(?:\s+[\p{L}'\-]{2,}){1,5})/u
# Cliente normalmente envia nome+CPF junto numa mensagem ("Rodrigo Borba 12345678901").
# Quando o contact ainda não tem CPF/nome cadastrados, varremos as últimas
# 10 mensagens incoming pra extrair e popular antes de chamar a Inter.
def hydrate_contact_from_recent_messages!(contact, conversation)
return if contact.blank?
needs_cpf = contact.custom_attributes.to_h.with_indifferent_access[:cpf].to_s.gsub(/\D/, '').length != 11
needs_name = contact.name.to_s.squish.length < 3
return unless needs_cpf || needs_name
extracted_cpf = nil
extracted_name = nil
conversation.messages
.where(message_type: :incoming, sender_type: 'Contact')
.reorder(created_at: :desc)
.limit(10).each do |msg|
text = msg.content.to_s
extracted_cpf ||= extract_cpf(text) if needs_cpf
extracted_name ||= extract_name(text) if needs_name
break if (!needs_cpf || extracted_cpf) && (!needs_name || extracted_name)
end
updates = {}
updates[:name] = extracted_name if needs_name && extracted_name.present?
updates[:custom_attributes] = contact.custom_attributes.to_h.merge('cpf' => extracted_cpf) if needs_cpf && extracted_cpf.present?
return if updates.empty?
contact.update!(updates)
Rails.logger.info("[Captain::Mcp::GeneratePixTool] hydrated contact #{contact.id} with #{updates.keys}")
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::GeneratePixTool] hydrate failed: #{e.class} - #{e.message}")
end
def extract_cpf(text)
digits = text.to_s.scan(CPF_REGEX).flatten.first.to_s.gsub(/\D/, '')
digits.length == 11 ? digits : nil
end
# Extrai 2-6 palavras alfabéticas seguidas no início do texto, ignorando
# números/pontuação ao redor.
def extract_name(text)
cleaned = text.to_s.gsub(/[\d.,;:\-()]+/, ' ').squish
match = cleaned.match(NAME_RUN_REGEX)
return nil if match.nil?
candidate = match[1].strip
return nil if candidate.length < 5
candidate.split.map(&:capitalize).join(' ')
end
# Reusa a draft mais recente da conversa (últimas 2h) ou cria nova.
# Atualiza campos com base nos novos args (categoria pode ter mudado).
def build_or_update_reservation!(conversation, unit, args, pricing, total_amount, deposit)
check_in_at = parse_check_in(args['check_in_date'], conversation.account)
period_hours = HOURS_BY_PERIOD[pricing[:breakdown][:period]] || 13
check_out_at = check_in_at + period_hours.hours
reservation = recent_draft_for(conversation) || Captain::Reservation.new(
account: conversation.account,
inbox: conversation.inbox,
contact: conversation.contact,
contact_inbox: conversation.contact_inbox,
conversation: conversation,
captain_unit_id: unit.id
)
reservation.assign_attributes(
suite_identifier: pricing[:breakdown][:suite_category],
check_in_at: check_in_at,
check_out_at: check_out_at,
status: :draft,
payment_status: 'pending',
total_amount: total_amount,
metadata: (reservation.metadata.to_h).merge(
'full_amount' => total_amount,
'deposit_amount' => deposit,
'created_by' => 'mcp_generate_pix_tool',
'pricing_breakdown' => pricing[:breakdown].stringify_keys
)
)
reservation.save!
reservation
end
def recent_draft_for(conversation)
Captain::Reservation
.where(conversation_id: conversation.id, status: 'draft')
.where('updated_at > ?', 2.hours.ago)
.order(updated_at: :desc).first
end
def parse_check_in(raw, account)
tz = account.respond_to?(:timezone) ? (account.timezone.presence || Time.zone.name) : Time.zone.name
Time.use_zone(tz) do
base = if raw.blank?
Time.zone.today
else
try_parse_date(raw) || Time.zone.today
end
Time.zone.local(base.year, base.month, base.day, 21, 0, 0) # entrada padrão 21h
end
end
def try_parse_date(raw)
Date.iso8601(raw)
rescue ArgumentError
begin
Date.strptime(raw, '%d/%m/%Y')
rescue ArgumentError
nil
end
end
def dispatch_link_message(conversation, charge, deposit)
base_url = InstallationConfig.find_by(name: 'FRONTEND_URL')&.value.presence ||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
base_url = base_url.gsub('0.0.0.0', '127.0.0.1') if base_url.include?('0.0.0.0')
token = charge.to_sgid(expires_in: 2.hours, purpose: :pix_payment).to_s
link = Rails.application.routes.url_helpers.short_payment_link_url(token, host: base_url)
body = "💸 *Pix do sinal — R$ #{format('%.2f', deposit)}*\n\n" \
"Abra o link abaixo pra ver o QR Code e copiar o código Pix:\n#{link}\n\n" \
'Sua reserva fica confirmada automaticamente assim que o pagamento cair (alguns segundos).'
Messages::MessageBuilder.new(
nil,
conversation,
content: body,
message_type: 'outgoing'
).perform
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::GeneratePixTool] failed to dispatch link: #{e.class} - #{e.message}")
end
def mark_awaiting_payment(conversation)
current = conversation.label_list
merged = (current + ['aguardando_pagamento']).uniq - %w[pagamento_confirmado reserva_feita]
conversation.update_labels(merged)
end
# Fallback "leve" pra cenários onde Pix nem foi tentado (categoria não
# existe na unit, período inválido, sem credencial Inter cadastrada).
# Sem reservation/pricing/valores — só monta link com o que tem do
# contact + args. UX é igual ao fallback de Inter falhar: cliente recebe
# link pra página oficial e conversa fica marcada pra triagem.
def dispatch_no_pricing_fallback!(conversation, unit, args, reason_code)
base = ENV.fetch('RESERVA_1001_BASE_URL',
InstallationConfig.find_by(name: 'RESERVA_1001_BASE_URL')&.value.presence ||
'https://reservas.hoteis1001noites.com.br')
contact = conversation.contact
custom = contact&.custom_attributes.to_h.with_indifferent_access
params = {
marca: unit&.brand&.name,
unidade: unit&.name,
permanencia: humanize_period(args['period'].to_s),
categoria: humanize_category(args['suite_category'].to_s),
checkin: parse_check_in(args['check_in_date'], conversation.account)&.iso8601,
nome: contact&.name,
telefone: contact&.phone_number,
cpf: custom[:cpf],
email: contact&.email
}.compact.reject { |_, v| v.to_s.strip.empty? }
url = "#{base.chomp('/')}/?#{URI.encode_www_form(params)}"
body = 'Pra evitar qualquer atrito no fechamento, é só finalizar pela página oficial ' \
"(seus dados já estão pré-preenchidos):\n#{url}"
Messages::MessageBuilder.new(nil, conversation, content: body, message_type: 'outgoing').perform
current = conversation.label_list
conversation.update_labels((current + %w[aguardando_pagamento pix_falhou_fallback]).uniq)
text_response(
"Pix indisponível (motivo=#{reason_code}). Mandei link da página de reserva pro cliente. " \
'Marquei conversa com pix_falhou_fallback pra gerência ver. NÃO repita o link nem fale sobre o problema técnico — ' \
'só confirme com o cliente que o link foi enviado.'
)
end
# Quando a Inter API falha (auth, certificado, timeout, etc), em vez de
# devolver erro, mandamos o cliente pra página oficial de reserva
# (reserva-1001) com query string preenchida. Cliente conclui por lá.
# Marca a conversa com `pix_falhou_fallback` pra triagem da gerência.
def dispatch_fallback_link!(conversation, unit, reservation, pricing, total_amount, deposit)
base = ENV.fetch('RESERVA_1001_BASE_URL',
InstallationConfig.find_by(name: 'RESERVA_1001_BASE_URL')&.value.presence ||
'https://reservas.hoteis1001noites.com.br')
contact = conversation.contact
custom = contact&.custom_attributes.to_h.with_indifferent_access
params = {
marca: unit.brand&.name,
unidade: unit.name,
permanencia: humanize_period(pricing[:breakdown][:period]),
categoria: humanize_category(pricing[:breakdown][:suite_category]),
checkin: reservation.check_in_at&.iso8601,
nome: contact&.name,
telefone: contact&.phone_number,
cpf: custom[:cpf],
email: contact&.email
}.compact.reject { |_, v| v.to_s.strip.empty? }
url = "#{base.chomp('/')}/?#{URI.encode_www_form(params)}"
body = 'Tive um problema técnico pra gerar o Pix por aqui — mas tudo certo, é só finalizar pela página oficial ' \
"(seus dados já estão pré-preenchidos):\n#{url}"
Messages::MessageBuilder.new(nil, conversation, content: body, message_type: 'outgoing').perform
current = conversation.label_list
conversation.update_labels((current + %w[aguardando_pagamento pix_falhou_fallback]).uniq)
text_response(
"Inter API indisponível. Link da página de reserva enviado pro cliente (R$ #{format('%.2f',
total_amount)} total / R$ #{format('%.2f',
deposit)} sinal). Marquei conversa com pix_falhou_fallback."
)
end
PERIOD_LABELS = {
'3h' => '3hrs',
'pernoite_promo' => 'Pernoite',
'pernoite_integral' => 'Pernoite',
'diaria' => 'Diaria'
}.freeze
def humanize_period(period_key)
PERIOD_LABELS[period_key] || period_key.to_s.humanize
end
CATEGORY_LABELS = {
'apartamento' => 'Apartamento',
'suite_master' => 'Suite Master',
'suite_luxo' => 'Suite Luxo',
'suite_tematica' => 'Suite Tematica',
'mini_chale_45' => 'Mini Chale 45',
'chale_2_suites' => 'Chale 2 Suites',
'suite_ouro' => 'Suite Ouro',
'chale_master_4_suites' => 'Chale Master 4 Suites'
}.freeze
def humanize_category(cat_key)
CATEGORY_LABELS[cat_key] || cat_key.to_s.tr('_', ' ').split.map(&:capitalize).join(' ')
end
end
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength

View File

@ -0,0 +1,56 @@
# Tool MCP: retorna FAQs aprovadas de um assistente existente.
#
# Caso de uso: Construtor pergunta "copiar FAQs de outro assistente?".
# Tool retorna lista markdown de Q&A pra usuário avaliar e (próxima
# tool) salvar no spec do novo agente.
class Captain::Mcp::Tools::GetAssistantFaqsTool < Captain::Mcp::Tools::BaseTool
MAX_FAQS = 50
class << self
def name
'get_assistant_faqs'
end
def description
'Retorna FAQs (perguntas/respostas) aprovadas de um assistente existente. ' \
'Use quando o usuário decidir copiar FAQs de outro assistente durante ' \
'criação. Retorna até 50 FAQs em markdown.'
end
def input_schema
{
type: 'object',
properties: {
assistant_id: {
type: 'integer',
description: 'ID do assistente fonte (pegue via list_assistants).'
}
},
required: ['assistant_id']
}
end
end
def call(args, context:) # rubocop:disable Metrics/AbcSize, Lint/UnusedMethodArgument
assistant = Captain::Assistant.find_by(id: args['assistant_id'])
return error_response("Assistente #{args['assistant_id']} não encontrado.") if assistant.blank?
faqs = assistant.responses
.where(status: 'approved')
.order(:id)
.limit(MAX_FAQS)
return text_response("_(#{assistant.name} não tem FAQs aprovados)_") if faqs.empty?
lines = ["# FAQs de #{assistant.name} (#{faqs.size})", '']
faqs.each_with_index do |f, i|
lines << "**#{i + 1}. #{f.question}**"
lines << f.answer.to_s.gsub(/\s+/, ' ').strip
lines << ''
end
text_response(lines.join("\n"))
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GetAssistantFaqsTool] error: #{e.class}: #{e.message}")
error_response("Erro ao buscar FAQs: #{e.message}")
end
end

View File

@ -0,0 +1,120 @@
# Tool MCP: retorna tabela de preços de um assistente existente.
#
# Caso de uso: Construtor copia tabela durante criação de novo agente
# (mesma marca → mesma tabela).
#
# Estratégia de leitura (em ordem de tentativa):
# 1. Se assistant tem unit vinculada e Captain::Mcp::PricingTables
# conhece essa unit → retorna tabela estruturada (Hermes-friendly)
# 2. Senão tenta extrair markdown de scenarios do assistant (caminho
# legado — Captain interno usa scenarios pra guardar tabela)
# 3. Senão retorna mensagem de "não encontrado"
class Captain::Mcp::Tools::GetAssistantPricingTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'get_assistant_pricing'
end
def description
'Retorna a tabela de preços de um assistente existente em markdown. ' \
'Use quando o usuário (na criação de novo agente) decidir copiar ' \
'a tabela de outro assistente. Retorna estrutura categórias × períodos ' \
'com regras de pessoa extra.'
end
def input_schema
{
type: 'object',
properties: {
assistant_id: {
type: 'integer',
description: 'ID do assistente fonte. Pegue via list_assistants.'
}
},
required: ['assistant_id']
}
end
end
def call(args, context:) # rubocop:disable Lint/UnusedMethodArgument
assistant = Captain::Assistant.find_by(id: args['assistant_id'])
return error_response("Assistente #{args['assistant_id']} não encontrado.") if assistant.blank?
text_response(extract_pricing_markdown(assistant))
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GetAssistantPricingTool] error: #{e.class}: #{e.message}")
error_response("Erro ao buscar tabela de preços: #{e.message}")
end
private
def extract_pricing_markdown(assistant)
structured = structured_pricing_for(assistant)
return structured if structured.present?
scenario = pricing_scenario_for(assistant)
return scenario if scenario.present?
"_(assistente #{assistant.name} não tem tabela de preços estruturada nem em scenario)_"
end
# Lookup em Captain::PricingCategory + Captain::PricingAmount (DB).
def structured_pricing_for(assistant)
unit = unit_for(assistant)
return nil if unit.blank?
return nil if unit.pricing_categories.empty?
format_structured_table(unit)
end
def unit_for(assistant)
return assistant.captain_unit if assistant.captain_unit_id.present?
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 << "**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
# usuário copiar ou Construtor parsear.
def pricing_scenario_for(assistant)
candidate = assistant.scenarios.where('LOWER(title) ~ ?', '(preç|tabela|reserva|valor)').first ||
assistant.scenarios.first
return nil if candidate.blank?
"# Scenario fonte — #{candidate.title}\n\n#{candidate.instruction}"
end
end

View File

@ -0,0 +1,69 @@
# Tool MCP: retorna o texto completo de um cenário (instruction) de um
# assistente existente.
#
# Caso de uso: Construtor pergunta "copiar identidade/maps/wifi/telefone
# da Lara?". Tool retorna o markdown bruto do scenario solicitado pra o
# Construtor (LLM) extrair os campos relevantes.
#
# Diferente de get_assistant_pricing (que parseia preços) e
# get_assistant_faqs (que lista responses): essa retorna o RAW PROMPT
# pra LLM interpretar livremente.
class Captain::Mcp::Tools::GetAssistantScenarioTool < Captain::Mcp::Tools::BaseTool
DEFAULT_SCENARIO_TITLE = 'Daniela_Reservas'.freeze
MAX_CHARS = 20_000
class << self
def name
'get_assistant_scenario'
end
def description
'Retorna o texto MARKDOWN completo de um cenário (prompt) de um ' \
'assistente existente. Use pra copiar identidade da unidade ' \
'(endereço, telefone, WhatsApp, maps, wifi, etc), persona ou ' \
'qualquer outro detalhe que esteja no prompt do agente fonte. ' \
'Default scenario_title="Daniela_Reservas".'
end
def input_schema
{
type: 'object',
properties: {
assistant_id: {
type: 'integer',
description: 'ID do assistente fonte.'
},
scenario_title: {
type: 'string',
description: "Título do cenário (default: '#{DEFAULT_SCENARIO_TITLE}'). Use list_assistants pra ver opções.",
default: DEFAULT_SCENARIO_TITLE
}
},
required: ['assistant_id']
}
end
end
def call(args, context:) # rubocop:disable Lint/UnusedMethodArgument, Metrics/AbcSize
assistant = Captain::Assistant.find_by(id: args['assistant_id'])
return error_response("Assistente #{args['assistant_id']} não encontrado.") if assistant.blank?
title = args['scenario_title'].to_s.presence || DEFAULT_SCENARIO_TITLE
scenario = assistant.scenarios.find_by(title: title)
if scenario.blank?
avail = assistant.scenarios.pluck(:title)
return error_response(
"Cenário '#{title}' não existe em #{assistant.name}. Disponíveis: #{avail.join(', ')}."
)
end
text = scenario.instruction.to_s
text = "#{text.first(MAX_CHARS)}\n\n... [truncado em #{MAX_CHARS} chars] ..." if text.size > MAX_CHARS
text_response("# Cenário '#{title}' de #{assistant.name}\n\n#{text}")
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GetAssistantScenarioTool] error: #{e.class}: #{e.message}")
error_response("Erro ao buscar cenário: #{e.message}")
end
end

View File

@ -0,0 +1,142 @@
# Tool MCP: retorna histórico estruturado do cliente em markdown.
#
# **Determinístico:** o conteúdo é montado on-the-fly do DB do Captain.
# LLM nunca escreve nem altera. Captain é source of truth único — Reservation,
# Conversation, Message, PixCharge etc — esta tool só serializa em markdown
# numa forma amigável pro LLM ler.
#
# Quando usar (do ponto de vista da Valentina):
# - Cliente pergunta sobre passado livre ("o que falamos sobre alergia?")
# - Cliente pede recap ("me lembra o que tava combinado?")
# - Cliente pergunta sobre reserva antiga não-recente (recente já vem no [ctx])
# - Suspeita de cliente VIP / fidelizado pra calibrar tom
#
# Quando NÃO usar:
# - Pergunta cobertas pelo [ctx] (last_res_*, total_reservas) — responda direto
# - Toda mensagem (custo de latência desnecessário)
class Captain::Mcp::Tools::GetContactHistoryTool < Captain::Mcp::Tools::BaseTool
MAX_RESERVATIONS = 8
MAX_CONVERSATIONS = 5
MAX_MESSAGE_SAMPLES_PER_CONV = 6
class << self
def name
'get_contact_history'
end
def description
'Retorna histórico completo do cliente em markdown (reservas, conversas anteriores, ' \
'labels, mensagens-chave). Use quando o cliente perguntar sobre algo do passado que ' \
'não está no [ctx] (ex: "qual era a reserva de 3 meses atrás", "o que falamos sobre X"). ' \
'NÃO use pra perguntas cobertas pelo [ctx] (last_res_date, total_reservas etc).'
end
def input_schema
{
type: 'object',
properties: {
contact_id: {
type: 'integer',
description: 'ID do contato (campo `contact` do [ctx]). Obrigatório.'
},
query: {
type: 'string',
description: 'Opcional. Termo pra filtrar mensagens por conteúdo (ex: "alergia", "desconto"). Se vazio, retorna histórico geral.'
}
},
required: ['contact_id']
}
end
end
def call(args, context:)
contact_id = args['contact_id'].presence || context[:contact_id]
return error_response('contact_id obrigatório.') if contact_id.blank?
contact = Contact.find_by(id: contact_id)
return error_response("Contato #{contact_id} não encontrado.") if contact.blank?
md = build_markdown(contact, args['query'].to_s.strip)
text_response(md)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GetContactHistoryTool] error: #{e.class}: #{e.message}")
error_response("Erro ao buscar histórico: #{e.message}")
end
private
def build_markdown(contact, query)
sections = []
sections << header_section(contact)
sections << reservations_section(contact)
sections << conversations_section(contact, query)
sections.compact.join("\n\n")
end
def header_section(contact)
custom = contact.custom_attributes.to_h.with_indifferent_access
cpf = custom[:cpf].to_s
cpf_fmt = cpf.length == 11 ? cpf.gsub(/(\d{3})(\d{3})(\d{3})(\d{2})/, '\1.\2.\3-\4') : cpf
[
"# Cliente: #{contact.name} (contact #{contact.id})",
([
cpf.present? ? "**CPF:** #{cpf_fmt}" : nil,
contact.email.present? ? "**Email:** #{contact.email}" : nil,
contact.phone_number.present? ? "**Telefone:** #{contact.phone_number}" : nil
].compact.join(' · ')).presence,
("**Notas:** #{custom[:notes]}" if custom[:notes].present?)
].compact.join("\n")
end
def reservations_section(contact) # rubocop:disable Metrics/AbcSize
reservations = Captain::Reservation
.where(contact_id: contact.id)
.order(check_in_at: :desc)
.limit(MAX_RESERVATIONS)
return '## Reservas\n_(sem reservas registradas)_' if reservations.empty?
lines = ['## Reservas']
reservations.each do |r|
checkin = r.check_in_at&.strftime('%d/%m/%Y às %Hh%M') || '-'
created = r.created_at.strftime('%d/%m/%Y')
total = r.total_amount.to_f
deposit = r.metadata.to_h['deposit_amount'].to_f
paid = Captain::PixCharge.exists?(reservation_id: r.id, status: 'paid')
lines << "### Reserva ##{r.id} — check-in #{checkin}"
lines << "Suíte: #{r.suite_identifier || '-'} · Status: **#{r.status}** · " \
"Total: R$ #{format('%.2f', total)} · Sinal: R$ #{format('%.2f', deposit)} " \
"(#{paid ? 'pago' : 'não pago'}) · Criada em #{created}"
end
lines.join("\n")
end
def conversations_section(contact, query)
convs = contact.conversations.order(last_activity_at: :desc).limit(MAX_CONVERSATIONS)
return '## Conversas anteriores\n_(sem conversas registradas)_' if convs.empty?
lines = ['## Conversas recentes']
convs.each do |c|
label_str = c.label_list.any? ? " · labels: #{c.label_list.join(', ')}" : ''
activity = c.last_activity_at&.strftime('%d/%m/%Y %H:%M') || '-'
lines << "### Conversa ##{c.display_id} (#{c.status}) — #{activity}#{label_str}"
msg_lines = sample_messages(c, query)
lines.concat(msg_lines) if msg_lines.any?
end
lines.join("\n")
end
def sample_messages(conversation, query)
scope = conversation.messages
.where(message_type: %i[incoming outgoing], private: false)
.where('content ~* ?', '\\S')
scope = scope.where('content ILIKE ?', "%#{query}%") if query.present?
scope = scope.reorder(created_at: :asc).limit(MAX_MESSAGE_SAMPLES_PER_CONV)
scope.map do |m|
who = m.message_type == 'incoming' ? 'Cliente' : 'Atendente'
preview = m.content.to_s.gsub(/\s+/, ' ').strip[0, 200]
"- **#{who}:** #{preview}"
end
end
end

View File

@ -0,0 +1,82 @@
# Tool MCP: lista assistentes existentes pro agente Construtor consultar.
#
# Caso de uso: durante criação de novo agente Hermes via skill socrática,
# o Construtor pergunta "quer copiar tabela de preços de outro assistente?".
# Esta tool retorna todos os assistentes cadastrados com metadata útil pra
# escolha (nome, marca, engine, scenarios count, FAQs count).
#
# Read-only. Scope: account_id obrigatório (vem do header X-Captain-Account-Id
# ou body context).
class Captain::Mcp::Tools::ListAssistantsTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'list_assistants'
end
def description
'Lista todos os assistentes existentes da conta. Use durante criação ' \
'de novo agente pra oferecer "copiar tabela/regras/FAQs de outro ' \
'assistente". Retorna nome, id, marca/unidade, engine (interno ou hermes), ' \
'qtd de scenarios e FAQs pra cada um.'
end
def input_schema
{
type: 'object',
properties: {
account_id: {
type: 'integer',
description: 'ID da conta. Default: account_id do contexto MCP.'
}
}
}
end
end
def call(args, context:)
account_id = args['account_id'].presence || context[:account_id]
return error_response('account_id obrigatório.') if account_id.blank?
account = Account.find_by(id: account_id)
return error_response("Account #{account_id} não encontrada.") if account.blank?
rows = account.captain_assistants.order(:id).map { |a| describe(a) }
text_response(format_markdown(rows))
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::ListAssistantsTool] error: #{e.class}: #{e.message}")
error_response("Erro ao listar assistentes: #{e.message}")
end
private
def describe(assistant)
inboxes = CaptainInbox.where(captain_assistant_id: assistant.id).filter_map(&:inbox)
units = inboxes.filter_map { |i| Captain::Unit.find_by(inbox_id: i.id) }
{
id: assistant.id,
name: assistant.name,
engine: assistant.config.to_h['engine_type'].presence || 'internal',
scenarios: assistant.scenarios.count,
faqs: assistant.responses.count,
inboxes: inboxes.map(&:name),
units: units.map(&:name),
brand: units.first&.brand&.name
}
end
def format_markdown(rows)
return '_(nenhum assistente cadastrado nesta conta)_' if rows.empty?
lines = ['# Assistentes da conta', '']
rows.each do |r|
engine_badge = r[:engine] == 'hermes' ? '⚡ Hermes' : '🧠 Captain interno'
lines << "## ##{r[:id]}#{r[:name]} · #{engine_badge}"
lines << "- Marca: #{r[:brand].presence || '_não vinculada_'}"
lines << "- Inbox(es): #{r[:inboxes].join(', ').presence || '_nenhuma_'}"
lines << "- Unidade(s): #{r[:units].join(', ').presence || '_nenhuma_'}"
lines << "- Scenarios: #{r[:scenarios]} · FAQs: #{r[:faqs]}"
lines << ''
end
lines.join("\n")
end
end

View File

@ -0,0 +1,98 @@
# Tool MCP: reage com emoji em uma mensagem do cliente.
#
# Caso de uso: gestos rápidos sem texto (cliente mandou foto bonita,
# áudio agradecendo, confirmação curta, etc). É bastidor — não substitui
# resposta textual; complementa ou indica leitura.
#
# Implementação: cria Message outgoing com `content_attributes.is_reaction=true`
# e `in_reply_to_external_id=<source_id da msg alvo>`. O pipeline wuzapi
# (Whatsapp::Providers::WuzapiService#send_reaction_message) detecta esses
# atributos e dispara via API do wuzapi como react nativo do WhatsApp.
class Captain::Mcp::Tools::ReactToMessageTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'react_to_message'
end
def description
'Reage com emoji em uma mensagem do cliente (ex: 👍 ❤️ 😍 🙏 😂 😮 😢). ' \
'Use pra gestos curtos: cliente mandou foto bonita → 😍, agradeceu → 🙏, ' \
'confirmou algo → 👍. NÃO substitui resposta — é complementar. Sem texto extra.'
end
def input_schema
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
emoji: {
type: 'string',
description: 'Emoji único a reagir (ex: 👍, ❤️, 😍, 🙏, 😂, 😮, 😢).'
},
message_id: {
type: 'integer',
description: 'Opcional. ID interno da mensagem do cliente. Se vazio, reage à última mensagem incoming da conversa.'
}
},
required: %w[conversation_id emoji]
}
end
end
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
emoji = args['emoji'].to_s.strip
return error_response('Argumento "emoji" é obrigatório.') if emoji.blank?
target = resolve_target_message(conversation, args['message_id'])
return error_response('Não achei mensagem do cliente pra reagir.') if target.blank?
if target.source_id.blank?
return error_response("Mensagem alvo (id=#{target.id}) sem source_id — wuzapi não consegue identificar a msg no WhatsApp.")
end
assistant = conversation.inbox.captain_assistant
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
sender: assistant,
content: emoji,
content_attributes: {
is_reaction: true,
in_reply_to_external_id: target.source_id,
external_source: 'hermes_react_tool'
}
)
text_response("Reação #{emoji} enviada na mensagem ##{target.id}.")
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::ReactToMessageTool] error: #{e.class}: #{e.message}")
error_response("Erro ao reagir: #{e.message}")
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def resolve_target_message(conversation, message_id)
if message_id.present?
conversation.messages.find_by(id: message_id)
else
conversation.messages.where(message_type: :incoming).order(created_at: :desc).first
end
end
end

View File

@ -0,0 +1,156 @@
# Tool MCP: remarca uma reserva existente.
#
# Caso de uso: cliente diz "vou precisar mudar pra outra data", "queria
# adiantar pra sex", "consegue empurrar pra 25?". Tool ajusta o
# check_in_at/check_out_at da Captain::Reservation mais recente da
# conversa, mantendo categoria e total_amount intactos.
#
# Política Dolce Amore: remarcação tem que ser feita com no mínimo 3h
# de antecedência em relação ao check-in atual. Tool valida.
#
# Idempotente em datas iguais: se a nova data == atual, não toca em nada.
#
# Não cobre: mudança de categoria/preço (use cancel + generate_pix novo)
# ou cancelamento (transferir pra humano via frase-âncora).
class Captain::Mcp::Tools::RescheduleReservationTool < Captain::Mcp::Tools::BaseTool
MIN_NOTICE_HOURS = 3
class << self
def name
'reschedule_reservation'
end
def description
'Remarca a reserva existente da conversa pra uma nova data. Mantém categoria e ' \
'valor. Política: precisa ser pedido com no mínimo 3h de antecedência em relação ' \
'ao check-in atual. Use quando cliente pedir mudança de data SEM mudar categoria. ' \
'Pra mudança de categoria, transfira pra humano (frase-âncora).'
end
def input_schema
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
new_check_in_date: {
type: 'string',
description: 'Nova data de check-in (YYYY-MM-DD ou DD/MM/YYYY). Hora padrão = mesma da reserva original.'
},
new_check_in_time: {
type: 'string',
description: 'Opcional. Nova hora de check-in (HH:MM, 24h). Default: mantém hora atual.'
}
},
required: %w[conversation_id new_check_in_date]
}
end
end
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
reservation = recent_reservation(conversation)
return error_response('Não há reserva ativa pra remarcar nessa conversa.') if reservation.blank?
new_check_in = build_new_check_in(args, reservation)
return error_response('Não consegui interpretar a data. Use YYYY-MM-DD ou DD/MM/YYYY.') if new_check_in.blank?
if new_check_in == reservation.check_in_at
formatted = new_check_in.strftime('%d/%m/%Y %Hh%M')
return text_response("Reserva ##{reservation.id} já está marcada pra #{formatted}. Nada a alterar.")
end
notice_hours = ((reservation.check_in_at - Time.current) / 1.hour).round
if notice_hours < MIN_NOTICE_HOURS
return error_response(
"Política do hotel: remarcação precisa ser pedida com no mínimo #{MIN_NOTICE_HOURS}h de antecedência. " \
"Faltam só #{notice_hours}h pro check-in atual — peça pro cliente confirmar com a gerência."
)
end
duration = reservation.check_out_at - reservation.check_in_at
reservation.update!(check_in_at: new_check_in, check_out_at: new_check_in + duration)
post_reschedule_note(conversation, reservation, new_check_in)
formatted = new_check_in.strftime('%d/%m/%Y às %Hh%M')
valor = format('%.2f', reservation.total_amount.to_f)
text_response(
"Reserva ##{reservation.id} remarcada pra #{formatted} " \
"(categoria #{reservation.suite_identifier}, valor R$ #{valor} mantido)."
)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::RescheduleReservationTool] error: #{e.class}: #{e.message}")
error_response("Erro ao remarcar: #{e.message}")
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
# Pega reserva mais recente da conversa que ainda não foi finalizada/cancelada.
def recent_reservation(conversation)
Captain::Reservation
.where(conversation_id: conversation.id)
.where.not(status: %w[cancelled done])
.order(check_in_at: :desc)
.first
end
def build_new_check_in(args, reservation) # rubocop:disable Metrics/AbcSize
date = parse_date(args['new_check_in_date'])
return nil if date.blank?
time = parse_time(args['new_check_in_time']) || [reservation.check_in_at.hour, reservation.check_in_at.min]
tz = reservation.account.respond_to?(:timezone) ? (reservation.account.timezone.presence || Time.zone.name) : Time.zone.name
Time.use_zone(tz) { Time.zone.local(date.year, date.month, date.day, time[0], time[1], 0) }
end
def parse_date(raw)
raw = raw.to_s.strip
return nil if raw.blank?
Date.iso8601(raw)
rescue ArgumentError
begin
Date.strptime(raw, '%d/%m/%Y')
rescue ArgumentError
nil
end
end
def parse_time(raw)
raw = raw.to_s.strip
return nil if raw.blank?
match = raw.match(/\A(\d{1,2}):(\d{2})\z/)
return nil unless match
[match[1].to_i, match[2].to_i]
end
def post_reschedule_note(conversation, reservation, new_check_in)
body = "🔄 Reserva ##{reservation.id} remarcada pra #{new_check_in.strftime('%d/%m/%Y às %Hh%M')}. Categoria e valor mantidos."
Messages::MessageBuilder.new(
nil,
conversation,
content: body,
message_type: 'outgoing',
private: true # nota interna pro time, cliente não vê
).perform
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::RescheduleReservationTool] failed to post note: #{e.class} - #{e.message}")
end
end

View File

@ -0,0 +1,396 @@
# Tool MCP: salva especificação de um novo agente Hermes EXPANDIDA pro
# formato que /usr/local/bin/hermes-provision consome direto.
#
# 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)
#
# 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
class << self
def name
'save_agent_spec'
end
def description
'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
{
type: 'object',
properties: {
slug: {
type: 'string',
description: 'Slug único pro agente (lowercase, snake_case). Será nome do profile e do arquivo.'
},
spec: {
type: 'object',
description: 'JSON da especificação — name, brand, unit_name, persona_source, pricing_source, identity, rules, faqs.'
}
},
required: %w[slug spec]
}
end
end
# 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 (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")
File.write(path, JSON.pretty_generate(expanded))
text_response(
"✅ 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("[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/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(' ')
serialized_categories = pricing_categories.map { |c| serialize_category(c) }
[build_expanded_hash(slug, name, parent, parent_id, parent_unit, brand, spec, serialized_categories), errors]
end
# rubocop:enable Metrics/AbcSize
# rubocop:disable Metrics/ParameterLists
def build_expanded_hash(slug, name, parent, parent_id, parent_unit, brand, spec, serialized_categories)
{
'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, serialized_categories),
'categories' => serialized_categories,
'saved_at' => Time.current.iso8601,
'saved_by_tool' => 'mcp_save_agent_spec'
}
end
# rubocop:enable Metrics/ParameterLists
def lookup_brand(parent, brand_name)
return nil if parent.nil?
# Sempre prefere a brand do parent_unit (fonte de verdade — Construtor copiou
# daquele agente). Spec.brand passado é só hint, pode estar errado/abreviado.
parent_brand = parent.captain_unit&.brand || parent.captain_inboxes.first&.captain_unit&.brand
return parent_brand if parent_brand
return nil if brand_name.blank?
candidates = Captain::Brand.where(account_id: parent.account_id)
candidates.find { |b| b.name.casecmp?(brand_name) } ||
candidates.find { |b| brand_matches?(b.name, brand_name) }
end
def parent_unit_for(parent)
return nil if parent.nil?
return parent.captain_unit if parent.captain_unit_id.present?
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'
unit_name = spec['unit_name'].to_s
<<~MD
# #{name} — #{default_pres}
Sou #{name}, #{default_pres}#{unit_name.present? ? " — unidade #{unit_name}" : ''}.
## Tom de voz
- Brasileira, calorosa, profissional. Fala como gente, sem formalidade exagerada.
- **Direta**. Cliente quer info, info . Cliente quer reservar, reservo. Sem enrolar.
- Bem-humorada na medida certa. Um emoji aqui e ali (😊), 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 (CRÍTICO)
Quando o cliente manda a PRIMEIRA msg da conversa (saudação tipo "Oi", "Bom dia", "Olá" SEM pedido específico), responda APENAS cumprimento + identificação + pergunta aberta. **NUNCA faça menu de produto** (categoria/permanência/preço) na primeira resposta espera o cliente dizer o que quer.
Formato exato:
- Com nome no contato: *"Oi, {primeiro_nome}! 😊 Sou #{name}, #{default_pres}. Como posso te ajudar?"*
- Sem nome válido (vazio, emoji, "Unknown"): *"Oi! 😊 Sou #{name}, #{default_pres}. Como posso te ajudar?"*
Bom dia / Boa tarde / Boa noite no lugar de "Oi" se o cliente abriu com isso.
**Exceção:** se cliente chegou na primeira msg perguntando algo concreto (ex: "qual o preço da hidro?"), cumprimente + responda direto. Não peça pra ele "contar mais".
## Quando transferir pra humano
Resposta única: **"⏳ Um momento — vou verificar."** + handoff. Nada além disso.
Casos:
- Hóspede no hotel reportando problema (ar, toalha, ruído, limpeza).
- Cancelamento de reserva feita.
- Pedido de desconto, cortesia, condição especial.
- Pergunta fora do meu escopo (reservas/preços/Pix) que não tenho certeza.
## Formatação WhatsApp (CRÍTICO)
WhatsApp tem markdown PRÓPRIO. NÃO use o markdown padrão.
- **Negrito:** UM asterisco `*texto*` NÃO dois.
- **Itálico:** UM underscore `_texto_`
- **Riscado:** UM til `~texto~`
Exemplos:
- `Hidromassagem pernoite: *R$ 250*`
- `Hidromassagem pernoite: **R$ 250**` (asteriscos vazariam literal pro cliente)
Use negrito pra valores e nomes de categoria. Em msg curta, sem negrito também ótimo.
## Memória
Lembro de cada cliente que conversou. Uso o conhecimento sem comentar "lembra de você".
## Contexto da conversa (linha [ctx])
Toda mensagem do cliente chega com `[ctx: cid=N aid=N contact=N name="..." reservas=N ultima_suite="..." last_res_*]` no topo. Use:
- **cid** = conversation_id (passar pra MCP tools que pedem `conversation_id`)
- **contact** = contact_id (memória do cliente)
- **name** = nome cadastrado (use se diferente de `Unknown`)
- **reservas / ultima_suite / last_res_*** = histórico desse cliente. Se reservas > 0, ele é recorrente trate familiarmente, não peça nome de novo.
## Tools MCP disponíveis (use proativamente)
- **`generate_pix(conversation_id, suite_category, period, total_guests, check_in_date)`** gera Pix do sinal de reserva. Use depois que tiver categoria + permanência + dia + horário coletados.
- **`react_to_message(conversation_id, emoji, message_id)`** reage com emoji à msg do cliente (gesto sutil).
- **`add_label(label)`** taga a conversa.
- **`send_suite_images(conversation_id, suite_category)`** manda foto da suíte se cliente pedir.
- **`faq_lookup(query)`** última opção, com query ESPECÍFICA. Prefira a tabela da skill.
Pra usar essas tools sempre passe o `conversation_id` correto (vem no `cid` do [ctx]).
## NUNCA cite tools, nem "vou consultar"
Pro cliente, é tudo #{name} respondendo. Tools são bastidor. Frases proibidas:
- "vou consultar o sistema"
- "deixa eu verificar"
- "tabela qui-dom" / "tabela seg-qua" (nomes internos)
- "como assistente virtual..." (a não ser que perguntem direto)
Se você TEM a info na skill, responda direto.
MD
end
# rubocop:enable Metrics/MethodLength
# rubocop:disable 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}**.
## 🚨 REGRA DE OURO — Info vs Reserva (LEIA ANTES DE QUALQUER RESPOSTA)
Classifique a intenção do cliente em **A** ou **B** ANTES de responder:
### A) CONSULTA DE INFO (cliente quer SABER, não reservar)
Sinais: "qual o preço?", "quanto custa?", "valores?", "tabela?", "preço da hidro?", "quanto fica a Master?".
**AÇÃO:** responda DIRETO com o(s) valor(es) da tabela. Sem questionário.
Se cliente disse genérico ("preços?"), manda resumo compacto cobrindo TODAS as categorias.
Se cliente disse específico ("hidro pernoite?"), manda esse valor.
**INFERA O DIA**: se cliente não falou data, assume HOJE. Se a tabela varia por dia da semana, usa o bucket correspondente a hoje. Se cliente quiser outro dia, ele dirá ("pra sexta", "quinta-feira", "amanhã"). NÃO pergunte "qual dia?" antes de mandar o preço.
**NO MÁXIMO 1 pergunta complementar** (categoria) e se for ESTRITAMENTE necessário pra dar o preço.
Termina com convite leve a reservar: *"Quer que eu já reserve?"*. SEM exigir data/horário/permanência ainda.
### B) INTENÇÃO DE RESERVA (cliente quer FECHAR)
Sinais: "quero reservar", "quero pegar", "vou querer", "bora", "topo", "pode reservar", "me reserva", ou dados concretos ("quero a master pra sexta às 22h").
**AÇÃO:** AGORA sim entra no fluxo de coleta pergunta categoria + data + horário + permanência (numa msg ).
**NUNCA confundir A com B.** Cliente perguntando preço cliente reservando. Não interrogue quem quer info.
## 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')}" : ''}
## 💳 Fluxo de Pix de Reserva (CRÍTICO)
Quando cliente confirma reserva ("pode reservar", "pode gerar", "bora", "topo", "sim"), você DEVE gerar Pix imediatamente. **NUNCA responda "Um momento" ou faça handoff nessa hora** handoff é pra problemas operacionais.
**Passos:**
1. Tendo categoria + permanência + data + horário (mínimo necessário), chame:
`generate_pix(conversation_id, suite_category, period, total_guests, check_in_date)`
- `conversation_id` = cid do [ctx]
- `suite_category` = nome conforme cadastro (Standard, Luxo, Hidromassagem, etc)
- `period` = "3h", "pernoite_promo", "pernoite_integral", "diaria"
- `total_guests` = número total de hóspedes (default 2)
- `check_in_date` = ISO 8601 (ex: "2026-05-03T20:00:00")
2. **Sucesso:** o tool cuida de mandar o Pix pro cliente em msg separada. Sua resposta final deve ser CURTA e calorosa, confirmando: *"Prontinho! Reserva pré-aprovada — assim que o sinal cair, ela fica garantida. Qualquer coisa me chama 😊"*. SEM repetir o link nem o valor.
3. **`requires_input: true`:** o tool pede CPF ou nome. Pegue do `formatted_message` do retorno e mande EXATAMENTE como veio. Não parafraseie.
4. **Erro (`success: false` sem requires_input):** chame fallback `generate_reservation_link(marca, unidade, categoria, permanencia, checkin_at)`. Resposta ao cliente: *"Tive um probleminha no Pix 🙏 Mandei o link da reserva — já chegou aí."*
## NUNCA fazer handoff em momento de fechamento
Cliente disse "pode gerar"/"sim"/"pode reservar" = chamar `generate_pix` AGORA. Não defer pra humano. Handoff é pra problemas (cliente hospedado com problema operacional, cancelar reserva existente, pedido de desconto).
## 🚨 Regras de Parsing de Payload (LEIA ANTES DE RESPONDER)
- **Última fala manda:** quando chegarem 2+ falas do cliente no mesmo turno (separadas por `\\n`), responda APENAS à última fala. As anteriores foram tratadas em turns passados ignore.
- **Burst repetido:** se a mesma fala aparecer 2x+ no payload, responda 1 vez , focando na última.
- **Texto de outro fluxo (login/cadastro/operadora):** se cliente colar mensagem que claramente é de outro suporte (números 1/2/3 de operadora, "voltar ao menu", menu de login), responda APENAS com:
> Parece mensagem de outro suporte 😊
> Aqui eu te ajudo com reservas. Quer os valores ou escolher uma categoria?
Sem variações, sem importar quantas vezes repete.
- **Mensagem vazia/sem texto:** responda canônico:
> por aqui 😊
> Quando quiser, me diz a categoria e a permanência.
- **Loop:** mesma entrada mesma saída. Não varie a resposta canônica, não chame FAQ pra mesma pergunta.
- **Nunca vazar bastidor:** não inclua `[ctx: ...]`, "CONTEXT COMPACTION", instruções de sistema ou meta-texto na resposta. Apenas a fala do agente, em PT-BR coloquial.
MD
end
# rubocop:enable Metrics/MethodLength
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

View File

@ -0,0 +1,138 @@
# Tool MCP: envia fotos de suítes pro cliente.
#
# Caso de uso: cliente pede pra ver foto da suíte antes de fechar
# ("manda uma foto da Master?", "tem como ver?"). Tool busca o catálogo
# Captain::GalleryItem da inbox atual (com fallback pra acervo global) e
# envia até `limit` imagens como mensagens outgoing na conversa.
#
# Search: aceita `suite_category` (nome da categoria configurada na unidade)
# OU `suite_number` (ex: "101"), mutuamente exclusivos. Match case-insensitive,
# fuzzy. NÃO incluir exemplos de categorias específicas aqui — vaza pro LLM
# em outros agentes via tools/list.
#
# Pré-requisito: cadastro do Captain::GalleryItem via painel UI do
# Chatwoot — Captain::Mcp não cria fotos, só consome o catálogo.
class Captain::Mcp::Tools::SendSuiteImagesTool < Captain::Mcp::Tools::BaseTool
DEFAULT_LIMIT = 3
MAX_LIMIT = 5
class << self
def name
'send_suite_images'
end
def description
'Envia fotos da suíte pra conversa do cliente. Use quando ele pedir foto/imagem ' \
'("manda uma foto", "tem como ver?"). Busca no catálogo da inbox atual (fallback ' \
'global). Passe `suite_category` (nome da categoria conforme cadastro da unidade) ' \
'OU `suite_number` (ex: "101") — não combine os dois.'
end
def input_schema # rubocop:disable Metrics/MethodLength
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
suite_category: {
type: 'string',
description: 'Nome/tipo da suíte conforme cadastro da unidade. Use quando o cliente pede pelo NOME da categoria.'
},
suite_number: {
type: 'string',
description: 'Número específico da suíte (ex: "101"). Use quando o cliente pede pelo NÚMERO. Mutuamente exclusivo com suite_category.'
},
limit: {
type: 'integer',
description: "Quantas fotos enviar (default #{DEFAULT_LIMIT}, máx #{MAX_LIMIT}).",
default: DEFAULT_LIMIT
}
},
required: ['conversation_id']
}
end
end
def call(args, context:) # rubocop:disable Metrics/AbcSize
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
suite_category = args['suite_category'].to_s.strip
suite_number = args['suite_number'].to_s.strip
return error_response('Passe suite_category OU suite_number — pelo menos um.') if suite_category.blank? && suite_number.blank?
items = find_items(conversation, suite_category, suite_number, args['limit'].to_i)
if items.blank?
label = suite_category.presence || suite_number
return text_response("Nenhuma foto cadastrada pra #{label}. Avise o cliente que pode pedir pro humano.")
end
sent = items.count { |item| send_image_message(conversation, item) }
label = suite_category.presence || "suíte #{suite_number}"
text_response("#{sent} foto(s) de #{label} enviadas na conversa #{conversation.display_id}.")
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::SendSuiteImagesTool] error: #{e.class}: #{e.message}")
error_response("Erro ao enviar fotos: #{e.message}")
end
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def find_items(conversation, suite_category, suite_number, limit)
base = Captain::GalleryItem
.active
.where(account_id: conversation.account_id)
.includes(image_attachment: :blob)
.ordered
filtered = if suite_number.present?
base.where('LOWER(suite_number) = ?', suite_number.downcase)
else
base.where(
'LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(suite_category, ' \
"'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e')) " \
'= LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(?, ' \
"'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e'))",
suite_category
)
end
inbox_scoped = filtered.where(scope: 'inbox', inbox_id: conversation.inbox_id)
pool = inbox_scoped.exists? ? inbox_scoped : filtered.where(scope: 'global')
pool.limit(normalize_limit(limit))
end
def normalize_limit(value)
n = value.to_i
return DEFAULT_LIMIT if n <= 0
[n, MAX_LIMIT].min
end
def send_image_message(conversation, item)
return false unless item.image.attached?
Messages::MessageBuilder.new(
nil,
conversation,
content: item.description.to_s.truncate(220),
message_type: 'outgoing',
attachments: [item.image.blob.signed_id]
).perform
true
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::SendSuiteImagesTool] failed sending item #{item.id}: #{e.class} - #{e.message}")
false
end
end

View File

@ -0,0 +1,127 @@
# Tool MCP: persiste dados do cliente no Contact do Captain (Chatwoot).
#
# Caso de uso: cliente forneceu nome/CPF/email/telefone na conversa.
# Valentina (ou qualquer agente) chama esta tool ASSIM QUE recebe os dados,
# antes mesmo de tentar gerar Pix. Garante que se o cliente abandonar a
# conversa antes de fechar, os dados ficam persistidos pra próxima
# conversa daquele Contact (visível pelo time humano e pelo Hermes via
# [ctx] na próxima vez).
#
# Validações:
# - name: mínimo 3 chars
# - cpf: exatamente 11 dígitos (formato livre — extrai dígitos)
# - email: regex básico
# - phone: aceita formato livre — não normaliza pra E.164 (Chatwoot já cuida disso ao salvar)
#
# Body wins: campo só é atualizado se passado E válido. Passar string vazia = ignora.
class Captain::Mcp::Tools::UpdateContactTool < Captain::Mcp::Tools::BaseTool
EMAIL_REGEX = /\A[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\z/
class << self
def name
'update_contact'
end
def description
'Salva dados do cliente no cadastro permanente (nome, CPF, email, telefone, ' \
'observações). Use assim que receber o dado — antes mesmo de gerar Pix. ' \
'Garante que próxima conversa do mesmo cliente já vem com [ctx: cpf_ok=true]. ' \
'Não confirme pro cliente que salvou — é bastidor.'
end
def input_schema # rubocop:disable Metrics/MethodLength
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
name: {
type: 'string',
description: 'Nome completo do cliente (mínimo 3 caracteres). Opcional.'
},
cpf: {
type: 'string',
description: 'CPF do cliente (qualquer formato com 11 dígitos). Opcional.'
},
email: {
type: 'string',
description: 'Email do cliente. Opcional.'
},
phone: {
type: 'string',
description: 'Telefone do cliente (com DDD e país preferencialmente). Opcional.'
},
notes: {
type: 'string',
description: 'Observação livre sobre o cliente (preferências, alergias, ' \
'particularidades). Vai pra custom_attributes.notes. Opcional.'
}
},
required: ['conversation_id']
}
end
end
def call(args, context:) # rubocop:disable Metrics/AbcSize
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
contact = conversation.contact
return error_response('Conversa sem contato vinculado.') if contact.blank?
updates = build_updates(args, contact)
return text_response('Nada novo pra salvar (campos vazios ou já idênticos).') if updates.empty?
contact.update!(updates)
text_response("Contato #{contact.id} atualizado: #{updates.keys.join(', ')}.")
rescue ActiveRecord::RecordInvalid => e
Rails.logger.warn("[Captain::Mcp::UpdateContactTool] validation: #{e.record.errors.full_messages.join(', ')}")
error_response("Validação falhou: #{e.record.errors.full_messages.join(', ')}.")
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::UpdateContactTool] error: #{e.class}: #{e.message}")
error_response("Erro ao atualizar contato: #{e.message}")
end
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def build_updates(args, contact) # rubocop:disable Metrics/AbcSize
updates = {}
name = args['name'].to_s.squish
updates[:name] = name if name.length >= 3 && name != contact.name.to_s.squish
email = args['email'].to_s.strip.downcase
updates[:email] = email if email.match?(EMAIL_REGEX) && email != contact.email.to_s.downcase
phone = args['phone'].to_s.strip
updates[:phone_number] = phone if phone.present? && phone.gsub(/\D/, '').length >= 10 && phone != contact.phone_number.to_s
custom_changes = build_custom_attribute_changes(args, contact)
updates[:custom_attributes] = contact.custom_attributes.to_h.merge(custom_changes) if custom_changes.any?
updates
end
def build_custom_attribute_changes(args, contact)
custom_changes = {}
current = contact.custom_attributes.to_h.with_indifferent_access
cpf_digits = args['cpf'].to_s.gsub(/\D/, '')
custom_changes['cpf'] = cpf_digits if cpf_digits.length == 11 && cpf_digits != current[:cpf].to_s
notes = args['notes'].to_s.strip
custom_changes['notes'] = notes if notes.present? && notes != current[:notes].to_s
custom_changes
end
end

View File

@ -20,6 +20,7 @@ class Captain::Payments::ConfirmationService
end
enqueue_roulette_offer! unless was_already_paid
notify_hermes_proactively! unless was_already_paid
Rails.logger.info "[PaymentConfirmation] Reserva #{@reservation.id} confirmada (#{source_label})"
end
@ -89,4 +90,14 @@ class Captain::Payments::ConfirmationService
rescue StandardError => e
Rails.logger.warn("[PaymentConfirmation] falha ao enfileirar roleta reserva=#{reservation.id}: #{e.class} - #{e.message}")
end
# Notifica o Hermes Agent (se a inbox estiver no fluxo Hermes) pra mandar
# mensagem espontânea pro cliente. Coexiste com o fluxo Captain interno —
# se a inbox NÃO estiver no Hermes, o job ignora silenciosamente. Side
# effect: nunca bloqueia a confirmação.
def notify_hermes_proactively!
Captain::Hermes::NotifyPaymentConfirmedJob.perform_later(reservation.id)
rescue StandardError => e
Rails.logger.warn("[PaymentConfirmation] falha ao notificar Hermes reserva=#{reservation.id}: #{e.class} - #{e.message}")
end
end

View File

@ -5,6 +5,7 @@ class Captain::Reservations::MarkerBuilder
'scheduled' => 'confirmed',
'active' => 'confirmed',
'completed' => 'confirmed',
'confirmed' => 'confirmed',
'cancelled' => 'cancelled'
}.freeze

View File

@ -30,6 +30,47 @@ module Enterprise::MessageTemplates::HookExecutionService
private
def schedule_captain_response
return schedule_hermes_response if Captain::Hermes.enabled_for?(conversation.inbox)
schedule_internal_response
end
def schedule_hermes_response
# Inbox roteada pro Hermes Agent (engine='hermes' no assistant ou env var legacy).
# Usa inbox.typing_delay como buffer/debounce: se outra msg chegar antes do delay
# vencer, cancela a anterior e reenfileira (a OutgoingJob agrupa msgs incoming
# desde a última resposta real do Hermes ao dispatch).
delay = conversation.inbox.typing_delay.to_i
cancel_pending_hermes_jobs!(conversation.id) if delay.positive?
if delay.positive?
Captain::Hermes::OutgoingJob.set(wait: delay.seconds).perform_later(conversation.id, message.id)
else
Captain::Hermes::OutgoingJob.perform_later(conversation.id, message.id)
end
end
def cancel_pending_hermes_jobs!(conv_id)
require 'sidekiq/api'
cancelled = 0
Sidekiq::ScheduledSet.new.each do |job|
args = begin
job.args.first
rescue StandardError
{}
end
next unless args.is_a?(Hash) && args['job_class'] == 'Captain::Hermes::OutgoingJob'
next unless args['arguments']&.first == conv_id
job.delete
cancelled += 1
end
Rails.logger.info("[Captain::Hermes::Debounce] cancelled #{cancelled} pending OutgoingJob for conv #{conv_id}") if cancelled.positive?
rescue StandardError => e
Rails.logger.warn("[Captain::Hermes::Debounce] failed to cancel pending: #{e.class} - #{e.message}")
end
def schedule_internal_response
job_args = [conversation, conversation.inbox.captain_assistant, message]
base_wait = conversation.inbox.typing_delay.to_i.seconds

View File

@ -0,0 +1,45 @@
# Encaminha mensagens da UI Hermes Builder pro gateway do Construtor (Hermes).
#
# Configuração:
# ENV['HERMES_BUILDER_WEBHOOK_URL'] — default: http://172.17.0.1:8646/webhooks/construtor-admin
# ENV['HERMES_BUILDER_WEBHOOK_SECRET'] — secret HMAC pra assinar payload
#
# Construtor responde async via plugin captain-http-callback que faz POST
# pra /webhooks/captain/builder_callback (HermesBuilderCallbackController).
module HermesBuilder::Dispatcher
DEFAULT_URL = 'http://172.17.0.1:8646/webhooks/construtor-admin'.freeze
TIMEOUT = 10
class DispatchError < StandardError; end
module_function
def send_to_construtor(session_id:, message:)
payload = { message: message, hermes_session_id: session_id }
body = payload.to_json
headers = signed_headers(body)
Rails.logger.info("[HermesBuilder::Dispatcher] sending session=#{session_id} (#{message.length} chars)")
response = HTTParty.post(webhook_url, body: body, headers: headers, timeout: TIMEOUT)
return response if response.success? || response.code == 202
raise DispatchError, "Construtor returned HTTP #{response.code}: #{response.body.to_s.truncate(200)}"
rescue HTTParty::Error, Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED => e
raise DispatchError, "Network error contacting Construtor (#{e.class}): #{e.message}"
end
def webhook_url
ENV.fetch('HERMES_BUILDER_WEBHOOK_URL', DEFAULT_URL)
end
def signed_headers(body)
headers = { 'Content-Type' => 'application/json; charset=utf-8' }
secret = ENV.fetch('HERMES_BUILDER_WEBHOOK_SECRET', nil)
if secret.present?
sig = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
headers['X-Hub-Signature-256'] = "sha256=#{sig}"
end
headers
end
end

View File

@ -0,0 +1,87 @@
# Reparos automatizados pra checks FAIL/WARN identificados pelo Validator.
#
# Cada `repair_id` presente em Validator::Result mapeia pra um handler aqui.
# Reparos cobrem só estado em DB (Captain::Assistant, CaptainInbox, Inbox,
# config). Reparos de filesystem/systemd ficam pro CLI hermes-provision na
# VPS — UI mostra mensagem orientadora pro admin.
class HermesBuilder::Repairer
REPAIR_HANDLERS = %w[
set_engine_hermes
sync_captain_inbox_unit
set_default_typing_delay
set_default_response_delay
].freeze
def self.repair(slug:, repair_id:)
asst = ::Captain::Assistant.find_by(hermes_profile_name: slug, engine: 'hermes')
return failure("Assistant '#{slug}' não encontrado") if asst.nil?
return failure("Reparo '#{repair_id}' não suportado pela UI. Rode hermes-provision na VPS.") unless REPAIR_HANDLERS.include?(repair_id)
send("repair_#{repair_id}", asst)
rescue StandardError => e
Rails.logger.error("[HermesBuilder::Repairer] error: #{e.class}: #{e.message}")
failure("Erro: #{e.class}: #{e.message}")
end
class << self
private
# rubocop:disable Rails/SkipsModelValidations
def repair_set_engine_hermes(asst)
asst.update_columns(engine: 'hermes')
success("Engine setado pra 'hermes' no assistant #{asst.id}")
end
# rubocop:enable Rails/SkipsModelValidations
# Resincroniza CaptainInbox.captain_unit_id com Assistant.captain_unit_id.
# Esse foi o bug raiz que travou a Juliana — CaptainInbox apontava pra
# unit antiga (Dolce Amore) enquanto Assistant.captain_unit_id era a
# nova (Qnn01). Resolve_unit usa CaptainInbox como segundo nível e
# vazava unit errada quando o context['assistant_id'] não chegava.
# rubocop:disable Rails/SkipsModelValidations
def repair_sync_captain_inbox_unit(asst)
return failure('Assistant sem captain_unit_id setado — corrija primeiro pelo cadastro') if asst.captain_unit_id.blank?
ci = ::CaptainInbox.where(captain_assistant_id: asst.id).first
return failure('Sem CaptainInbox pra esse assistant — vincule no painel de inboxes primeiro') if ci.nil?
old = ci.captain_unit_id
ci.update_columns(captain_unit_id: asst.captain_unit_id)
::Captain::Unit.where(inbox_id: ci.inbox_id).where.not(id: asst.captain_unit_id).update_all(inbox_id: nil)
::Captain::Unit.where(id: asst.captain_unit_id).update_all(inbox_id: ci.inbox_id)
success("CaptainInbox.unit_id ressincronizado: #{old}#{asst.captain_unit_id}")
end
# rubocop:enable Rails/SkipsModelValidations
def repair_set_default_typing_delay(asst)
ci = ::CaptainInbox.where(captain_assistant_id: asst.id).first
return failure('Sem inbox vinculada') if ci&.inbox.nil?
ci.inbox.update!(typing_delay: 5)
success("Inbox #{ci.inbox.id} typing_delay setado pra 5s")
end
def repair_set_default_response_delay(asst)
cfg = asst.config.to_h.merge(
'response_delay' => {
'mode' => 'typing_simulation',
'chars_per_second' => 25,
'min_seconds' => 1.5,
'max_seconds' => 6.0
}
)
asst.update!(config: cfg)
success('Humanização typing_simulation ativada (default: 25 cps, 1.5s..6s)')
end
def success(msg)
{ ok: true, message: msg }
end
def failure(msg)
{ ok: false, error: msg }
end
end
end

View File

@ -0,0 +1,43 @@
# Storage de mensagens da UI Hermes Builder.
#
# Persistência em Rails.cache (Redis em prod) — TTL 4h. Suficiente pra uma
# sessão de criação completa de agente (geralmente ~10-20min).
#
# Cada mensagem tem: role ('user' | 'construtor'), content (string),
# created_at (ISO8601). Lista cresce em ordem cronológica.
module HermesBuilder
end
module HermesBuilder::Storage
TTL = 4.hours
MAX_MESSAGES = 200
module_function
def messages_for(session_key)
Rails.cache.fetch(session_key) { [] }
end
def append(session_key, role:, content:)
msgs = messages_for(session_key)
msgs << { role: role, content: content.to_s, created_at: Time.current.iso8601 }
msgs = msgs.last(MAX_MESSAGES)
Rails.cache.write(session_key, msgs, expires_in: TTL)
msgs
end
def clear(session_key)
Rails.cache.delete(session_key)
end
# Última sessão ativa por account — usada pelo callback do Hermes pra
# roteamento (Hermes nao propaga chat_id no metadata da resposta).
# Aceitavel pra MVP com 1 admin por vez por conta.
def remember_last_session(account_id, session_key)
Rails.cache.write("hermes_builder:last_session:account:#{account_id}", session_key, expires_in: TTL)
end
def last_session_for(account_id)
Rails.cache.read("hermes_builder:last_session:account:#{account_id}")
end
end

View File

@ -0,0 +1,212 @@
# Validação de saúde de um agente Hermes — porta dos checks DB/runtime do
# script CLI `bin/hermes-validate` pro contexto Rails (consumido pela aba
# "Verificação" no Construtor UI).
#
# Cobre: configuração do Captain::Assistant, mapeamento CaptainInbox/Unit,
# pricing dry-run, credenciais Inter, registry MCP, humanização. NÃO cobre
# filesystem (SOUL.md/SKILL.md/config.yaml) nem systemd — esses ficam pro
# CLI (rodar no shell da VPS) porque o container Rails não tem visibilidade
# do `/root/.hermes/profiles/` do host.
#
# Cada check tem `repair_id` opcional — quando setado, indica que existe
# handler em HermesBuilder::Repairer pra ressincronizar/corrigir o estado
# (ex: sync_captain_inbox_unit, set_engine_hermes, set_default_typing).
class HermesBuilder::Validator
EXPECTED_MCP_TOOLS = %w[
generate_pix faq_lookup add_label send_suite_images react_to_message
update_contact get_contact_history check_pix_payment reschedule_reservation
].freeze
Result = Struct.new(:label, :status, :detail, :repair_id, :category, keyword_init: true) do
def to_h
{ label: label, status: status, detail: detail.to_s, repair_id: repair_id, category: category }
end
end
def self.run(slug)
new(slug).call
end
def initialize(slug)
@slug = slug.to_s.strip
@results = []
end
def call
asst = ::Captain::Assistant.find_by(hermes_profile_name: @slug, engine: 'hermes')
if asst.nil?
add('Captain::Assistant existe', 'FAIL', "Nenhum assistant com hermes_profile_name='#{@slug}'", category: 'db')
return summary
end
@asst = asst
@ci = ::CaptainInbox.where(captain_assistant_id: asst.id).first
@inbox = @ci&.inbox
@unit = asst.captain_unit
check_db
check_pricing
check_routing
check_humanization
check_mcp_tools
summary
end
private
def check_db
check_db_engine
check_db_endpoint
check_db_unit_consistency
end
def check_db_engine
add('engine=hermes', pf(@asst.engine == 'hermes'), @asst.engine,
repair_id: 'set_engine_hermes', category: 'db')
add('hermes_profile_name setado', pf(@asst.hermes_profile_name.present?),
@asst.hermes_profile_name, category: 'db')
add('parent_assistant_id setado', pw(@asst.parent_assistant_id.present?),
"parent=#{@asst.parent_assistant_id}", category: 'db')
end
def check_db_endpoint
add('hermes_port setado', pf(@asst.hermes_port.present?),
"port=#{@asst.hermes_port}", category: 'db')
add('hermes_subscription_secret setado', pf(@asst.hermes_subscription_secret.present?),
@asst.hermes_subscription_secret.present? ? 'presente' : 'vazio', category: 'db')
add('hermes_webhook_base_url', pf(@asst.hermes_webhook_base_url.to_s.start_with?('http')),
@asst.hermes_webhook_base_url, category: 'db')
end
def pf(bool) = bool ? 'PASS' : 'FAIL'
def pw(bool) = bool ? 'PASS' : 'WARN'
def check_db_unit_consistency
add('captain_unit_id setado', @asst.captain_unit_id.present? ? 'PASS' : 'FAIL',
@unit&.name, category: 'db')
ci_unit = @ci&.captain_unit_id
sync_ok = @asst.captain_unit_id.present? && ci_unit == @asst.captain_unit_id
add('CaptainInbox.unit == Assistant.unit', sync_ok ? 'PASS' : 'FAIL',
"asst=#{@asst.captain_unit_id} ci=#{ci_unit}",
repair_id: sync_ok ? nil : 'sync_captain_inbox_unit', category: 'db')
add('Brand resolvida', @unit&.brand.present? ? 'PASS' : 'FAIL', @unit&.brand&.name, category: 'db')
add('CaptainInbox mapeada', @inbox.present? ? 'PASS' : 'WARN', "inbox=#{@inbox&.id}", category: 'db')
end
def check_pricing
cats = (@unit&.pricing_categories&.includes(:amounts)&.to_a) || []
add('Pricing categorias > 0', cats.any? ? 'PASS' : 'FAIL',
"#{cats.size} cats: #{cats.map(&:key).join(',')}", category: 'pricing')
add('Pricing amounts > 0', cats.flat_map { |c| c.amounts.to_a }.size.positive? ? 'PASS' : 'FAIL',
"#{cats.flat_map { |c| c.amounts.to_a }.size} amounts", category: 'pricing')
check_pricing_dry_run(cats)
check_inter_creds
end
def check_pricing_dry_run(cats)
return if cats.empty?
first_cat = cats.first.key
res = ::Captain::Mcp::PricingTables.calculate(
unit_id: @unit.id, suite_category: first_cat,
period: 'pernoite_promo', total_guests: 2
)
if res[:error]
add('Pricing dry-run', 'FAIL', "ERR: #{res[:error]}", category: 'pricing')
else
add('Pricing dry-run', 'PASS', "OK R$ #{res[:amount]} (#{first_cat}/pernoite)", category: 'pricing')
end
end
def check_inter_creds
inter_ok = @unit.present? && @unit.respond_to?(:inter_credentials_present?) && @unit.inter_credentials_present?
add('Credenciais Inter completas', pw(inter_ok),
inter_ok ? 'cert+key+client_id presentes' : 'faltam — generate_pix cai no fallback',
category: 'pricing')
end
def check_routing
if @inbox.blank?
add('Captain::Hermes.enabled_for?', 'WARN', 'sem inbox mapeada', category: 'routing')
return
end
enabled = ::Captain::Hermes.enabled_for?(@inbox)
add('Captain::Hermes.enabled_for?', enabled ? 'PASS' : 'FAIL', enabled.to_s, category: 'routing')
url = ::Captain::Hermes.webhook_url_for(@inbox).to_s
expected_suffix = "/webhooks/captain-inbox-#{@slug}"
add('webhook_url aponta pra slug', url.end_with?(expected_suffix) ? 'PASS' : 'FAIL',
url, category: 'routing')
secret = ::Captain::Hermes.subscription_signing_secret(@inbox).to_s
add('subscription_signing_secret presente', secret.present? ? 'PASS' : 'FAIL',
secret.present? ? "#{secret.first(8)}..." : 'vazio', category: 'routing')
end
def check_humanization
typing_delay = @inbox.respond_to?(:typing_delay) ? @inbox.typing_delay.to_i : 0
add('Inbox.typing_delay > 0 (debounce)', typing_delay.positive? ? 'PASS' : 'WARN',
"#{typing_delay}s",
repair_id: typing_delay.positive? ? nil : 'set_default_typing_delay',
category: 'humanization')
mode = @asst.config.to_h.dig('response_delay', 'mode')
add('config.response_delay (typing simulation)', mode == 'typing_simulation' ? 'PASS' : 'WARN',
mode || 'none',
repair_id: mode == 'typing_simulation' ? nil : 'set_default_response_delay',
category: 'humanization')
gallery_count = @unit&.gallery_items&.count || 0
add('GalleryItem para fotos', gallery_count.positive? ? 'PASS' : 'WARN',
"#{gallery_count} items (send_suite_images precisa)", category: 'humanization')
end
def check_mcp_tools
registered = mcp_tool_names
EXPECTED_MCP_TOOLS.each do |t|
add("MCP tool '#{t}' registrado", registered.include?(t) ? 'PASS' : 'FAIL', nil, category: 'mcp')
end
check_own_faqs
end
# FAQs aprovadas vinculadas ao próprio assistant (não ao parent). Se zero,
# toda chamada faq_lookup vai cair no parent — vazou senha errada do
# Wi-Fi em 2026-05-02 porque parent.id=1 tinha FAQ "presencial" e a
# senha nova só estava cadastrada no próprio Hermes.id=10.
def check_own_faqs
count = ::Captain::AssistantResponse.where(assistant_id: @asst.id, status: :approved).count
add('FAQs próprias aprovadas > 0', count.positive? ? 'PASS' : 'WARN',
"#{count} FAQs (zero significa que faq_lookup busca dados do parent — risco de info desatualizada)",
category: 'mcp')
end
def mcp_tool_names
::Captain::Mcp::ToolRegistry::TOOLS.map(&:name)
rescue StandardError
[]
end
def add(label, status, detail = nil, repair_id: nil, category: nil)
@results << Result.new(label: label, status: status, detail: detail, repair_id: repair_id, category: category)
end
def summary
pass = @results.count { |r| r.status == 'PASS' }
fail_n = @results.count { |r| r.status == 'FAIL' }
warn = @results.count { |r| r.status == 'WARN' }
{
slug: @slug,
ok: fail_n.zero?,
total: @results.size,
pass: pass,
fail: fail_n,
warn: warn,
results: @results.map(&:to_h)
}
end
end

View File

@ -7,4 +7,5 @@ end
json.meta do
json.total_count @reservations_count
json.page @current_page
json.status_counts @status_counts || {}
end

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

View File

@ -0,0 +1,76 @@
# captain-http-callback
Hermes Agent platform plugin que entrega a resposta do agente como `POST` HTTP a uma URL configurável.
## Por que existe
O Hermes nativamente entrega respostas em plataformas conhecidas (Telegram, Slack, Discord, WhatsApp, etc). Quando integramos o Hermes como cérebro de outro backend (no nosso caso o **Captain / Chatwoot**), precisamos da resposta de volta via HTTP para o backend continuar o fluxo (mandar pro cliente WhatsApp, atualizar conversa, etc) — esse plugin fornece essa ponte.
## Fluxo
```
1. Captain → POST /webhooks/<rota> no Hermes (entrada da mensagem do cliente)
2. Hermes processa via LLM (Codex/Anthropic/Gemini conforme config dele)
3. Hermes invoca este plugin com a resposta gerada
4. Plugin → POST <url-configurada> com a resposta
5. Captain recebe, identifica conversa, manda pro WhatsApp
```
## Instalação
Plugin é discovered automaticamente quando colocado em `~/.hermes/plugins/captain-http-callback/`. Após copiar os arquivos, reinicie o gateway:
```bash
pkill -f "hermes.*gateway" && sleep 2
nohup hermes gateway run --replace > /var/log/hermes-gateway.log 2>&1 &
```
Verifique:
```bash
hermes plugins list | grep http_callback
```
## Uso
Cria webhook subscription apontando para este deliver type:
```bash
hermes webhook subscribe minha-rota \
--prompt "Cliente disse: {message}. Responda como ..." \
--deliver http_callback \
--deliver-chat-id "https://seu-backend.example/api/hermes_callback"
```
`--deliver-chat-id` é interpretado como a **URL de callback**. Quando o agente terminar de processar, este plugin POSTa nessa URL.
## Formato do POST de callback
```http
POST <url-configurada> HTTP/1.1
Content-Type: application/json; charset=utf-8
X-Hermes-Callback-Signature: sha256=<hex-hmac> (opcional, se signing_secret configurado)
{
"content": "<resposta gerada pelo agente>",
"reply_to": "<id da mensagem original ou null>",
"metadata": { ... },
"timestamp": <unix epoch>
}
```
## Assinatura HMAC opcional
Define a env var `CAPTAIN_HTTP_CALLBACK_SECRET` em `~/.hermes/.env` (ou no shell do Hermes). Quando set, o plugin assina cada POST com `HMAC-SHA256(secret, body)` no header `X-Hermes-Callback-Signature`. O backend valida a assinatura antes de processar.
```bash
# em ~/.hermes/.env
CAPTAIN_HTTP_CALLBACK_SECRET=<gere com: openssl rand -hex 32>
```
## Limitações
- **Send-only.** Não recebe mensagens. A entrada da conversa precisa vir via outro adapter (geralmente o `webhook` adapter built-in).
- **Resposta esperada:** o backend precisa aceitar `POST` JSON e responder `2xx`. Plugin loga warning em qualquer status `>= 300` e retorna falha pro Hermes.
- **Timeout default:** 15s. Configurável via `extra.timeout_seconds` no `config.yaml`.
- **Sem retry built-in.** Se o backend retornar erro, é falha — o Hermes pode escolher logar e seguir. Adicione retry no backend caller se precisar.

View File

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View File

@ -0,0 +1,216 @@
"""HTTP Callback Platform Adapter for Hermes Agent.
A plugin-based gateway adapter that delivers the agent response as an HTTP
POST to a configurable URL instead of pushing it to a chat platform.
Designed for backend integrations (e.g. Captain / Chatwoot Hermes) where
the response must be returned synchronously to a known service endpoint.
Usage:
hermes webhook subscribe my-route \\
--deliver http_callback \\
--deliver-chat-id "http://your-backend/api/hermes_callback"
The URL passed via ``--deliver-chat-id`` is what receives the POST when the
agent finishes processing the inbound webhook.
Environment variables:
CAPTAIN_HTTP_CALLBACK_SECRET Optional HMAC-SHA256 signing key. When set,
each POST includes header
``X-Hermes-Callback-Signature: sha256=<hex>``
computed over the raw request body.
"""
import asyncio
import hashlib
import hmac
import json
import logging
import os
import time
from typing import Any, AsyncIterator, Dict, Optional
import aiohttp
from gateway.config import Platform
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
logger = logging.getLogger(__name__)
class HttpCallbackAdapter(BasePlatformAdapter):
"""Send-only adapter that POSTs the agent response to an HTTP URL.
Inbound message reception is NOT supported this adapter is meant to
pair with the built-in ``webhook`` adapter (which receives requests and
invokes the agent). The flow is:
external service -> POST /webhooks/<route> (webhook adapter)
-> agent processes
-> http_callback adapter POSTs response back
"""
def __init__(self, config, **kwargs):
platform = Platform("http_callback")
super().__init__(config=config, platform=platform)
extra = getattr(config, "extra", {}) or {}
self.signing_secret: str = (
os.getenv("CAPTAIN_HTTP_CALLBACK_SECRET")
or extra.get("signing_secret", "")
)
self.timeout_seconds: float = float(extra.get("timeout_seconds", 15))
self._session: Optional[aiohttp.ClientSession] = None
# ------------------------------------------------------------------ lifecycle
async def connect(self) -> bool:
"""Open a reusable aiohttp session — no real network handshake."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=self.timeout_seconds),
)
logger.info(
"[http_callback] adapter ready (signing=%s, timeout=%.1fs)",
"yes" if self.signing_secret else "no",
self.timeout_seconds,
)
return True
async def disconnect(self) -> None:
if self._session is not None and not self._session.closed:
await self._session.close()
self._session = None
# ------------------------------------------------------------------ send
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""POST the agent response to ``chat_id`` (interpreted as a URL)."""
url = (chat_id or "").strip()
if not url.startswith(("http://", "https://")):
return SendResult(
success=False,
error=f"Invalid callback URL: {url!r} (must start with http:// or https://)",
)
body: Dict[str, Any] = {
"content": content,
"reply_to": reply_to,
"metadata": metadata or {},
"timestamp": int(time.time()),
}
body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json; charset=utf-8"}
if self.signing_secret:
sig = hmac.new(
self.signing_secret.encode("utf-8"),
body_bytes,
hashlib.sha256,
).hexdigest()
headers["X-Hermes-Callback-Signature"] = f"sha256={sig}"
if self._session is None or self._session.closed:
await self.connect()
try:
async with self._session.post(url, data=body_bytes, headers=headers) as resp:
status = resp.status
resp_text = await resp.text()
if 200 <= status < 300:
logger.info("[http_callback] POST %s -> HTTP %d", url, status)
return SendResult(
success=True,
message_id=f"hcb-{int(time.time() * 1000)}",
)
logger.warning(
"[http_callback] POST %s -> HTTP %d (body[:200]=%r)",
url,
status,
resp_text[:200],
)
return SendResult(
success=False,
error=f"HTTP {status}: {resp_text[:200]}",
)
except asyncio.TimeoutError:
return SendResult(
success=False,
error=f"Timeout after {self.timeout_seconds}s",
)
except aiohttp.ClientError as e:
logger.exception("[http_callback] aiohttp error for %s", url)
return SendResult(success=False, error=f"{type(e).__name__}: {e}")
except Exception as e: # pylint: disable=broad-except
logger.exception("[http_callback] unexpected error for %s", url)
return SendResult(success=False, error=f"{type(e).__name__}: {e}")
# ------------------------------------------------------------------ no-op
async def send_typing(self, chat_id: str, metadata=None) -> None: # noqa: D401
"""No-op — HTTP callback has no typing indicator."""
return None
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {
"chat_id": chat_id,
"platform": "http_callback",
"type": "http_callback",
}
async def receive_messages(self) -> AsyncIterator[MessageEvent]:
"""Adapter is send-only — never yields events."""
if False: # pragma: no cover - never executed; keeps async generator typing
yield # type: ignore[misc]
return
# ---------------------------------------------------------------------------
# Plugin registry hooks
# ---------------------------------------------------------------------------
def check_requirements() -> bool:
"""``aiohttp`` ships with Hermes — should always be importable."""
try:
import aiohttp # noqa: F401
return True
except ImportError:
return False
def validate_config(config) -> bool: # pragma: no cover - trivial
"""No global config required — URL comes per-subscription via chat_id."""
return True
def is_connected(config) -> bool: # pragma: no cover - trivial
"""Always considered ready (no persistent connection)."""
return True
def register(ctx) -> None:
"""Plugin entry point invoked by Hermes during plugin discovery."""
ctx.register_platform(
name="http_callback",
label="HTTP Callback",
adapter_factory=lambda cfg: HttpCallbackAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=[],
install_hint="aiohttp (bundled with Hermes — no extra install needed)",
emoji="🔗",
pii_safe=True,
allow_update_command=False,
platform_hint=(
"Você está respondendo via callback HTTP a um sistema backend. "
"Sua resposta será enviada por POST JSON a um endpoint configurado "
"pelo desenvolvedor — não vai para um chat humano direto. Mantenha "
"o formato natural como se fosse pro cliente final; o backend "
"encaminha sua resposta ao destinatário real."
),
)

View File

@ -0,0 +1,14 @@
name: captain-http-callback
kind: platform
version: 0.1.0
description: >
HTTP callback platform adapter for Hermes Agent.
POSTs the agent response back to a configurable URL (passed as
--deliver-chat-id when creating the webhook subscription).
Optional HMAC-SHA256 signing via env var CAPTAIN_HTTP_CALLBACK_SECRET.
Use case: integrate Hermes as the LLM/agent brain behind another
backend (e.g. Captain / Chatwoot) where the agent response must be
delivered synchronously to a known service endpoint, rather than to
a chat platform like Telegram or Slack.
author: Captain (fazer.ai)

View File

@ -0,0 +1,77 @@
# captain-webhook
Hermes Agent platform plugin que **substitui** o `WebhookAdapter` built-in pra suportar **session_chat_id estável** derivado de campo no payload.
## Por que existe
O webhook adapter built-in do Hermes monta a chave de sessão como:
```
session_chat_id = f"webhook:{route_name}:{delivery_id}"
```
`delivery_id` é único por POST → cada mensagem cria sessão nova no Hermes. Isso funciona pra webhooks one-shot (alertas, GitHub events), mas é **errado pra integração de chat** onde múltiplas mensagens da mesma conversa precisam compartilhar memória de sessão.
Esse plugin permite que o caller (ex: Captain) inclua um identificador estável no payload — `conversation_id` (preferido) ou `hermes_session_id` — e o adapter reescreve a chave pra:
```
session_chat_id = f"webhook:{route_name}:session:{conversation_id}"
```
Mesmo `conversation_id` em múltiplas POSTs → mesma sessão Hermes → memória da conversa preservada.
## Como funciona
`CaptainWebhookAdapter` herda de `WebhookAdapter` built-in e faz **uma única override**: o método `handle_message()`. Ele:
1. Recebe o `event` já montado pelo built-in
2. Lê `event.raw_message` (o payload JSON do webhook)
3. Se houver `hermes_session_id` ou `conversation_id`, monta novo `chat_id`
4. Mirror o `_delivery_info` pra nova chave (pra o `send()` posterior achar config)
5. Modifica `event.source.chat_id`
6. Chama `super().handle_message(event)`
Toda outra lógica (HMAC, rate limit, idempotency, parsing JSON, signature validation, deliver dispatch) é herdada **sem cópia**.
## Como o Hermes substitui o built-in
`gateway/run.py`:
```python
# Plugin-registered platforms (checked first)
if platform_registry.is_registered(platform.value):
adapter = platform_registry.create_adapter(platform.value, config)
if adapter is not None:
return adapter
# Fall through to built-in adapters below
```
Se este plugin se registrar com `name="webhook"` (mesmo nome do built-in), `is_registered("webhook")` retorna `True` e o `CaptainWebhookAdapter` é usado em vez do built-in `WebhookAdapter`.
## Instalação no profile do Hermes
```bash
# Copia plugin pro profile
cp -r hermes-plugins/captain-webhook /root/.hermes/profiles/<profile>/plugins/
# Ativa
HERMES_HOME=/root/.hermes/profiles/<profile> hermes plugins enable captain-webhook
# Reinicia gateway pra carregar
pkill -f "HERMES_HOME=/root/.hermes/profiles/<profile>"
HERMES_HOME=/root/.hermes/profiles/<profile> nohup hermes gateway run --replace > /var/log/hermes-<profile>.log 2>&1 &
```
Verifica:
```bash
HERMES_HOME=/root/.hermes/profiles/<profile> hermes plugins list | grep captain-webhook
```
## Backward compatibility
Quando o payload **NÃO traz** `hermes_session_id` nem `conversation_id`, o adapter **não modifica nada** — comportamento idêntico ao built-in. Webhooks one-shot continuam funcionando normalmente.
## Limitações
- O plugin estende a versão do `WebhookAdapter` instalada no Hermes. Quando o Hermes for atualizado, é prudente revisar se a interface base mudou (signature do `handle_message`, formato do `chat_id`, etc).
- Não modifica o ciclo de idempotency: cada POST ainda precisa de `delivery_id` único (auto-gerado pelo Hermes ou via header `X-Request-ID`).
- Não persiste sessions entre restarts do Hermes — isso é responsabilidade do session store do próprio Hermes (SQLite por profile).

View File

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View File

@ -0,0 +1,198 @@
"""Captain Webhook Adapter — extends Hermes built-in WebhookAdapter to
support stable session_chat_id derived from a payload field.
Why this exists
---------------
The default ``WebhookAdapter`` builds the session key as::
session_chat_id = f"webhook:{route_name}:{delivery_id}"
Since ``delivery_id`` is unique per HTTP POST, every webhook hit creates a
fresh Hermes session. That's correct behavior for one-shot webhook events
(GitHub PRs, monitoring alerts) but wrong for backend integrations where
many messages of the same logical conversation must share session memory.
This plugin lets the caller include a stable identifier in the JSON
payload typically ``conversation_id`` (preferred) or
``hermes_session_id`` and the adapter rewrites the session key to::
session_chat_id = f"webhook:{route_name}:session:{conversation_id}"
Same conversation_id across multiple POSTs same session in Hermes
session memory preserved. Different conversation_id (or none) fresh
session, identical to the built-in.
Idempotency (``delivery_id`` based) is unchanged: each POST still gets a
unique delivery_id, so retries are still de-duplicated.
Implementation
--------------
We register this plugin under the SAME platform name as the built-in
(``webhook``). Hermes' ``_create_adapter`` checks ``platform_registry``
first and uses the plugin if registered, falling back to the built-in
only if nothing is registered. So registering ``name="webhook"`` here
substitutes the built-in completely.
Behavior is inherited unchanged from ``WebhookAdapter`` (HMAC, rate
limiting, idempotency, signature validation, prompt rendering, deliver
dispatch). The only override is ``handle_message()``, which mutates
``event.source.chat_id`` before delegating to the parent.
"""
import logging
from typing import Optional
from gateway.platforms.base import MessageEvent
from gateway.platforms.webhook import WebhookAdapter
logger = logging.getLogger(__name__)
class CaptainWebhookAdapter(WebhookAdapter):
"""WebhookAdapter with stable session_chat_id derived from payload."""
SESSION_FIELD_PRIMARY = "hermes_session_id"
SESSION_FIELD_FALLBACK = "conversation_id"
async def connect(self) -> bool:
# Hermes' built-in webhook adapter is the only platform that gets
# `gateway_runner` set explicitly in `_create_adapter()` (see
# gateway/run.py around `Platform.WEBHOOK`). When a plugin replaces
# the built-in via `platform_registry`, that explicit assignment is
# skipped — leaving `self.gateway_runner` unset and breaking
# cross-platform delivery (e.g. forwarding the response to a sibling
# plugin like `http_callback`).
#
# Recover by pulling the live runner from the module-level
# `_gateway_runner_ref` weakref that GatewayRunner.__init__ populates.
if not getattr(self, "gateway_runner", None):
try:
from gateway.run import _gateway_runner_ref
runner = _gateway_runner_ref()
if runner is not None:
self.gateway_runner = runner
logger.info(
"[captain-webhook] gateway_runner linked via _gateway_runner_ref"
)
else:
logger.warning(
"[captain-webhook] _gateway_runner_ref() returned None — "
"cross-platform delivery (http_callback etc) will fail"
)
except Exception as exc: # pragma: no cover - defensive
logger.warning(
"[captain-webhook] Could not link gateway_runner: %s", exc
)
return await super().connect()
async def handle_message(self, event: MessageEvent) -> None:
custom_session = self._extract_custom_session_id(event)
if custom_session:
self._rewrite_chat_id(event, custom_session)
return await super().handle_message(event)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _extract_custom_session_id(self, event: MessageEvent) -> Optional[str]:
payload = event.raw_message
if not isinstance(payload, dict):
return None
value = payload.get(self.SESSION_FIELD_PRIMARY) or payload.get(
self.SESSION_FIELD_FALLBACK
)
if value is None:
return None
return str(value).strip() or None
def _rewrite_chat_id(self, event: MessageEvent, custom_session: str) -> None:
old_chat_id = event.source.chat_id
# Default chat_id format from base WebhookAdapter:
# "webhook:<route_name>:<delivery_id>"
# Extract route_name to keep the new key consistent with that scheme.
try:
prefix, route_name, _ = old_chat_id.split(":", 2)
except ValueError:
logger.warning(
"[captain-webhook] Unexpected chat_id format %r — skipping rewrite",
old_chat_id,
)
return
new_chat_id = f"{prefix}:{route_name}:session:{custom_session}"
if new_chat_id == old_chat_id:
return
# The base adapter stores delivery info under the original chat_id so
# that send() can locate the deliver target/payload when the agent
# finishes. Mirror the entry under the new chat_id so the rewrite
# doesn't break delivery routing.
delivery_config = self._delivery_info.get(old_chat_id)
if delivery_config is not None:
self._delivery_info[new_chat_id] = delivery_config
self._delivery_info_created[new_chat_id] = (
self._delivery_info_created.get(old_chat_id, 0)
)
# SessionSource and MessageEvent are non-frozen dataclasses — safe to mutate.
event.source.chat_id = new_chat_id
logger.info(
"[captain-webhook] Stable session: %s -> %s (custom=%s)",
old_chat_id,
new_chat_id,
custom_session,
)
# ---------------------------------------------------------------------------
# Plugin registry hooks
# ---------------------------------------------------------------------------
def check_requirements() -> bool:
"""Built-in WebhookAdapter must be importable (it always is in Hermes)."""
try:
from gateway.platforms.webhook import WebhookAdapter # noqa: F401
return True
except ImportError:
return False
def validate_config(config) -> bool: # pragma: no cover - trivial passthrough
"""All built-in webhook config is valid for this adapter as well."""
return True
def is_connected(config) -> bool: # pragma: no cover - trivial passthrough
"""Always considered ready when the platform block is enabled."""
extra = getattr(config, "extra", {}) or {}
return bool(extra.get("enabled", True))
def register(ctx) -> None:
"""Plugin entry point.
Registers under name="webhook" same as the built-in so Hermes'
``_create_adapter`` picks this adapter instead of the built-in
(registry is checked before the if/elif chain).
"""
ctx.register_platform(
name="webhook",
label="Captain Webhook (stable sessions)",
adapter_factory=lambda cfg: CaptainWebhookAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
is_connected=is_connected,
required_env=[],
install_hint="Inherits from built-in WebhookAdapter — no extra deps.",
emoji="🪝",
pii_safe=True,
allow_update_command=False,
platform_hint=(
"Webhook platform with stable session_chat_id when payload "
"carries 'conversation_id' or 'hermes_session_id'. Keeps the "
"same Hermes session across multiple HTTP POSTs of the same "
"logical conversation."
),
)

View File

@ -0,0 +1,28 @@
name: captain-webhook
kind: platform
version: 0.1.0
description: >
Drop-in replacement for the built-in `webhook` platform adapter that
supports stable session_chat_id via payload field.
The default Hermes WebhookAdapter creates a fresh session per POST
(session_chat_id = "webhook:<route>:<delivery_id>" — and delivery_id is
unique per request). For backend integrations like Captain ↔ Hermes
where multiple messages of the same conversation must share session
memory, the caller can include `conversation_id` (or `hermes_session_id`)
in the payload — this adapter constructs:
session_chat_id = "webhook:<route>:session:<conversation_id>"
keeping the Hermes session continuous across messages of the same
conversation. Idempotency (delivery_id) remains unchanged.
When the payload has neither `conversation_id` nor `hermes_session_id`,
behavior is identical to the built-in adapter (every msg is fresh
session). 100% backward-compatible.
Inheritance-only override: this plugin extends WebhookAdapter and only
overrides handle_message() to rewrite event.source.chat_id when the
payload carries a stable session id. All other behavior (HMAC, rate
limiting, idempotency, parsing, deliver) is inherited unchanged.
author: Captain (fazer.ai)

View File

@ -6,6 +6,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
let(:document) { create(:captain_document, assistant: assistant, account: account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:agent_with_custom_role) { create(:user, account: account, role: :agent) }
let(:another_assistant) { create(:captain_assistant, account: account) }
let(:another_document) { create(:captain_document, account: account, assistant: assistant) }
@ -179,6 +180,46 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(json_response[:answer]).to eq('Test answer')
end
it 'creates a new response if the user is an agent' do
expect do
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: valid_params,
headers: agent.create_new_auth_token,
as: :json
end.to change(Captain::AssistantResponse, :count).by(1)
expect(response).to have_http_status(:success)
expect(json_response[:question]).to eq('Test question?')
end
it 'creates a new response if the user has a custom role with knowledge base permission' do
custom_role = create(:custom_role, account: account, permissions: ['knowledge_base_manage'])
AccountUser.find_by!(account: account, user: agent_with_custom_role).update!(custom_role: custom_role)
expect do
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: valid_params,
headers: agent_with_custom_role.create_new_auth_token,
as: :json
end.to change(Captain::AssistantResponse, :count).by(1)
expect(response).to have_http_status(:success)
end
it 'does not create a response if the custom role lacks knowledge base permission' do
custom_role = create(:custom_role, account: account, permissions: ['conversation_manage'])
AccountUser.find_by!(account: account, user: agent_with_custom_role).update!(custom_role: custom_role)
expect do
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: valid_params,
headers: agent_with_custom_role.create_new_auth_token,
as: :json
end.not_to change(Captain::AssistantResponse, :count)
expect(response).to have_http_status(:forbidden)
end
context 'with invalid params' do
let(:invalid_params) do
{
@ -213,6 +254,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
@ -239,6 +281,16 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(json_response[:answer]).to eq('Updated answer')
end
it 'updates the response if the user is an agent' do
patch "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
params: update_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:question]).to eq('Updated question?')
end
context 'with invalid params' do
let(:invalid_params) do
{
@ -266,7 +318,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
it 'deletes the response' do
expect do
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
headers: admin.create_new_auth_token,
headers: agent.create_new_auth_token,
as: :json
end.to change(Captain::AssistantResponse, :count).by(-1)

View File

@ -5,6 +5,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
let(:assistant) { create(:captain_assistant, account: account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:agent_with_custom_role) { create(:user, account: account, role: :agent) }
let!(:pending_responses) do
create_list(
:captain_assistant_response,
@ -32,7 +33,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
it 'approves the responses and returns the updated records' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: valid_params,
headers: admin.create_new_auth_token,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
@ -59,7 +60,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: delete_params,
headers: admin.create_new_auth_token,
headers: agent.create_new_auth_token,
as: :json
end.to change(Captain::AssistantResponse, :count).by(-2)
@ -73,6 +74,40 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
end
end
context 'when the user has a custom role' do
let(:approve_params) do
{
type: 'AssistantResponse',
ids: pending_responses.map(&:id),
fields: { status: 'approve' }
}
end
it 'allows bulk actions with knowledge base permission' do
custom_role = create(:custom_role, account: account, permissions: ['knowledge_base_manage'])
AccountUser.find_by!(account: account, user: agent_with_custom_role).update!(custom_role: custom_role)
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: approve_params,
headers: agent_with_custom_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
end
it 'rejects bulk actions without knowledge base permission' do
custom_role = create(:custom_role, account: account, permissions: ['conversation_manage'])
AccountUser.find_by!(account: account, user: agent_with_custom_role).update!(custom_role: custom_role)
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: approve_params,
headers: agent_with_custom_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:forbidden)
end
end
context 'with invalid type' do
let(:invalid_params) do
{

View File

@ -62,14 +62,19 @@ RSpec.describe 'Api::V1::Accounts::Captain::Reservations', type: :request do
)
end
it 'returns paginated reservations ordered by operational priority by default' do
it 'returns paginated reservations with confirmed reservations first by default' do
get "/api/v1/accounts/#{account.id}/captain/reservations",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(json_response['payload'].pluck('id')).to eq([pending_reservation.id, confirmed_reservation.id])
expect(json_response['payload'].pluck('id')).to eq([confirmed_reservation.id, pending_reservation.id])
expect(json_response.dig('meta', 'total_count')).to eq(2)
expect(json_response.dig('payload', 0, 'ui_status')).to eq('pending_payment')
expect(json_response.dig('meta', 'status_counts')).to include(
'all' => 2,
'pending_payment' => 1,
'confirmed' => 1
)
expect(json_response.dig('payload', 0, 'ui_status')).to eq('confirmed')
end
it 'filters by confirmed ui status and search query' do

View File

@ -63,5 +63,72 @@ RSpec.describe Captain::Llm::ProviderConfig do
expect(described_class.settings[:api_base]).to eq(described_class::DEFAULT_CODEX_PROXY_URL)
end
end
context 'when provider is openai_hermes_gateway' do
before do
InstallationConfig.create!(name: 'CAPTAIN_LLM_PROVIDER', value: 'openai_hermes_gateway')
InstallationConfig.create!(name: 'CAPTAIN_HERMES_GATEWAY_URL', value: 'http://host.docker.internal:9877')
InstallationConfig.create!(name: 'CAPTAIN_HERMES_GATEWAY_MODEL', value: 'anthropic/claude-opus-4-5')
end
it 'returns the gateway URL with dummy api_key when no key is configured' do
settings = described_class.settings
expect(settings[:api_key]).to eq(described_class::HERMES_GATEWAY_DUMMY_KEY)
expect(settings[:api_base]).to eq('http://host.docker.internal:9877')
expect(settings[:model]).to eq('anthropic/claude-opus-4-5')
end
it 'honors CAPTAIN_HERMES_GATEWAY_API_KEY when present' do
InstallationConfig.create!(name: 'CAPTAIN_HERMES_GATEWAY_API_KEY', value: 'sk-hermes-real')
expect(described_class.settings[:api_key]).to eq('sk-hermes-real')
end
it 'honors a custom CAPTAIN_HERMES_GATEWAY_MODEL value' do
InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_MODEL').update!(value: 'openai/gpt-5.4')
expect(described_class.settings[:model]).to eq('openai/gpt-5.4')
end
it 'reports hermes_gateway? as true and codex_oauth? as false' do
expect(described_class.hermes_gateway?).to be true
expect(described_class.codex_oauth?).to be false
end
it 'strips trailing slash from gateway URL' do
InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_URL').update!(value: 'http://host.docker.internal:9877/')
expect(described_class.settings[:api_base]).to eq('http://host.docker.internal:9877')
end
it 'uses default model when CAPTAIN_HERMES_GATEWAY_MODEL is missing' do
InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_MODEL').delete
expect(described_class.settings[:model]).to eq(described_class::DEFAULT_HERMES_GATEWAY_MODEL)
end
it 'uses default URL when CAPTAIN_HERMES_GATEWAY_URL is missing' do
InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_URL').delete
expect(described_class.settings[:api_base]).to eq(described_class::DEFAULT_HERMES_GATEWAY_URL)
end
end
context 'when default provider (openai_api) is in use' do
it 'reports hermes_gateway? as false' do
expect(described_class.hermes_gateway?).to be false
end
end
describe '.light_model' do
it 'returns gpt-4o-mini for openai_api' do
expect(described_class.light_model).to eq('gpt-4o-mini')
end
it 'returns DEFAULT_CODEX_MODEL for openai_codex_oauth' do
InstallationConfig.create!(name: 'CAPTAIN_LLM_PROVIDER', value: 'openai_codex_oauth')
expect(described_class.light_model).to eq(described_class::DEFAULT_CODEX_MODEL)
end
it 'returns DEFAULT_HERMES_GATEWAY_MODEL for openai_hermes_gateway' do
InstallationConfig.create!(name: 'CAPTAIN_LLM_PROVIDER', value: 'openai_hermes_gateway')
expect(described_class.light_model).to eq(described_class::DEFAULT_HERMES_GATEWAY_MODEL)
end
end
end
end

View File

@ -34,6 +34,7 @@ RSpec.describe Captain::Reservations::MarkerBuilder do
expect(described_class.ui_status(:scheduled)).to eq('confirmed')
expect(described_class.ui_status(:active)).to eq('confirmed')
expect(described_class.ui_status(:completed)).to eq('confirmed')
expect(described_class.ui_status(:confirmed)).to eq('confirmed')
expect(described_class.ui_status(:cancelled)).to eq('cancelled')
end
end