diff --git a/app/controllers/webhooks/captain/hermes_builder_callback_controller.rb b/app/controllers/webhooks/captain/hermes_builder_callback_controller.rb new file mode 100644 index 000000000..901cb31f0 --- /dev/null +++ b/app/controllers/webhooks/captain/hermes_builder_callback_controller.rb @@ -0,0 +1,43 @@ +# Recebe callback do Hermes Construtor (plugin captain-http-callback). +# +# Construtor responde async via POST pra esta URL com: +# { content: "", 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 diff --git a/app/controllers/webhooks/captain/hermes_callback_controller.rb b/app/controllers/webhooks/captain/hermes_callback_controller.rb new file mode 100644 index 000000000..04e2a62bf --- /dev/null +++ b/app/controllers/webhooks/captain/hermes_callback_controller.rb @@ -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- 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= +# Body: { "content": "", "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 diff --git a/app/controllers/webhooks/captain/mcp_controller.rb b/app/controllers/webhooks/captain/mcp_controller.rb new file mode 100644 index 000000000..a996e782f --- /dev/null +++ b/app/controllers/webhooks/captain/mcp_controller.rb @@ -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 ` +# É o que `hermes mcp add --auth header` usa nativamente. +# - HMAC-SHA256 do body: `X-Hub-Signature-256: sha256=` +# 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 diff --git a/app/javascript/dashboard/api/captain/hermesBuilder.js b/app/javascript/dashboard/api/captain/hermesBuilder.js new file mode 100644 index 000000000..47853be2b --- /dev/null +++ b/app/javascript/dashboard/api/captain/hermesBuilder.js @@ -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(); diff --git a/app/javascript/dashboard/components-next/captain/assistant/AssistantCard.vue b/app/javascript/dashboard/components-next/captain/assistant/AssistantCard.vue index 7e80cd32c..f6b366a4d 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/AssistantCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/AssistantCard.vue @@ -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 }) => {