merge: align main with hermes production branch
This commit is contained in:
commit
c7d5bbff99
@ -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
|
||||
226
app/controllers/webhooks/captain/hermes_callback_controller.rb
Normal file
226
app/controllers/webhooks/captain/hermes_callback_controller.rb
Normal 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
|
||||
110
app/controllers/webhooks/captain/mcp_controller.rb
Normal file
110
app/controllers/webhooks/captain/mcp_controller.rb
Normal 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
|
||||
38
app/javascript/dashboard/api/captain/hermesBuilder.js
Normal file
38
app/javascript/dashboard/api/captain/hermesBuilder.js
Normal 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();
|
||||
@ -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)"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -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>
|
||||
@ -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 />
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
401
bin/hermes-provision
Executable 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
227
bin/hermes-validate
Executable 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
|
||||
@ -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.'
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
29
db/migrate/20260502120000_create_captain_pricing_tables.rb
Normal file
29
db/migrate/20260502120000_create_captain_pricing_tables.rb
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
40
db/schema.rb
40
db/schema.rb
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_01_030000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_05_02_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
150
db/seed_pricing_tables.rb
Normal 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
|
||||
120
db/seed_pricing_units_1_2_3.rb
Normal file
120
db/seed_pricing_units_1_2_3.rb
Normal file
@ -0,0 +1,120 @@
|
||||
# Seed pricing for units 1 (Hotel Recanto), 2 (PrimeAL), 3 (Qnn01) from
|
||||
# scenario data. Units 4 (Dolce), 5 (Express), 6 (Prime Ceilândia) já têm.
|
||||
#
|
||||
# Idempotente. Roda quantas vezes quiser.
|
||||
# rubocop:disable all
|
||||
|
||||
NOITES_DATA = {
|
||||
'standard' => {
|
||||
aliases: %w[standard comum], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'2h' => { 'mon_wed' => 40.0, 'thu_sun' => 50.0 },
|
||||
'3h' => { 'mon_wed' => 50.0, 'thu_sun' => 65.0 },
|
||||
'4h' => { 'mon_wed' => 60.0, 'thu_sun' => 80.0 },
|
||||
'pernoite_promo' => { 'mon_wed' => 100.0, 'thu_sun' => 150.0 },
|
||||
'diaria' => 170.0
|
||||
}
|
||||
},
|
||||
'luxo' => {
|
||||
aliases: ['luxo', 'classica', 'clássica'], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'2h' => 60.0, '3h' => 75.0, '4h' => 85.0,
|
||||
'pernoite_promo' => { 'mon_wed' => 130.0, 'thu_sun' => 160.0 },
|
||||
'diaria' => 190.0
|
||||
}
|
||||
},
|
||||
'hidromassagem' => {
|
||||
aliases: %w[hidro hidromassagem banheira spa jacuzzi], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'2h' => 110.0, '3h' => 120.0, '4h' => 150.0,
|
||||
'pernoite_promo' => 250.0, 'diaria' => 300.0
|
||||
}
|
||||
}
|
||||
}.freeze
|
||||
|
||||
PRIME_DATA = {
|
||||
'stilo' => {
|
||||
aliases: %w[stilo estilo], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'1h' => { 'mon_wed' => 40.0, 'thu_sun' => 50.0 },
|
||||
'2h' => { 'mon_wed' => 60.0, 'thu_sun' => 70.0 },
|
||||
'3h' => { 'mon_wed' => 70.0, 'thu_sun' => 80.0 },
|
||||
'4h' => { 'mon_wed' => 75.0, 'thu_sun' => 85.0 },
|
||||
'pernoite_promo' => { 'mon_wed' => 130.0, 'thu_sun' => 150.0 },
|
||||
'pernoite_integral' => { 'mon_wed' => 150.0, 'thu_sun' => 170.0 },
|
||||
'diaria' => { 'mon_wed' => 160.0, 'thu_sun' => 180.0 }
|
||||
}
|
||||
},
|
||||
'alexa' => {
|
||||
aliases: %w[alexa], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'1h' => { 'mon_wed' => 50.0, 'thu_sun' => 60.0 },
|
||||
'2h' => { 'mon_wed' => 65.0, 'thu_sun' => 75.0 },
|
||||
'3h' => { 'mon_wed' => 75.0, 'thu_sun' => 85.0 },
|
||||
'4h' => { 'mon_wed' => 80.0, 'thu_sun' => 90.0 },
|
||||
'pernoite_promo' => { 'mon_wed' => 140.0, 'thu_sun' => 160.0 },
|
||||
'pernoite_integral' => { 'mon_wed' => 160.0, 'thu_sun' => 180.0 },
|
||||
'diaria' => { 'mon_wed' => 170.0, 'thu_sun' => 200.0 }
|
||||
}
|
||||
},
|
||||
'hidromassagem' => {
|
||||
aliases: %w[hidro hidromassagem banheira spa jacuzzi ofuro], extra_person_starts_at: 3,
|
||||
prices: {
|
||||
'1h' => { 'mon_wed' => 130.0, 'thu_sun' => 140.0 },
|
||||
'2h' => { 'mon_wed' => 150.0, 'thu_sun' => 160.0 },
|
||||
'3h' => { 'mon_wed' => 170.0, 'thu_sun' => 180.0 },
|
||||
'4h' => { 'mon_wed' => 190.0, 'thu_sun' => 200.0 },
|
||||
'pernoite_promo' => { 'mon_wed' => 260.0, 'thu_sun' => 280.0 },
|
||||
'pernoite_integral' => { 'mon_wed' => 280.0, 'thu_sun' => 300.0 },
|
||||
'diaria' => { 'mon_wed' => 350.0, 'thu_sun' => 370.0 }
|
||||
}
|
||||
}
|
||||
}.freeze
|
||||
|
||||
def upsert(unit, data)
|
||||
data.each do |key, attrs|
|
||||
cat = Captain::PricingCategory.find_or_initialize_by(captain_unit_id: unit.id, key: key)
|
||||
cat.aliases = attrs[:aliases]
|
||||
cat.extra_person_starts_at = attrs[:extra_person_starts_at]
|
||||
cat.save!
|
||||
attrs[:prices].each do |period, value|
|
||||
if value.is_a?(Hash)
|
||||
value.each do |bucket, amount|
|
||||
row = Captain::PricingAmount.find_or_initialize_by(
|
||||
captain_pricing_category_id: cat.id, period: period, day_bucket: bucket
|
||||
)
|
||||
row.amount = amount
|
||||
row.save!
|
||||
end
|
||||
else
|
||||
row = Captain::PricingAmount.find_or_initialize_by(
|
||||
captain_pricing_category_id: cat.id, period: period, day_bucket: nil
|
||||
)
|
||||
row.amount = value
|
||||
row.save!
|
||||
end
|
||||
end
|
||||
puts "✓ unit=#{unit.id} #{key}"
|
||||
end
|
||||
end
|
||||
|
||||
# 1001 Noites brand units = 1 (Hotel Recanto), 3 (Qnn01)
|
||||
[1, 3].each do |id|
|
||||
u = Captain::Unit.find(id)
|
||||
u.update!(extra_person_fee: 45.0, currency: 'BRL') if u.extra_person_fee.to_f.zero?
|
||||
upsert(u, NOITES_DATA)
|
||||
end
|
||||
|
||||
# Prime brand unit = 2 (PrimeAL)
|
||||
u = Captain::Unit.find(2)
|
||||
u.update!(extra_person_fee: 0.0, currency: 'BRL')
|
||||
upsert(u, PRIME_DATA)
|
||||
|
||||
puts "--- summary ---"
|
||||
[1, 2, 3].each do |id|
|
||||
u = Captain::Unit.find(id)
|
||||
cats = u.pricing_categories.count
|
||||
amounts = Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: u.id }).count
|
||||
puts "unit #{id} #{u.name}: cats=#{cats} amounts=#{amounts}"
|
||||
end
|
||||
# rubocop:enable all
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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?
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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'
|
||||
)
|
||||
|
||||
85
enterprise/app/jobs/captain/hermes/delayed_reply_job.rb
Normal file
85
enterprise/app/jobs/captain/hermes/delayed_reply_job.rb
Normal 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
|
||||
@ -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
|
||||
71
enterprise/app/jobs/captain/hermes/outgoing_job.rb
Normal file
71
enterprise/app/jobs/captain/hermes/outgoing_job.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
40
enterprise/app/models/captain/pricing_amount.rb
Normal file
40
enterprise/app/models/captain/pricing_amount.rb
Normal 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
|
||||
55
enterprise/app/models/captain/pricing_category.rb
Normal file
55
enterprise/app/models/captain/pricing_category.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
33
enterprise/app/policies/captain/assistant_response_policy.rb
Normal file
33
enterprise/app/policies/captain/assistant_response_policy.rb
Normal 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
|
||||
115
enterprise/app/services/captain/hermes.rb
Normal file
115
enterprise/app/services/captain/hermes.rb
Normal 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
|
||||
167
enterprise/app/services/captain/hermes/auto_react_service.rb
Normal file
167
enterprise/app/services/captain/hermes/auto_react_service.rb
Normal 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
|
||||
209
enterprise/app/services/captain/hermes/client.rb
Normal file
209
enterprise/app/services/captain/hermes/client.rb
Normal 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
|
||||
@ -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',
|
||||
|
||||
@ -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'),
|
||||
|
||||
111
enterprise/app/services/captain/mcp/pricing_tables.rb
Normal file
111
enterprise/app/services/captain/mcp/pricing_tables.rb
Normal 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
|
||||
96
enterprise/app/services/captain/mcp/server.rb
Normal file
96
enterprise/app/services/captain/mcp/server.rb
Normal 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
|
||||
47
enterprise/app/services/captain/mcp/tool_registry.rb
Normal file
47
enterprise/app/services/captain/mcp/tool_registry.rb
Normal 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
|
||||
82
enterprise/app/services/captain/mcp/tools/add_label_tool.rb
Normal file
82
enterprise/app/services/captain/mcp/tools/add_label_tool.rb
Normal 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
|
||||
56
enterprise/app/services/captain/mcp/tools/base_tool.rb
Normal file
56
enterprise/app/services/captain/mcp/tools/base_tool.rb
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
76
enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb
Normal file
76
enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb
Normal 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
|
||||
432
enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb
Normal file
432
enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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 já. 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 JÁ 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 já 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 SÓ pra valores e nomes de categoria. Em msg curta, sem negrito também tá ótimo.
|
||||
|
||||
## Memória
|
||||
Lembro de cada cliente que já 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 SÓ 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 só 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 só 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 já dá 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 só).
|
||||
|
||||
**NUNCA confundir A com B.** Cliente perguntando preço ≠ cliente reservando. Não interrogue quem só 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 é só 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 já 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 é só pra problemas (cliente já 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 já foram tratadas em turns passados — ignore.
|
||||
- **Burst repetido:** se a mesma fala aparecer 2x+ no payload, responda 1 vez só, 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 já escolher uma categoria?
|
||||
|
||||
Sem variações, sem importar quantas vezes repete.
|
||||
- **Mensagem vazia/sem texto:** responda canônico:
|
||||
> Tô 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
|
||||
@ -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
|
||||
127
enterprise/app/services/captain/mcp/tools/update_contact_tool.rb
Normal file
127
enterprise/app/services/captain/mcp/tools/update_contact_tool.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -5,6 +5,7 @@ class Captain::Reservations::MarkerBuilder
|
||||
'scheduled' => 'confirmed',
|
||||
'active' => 'confirmed',
|
||||
'completed' => 'confirmed',
|
||||
'confirmed' => 'confirmed',
|
||||
'cancelled' => 'cancelled'
|
||||
}.freeze
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
45
enterprise/app/services/hermes_builder/dispatcher.rb
Normal file
45
enterprise/app/services/hermes_builder/dispatcher.rb
Normal 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
|
||||
87
enterprise/app/services/hermes_builder/repairer.rb
Normal file
87
enterprise/app/services/hermes_builder/repairer.rb
Normal 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
|
||||
43
enterprise/app/services/hermes_builder/storage.rb
Normal file
43
enterprise/app/services/hermes_builder/storage.rb
Normal 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
|
||||
212
enterprise/app/services/hermes_builder/validator.rb
Normal file
212
enterprise/app/services/hermes_builder/validator.rb
Normal 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
|
||||
@ -7,4 +7,5 @@ end
|
||||
json.meta do
|
||||
json.total_count @reservations_count
|
||||
json.page @current_page
|
||||
json.status_counts @status_counts || {}
|
||||
end
|
||||
|
||||
@ -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
|
||||
|
||||
76
hermes-plugins/captain-http-callback/README.md
Normal file
76
hermes-plugins/captain-http-callback/README.md
Normal 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.
|
||||
3
hermes-plugins/captain-http-callback/__init__.py
Normal file
3
hermes-plugins/captain-http-callback/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
216
hermes-plugins/captain-http-callback/adapter.py
Normal file
216
hermes-plugins/captain-http-callback/adapter.py
Normal 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."
|
||||
),
|
||||
)
|
||||
14
hermes-plugins/captain-http-callback/plugin.yaml
Normal file
14
hermes-plugins/captain-http-callback/plugin.yaml
Normal 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)
|
||||
77
hermes-plugins/captain-webhook/README.md
Normal file
77
hermes-plugins/captain-webhook/README.md
Normal 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).
|
||||
3
hermes-plugins/captain-webhook/__init__.py
Normal file
3
hermes-plugins/captain-webhook/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
198
hermes-plugins/captain-webhook/adapter.py
Normal file
198
hermes-plugins/captain-webhook/adapter.py
Normal 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."
|
||||
),
|
||||
)
|
||||
28
hermes-plugins/captain-webhook/plugin.yaml
Normal file
28
hermes-plugins/captain-webhook/plugin.yaml
Normal 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)
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user