UI nova dentro do Construtor (Hermes) — TabBar com Chat e Verificação.
Verificação roda HermesBuilder::Validator (DB+runtime) e exibe resultado
agrupado por categoria, com botão Refazer inline em FAIL/WARN reparáveis.
Backend (porta dos checks DB do CLI bin/hermes-validate):
- HermesBuilder::Validator com 22+ checks: engine, profile, port,
secret, parent, unit, Brand, CaptainInbox sync (o bug que travou
Juliana), pricing dry-run, Inter creds, typing/response_delay,
registry MCP completo.
- HermesBuilder::Repairer com 4 handlers automáticos: set_engine_hermes,
sync_captain_inbox_unit, set_default_typing_delay,
set_default_response_delay.
- Endpoints novos: GET assistants, GET validate?slug=, POST repair.
Frontend:
- builder/Index.vue: wrapper com TabBar.
- builder/BuilderChat.vue: extraído do Index original.
- builder/BuilderVerification.vue: dropdown + Conferir agora + lista
agrupada por categoria com badges + botão Refazer inline.
i18n: keys em pt_BR e en sob CAPTAIN_HERMES_BUILDER.VERIFY.*.
Filesystem/systemd checks ficam pro CLI hermes-validate (Rails container
não enxerga /root/.hermes/profiles do host).
Validado HTTP: GET /validate?slug=juliana_qnn1 → 28 PASS / 0 FAIL / 1 WARN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
resolve_unit agora prioriza Captain::Assistant.captain_unit_id sobre o
mapping legado CaptainInbox (que falha quando 2 agentes — interno e
Hermes — compartilham a mesma inbox).
Caso real: Juliana Hermes (unit Qnn01) compartilhava inbox 1 com Juliana
captain_interno (unit Recanto), mas o CaptainInbox da inbox 1 estava
mapeado pra unit Dolce Amore (id=4) por contaminação anterior. Tool
resolvia unit errada, generate_pix retornava "categoria não reconhecida"
e o agente travava em "⏳ Um momento — vou verificar." sem retomar.
bin/hermes-validate ganha 3 checks novos:
- CaptainInbox.unit == Assistant.unit (FAIL — exatamente o bug acima)
- Pricing dry-run (calcula preço da 1ª categoria sem erro)
- Credenciais Inter completas (WARN se faltar cert/key — cai no fallback)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug 1: send_suite_images_tool description mencionava 'Master, Luxo, Mini
Chalé 45' como exemplos. Quando outros agentes (ex: Juliana de Qnn01)
faziam tools/list, o LLM via essas categorias e usava como referência —
respondia oferecendo Mini Chalé 45 e Suíte Ouro pra cliente do 1001
Noites Ceilândia (que não tem essas categorias). Removidos exemplos.
Bug 2: lookup_brand fazia fuzzy match permissivo demais. 'Hoteis 1001
Noites' e 'Hotel 1001 Noites Prime' ambos contêm '1001 Noites' — quem
vinha primeiro no .find ganhava (Prime, ID menor). Juliana de Qnn01
(brand 'Hoteis 1001 Noites') saiu do Construtor com SOUL.md dizendo
'Hotel 1001 Noites Prime'. Fix: prioriza brand do parent_unit (fonte
canônica), depois exact-match casecmp, depois fuzzy.
Tudo isso vale pra Construtor entregar agente certo da próxima vez —
foram os 2 erros de provisionamento da Juliana hoje.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: Captain dispatchava pra /webhooks/captain-inbox-<inbox.id>, mas o
script hermes-provision criava subscription com nome captain-inbox-<slug>.
Mismatch → daemon retornava 404, Sidekiq retentava, AutoReact firava
N reactions sem nunca dispatchar pro LLM.
Fix:
- subscription_name_for(inbox): se o assistant tem hermes_profile_name,
usa "captain-inbox-<slug>" (estável por agente). Fallback pra
"captain-inbox-<inbox.id>" só se não tiver slug.
- subscription_signing_secret(inbox): lê de
assistant.hermes_subscription_secret primeiro (DB-driven, gravado pelo
script). Fallback pra env var legacy CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_<id>.
Resultado: admin pode apontar Angelina (inbox 1) pra qualquer agente
Hermes (Valentina · Hermes / Nina · Hermes / Lara.H / Juliana · Hermes)
e o roteamento funciona — não depende mais de inbox.id no path.
Renomeei manualmente as subscriptions de Valentina e Nina nos profiles
da VPS (eram captain-inbox-1 e captain-inbox-5 legado) pra
captain-inbox-valentina e captain-inbox-nina.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: build_skill_md recebia AR Captain::PricingCategory objects mas o
format_pricing_block fazia cat['amounts'] (índice de hash). AR respondem
a .amounts, não a ['amounts'] → nil → undefined method 'group_by' for nil.
Fix: serializa primeiro, reusa em ambos os lugares.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captain::Assistant agora aponta direto pra Captain::Unit. Antes a relação
ia via CaptainInbox, mas isso quebrou quando re-apontamos as inboxes pros
Hermes — assistants captain_interno (Juliana, Bianca, Lara, Nina,
Valentina) ficaram SEM CaptainInbox associada e o lookup
unit_for(assistant) retornava nil.
Resultado: get_assistant_pricing(3) (Lara) caía no fallback de scenario
text. Construtor reportava "veio cenário/prompt, não tabela estruturada".
Migration adiciona captain_unit_id (FK opcional). Backfill explícito:
- 1 Juliana → unit 3 (Qnn01)
- 2 Bianca → unit 2 (PrimeAL)
- 3 Lara → unit 2 (PrimeAL — mesmo brand)
- 4 Nina → unit 5 (Express)
- 6 Valentina → unit 4 (Dolce Amore)
- 9 Lara.H → unit 2 (via parent_assistant_id=3)
Tools get_assistant_pricing_tool e save_agent_spec_tool atualizados pra
usar assistant.captain_unit primeiro (nova relação direta), com fallback
pro CaptainInbox se nulo (pra retrocompatibilidade).
Validado live: tool retorna grid markdown com Stilo/Alexa/Hidromassagem
em Seg-Qua + Qui-Dom corretamente.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ROOT FIX (não paliativo) das 3 lacunas que travavam o Construtor:
1. get_assistant_pricing_tool: lia de Captain::Mcp::PricingTables::TABLES
(hash Ruby) que NÃO EXISTE MAIS desde a migração pra DB. Caía no
fallback de scenario raw. Refactor: lê de Captain::PricingCategory +
Captain::PricingAmount, formata grid markdown agrupado por day_bucket.
2. save_agent_spec_tool: Construtor salvava REFERÊNCIAS
(pricing_source.copied_from_assistant_id) mas hermes-provision script
espera DADOS EXPANDIDOS (categories[] com amounts, soul_md+skill_md).
Refactor: tool agora EXPANDE server-side — busca PricingCategory do
parent, monta categories array, gera SOUL.md (template + identity +
disclosure_policy) e SKILL.md (template + pricing + rules + identity).
Output já é spec consumível pelo script.
3. Captain::PricingAmount::PERIODS: adicionado '1h' (Prime tem 1h).
4. Seed pras 3 units faltando: Hotel Recanto (1) + PrimeAL (2) + Qnn01
(3). Agora os 6 units existentes têm pricing em DB.
Hot-patched ambos tools + USR1 no Puma. Construtor pronto pra criar
Bianca/Juliana etc end-to-end sem intervenção manual.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Construtor atende 'copiar maps/endereço/telefone/wifi da Lara' sem o admin
redigitar. Tool retorna o markdown bruto do scenario (default
Daniela_Reservas) do assistant fonte; LLM extrai os campos relevantes.
Cobre o gap entre get_assistant_pricing (preços estruturados) e
get_assistant_faqs (Q&As): essa retorna prompt CRU pra LLM interpretar
campos não estruturados (contatos, links, wifi, persona).
Hot-patched + USR1 no Puma e Construtor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filtro original só retornava FAQs com documentable_type=NULL, mas a maior
parte das FAQs aprovadas das Jasmines (Juliana, Bianca, Lara, Nina) tem
documentable_type='User' ou 'Conversation' (origem: histórico de
conversas). Resultado: tool retornava "0 FAQs aprovados" pra todos exceto
Valentina (única com FAQs criadas direto sem origem documentável).
Removido o filtro. Status='approved' já é suficiente — admin reviewou.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tool registry chama tool.call(args, context: foo) mas as 3 tools read-only
do Construtor (get_assistant_faqs, get_assistant_pricing, save_agent_spec)
estavam declaradas como def call(args, _context:), com underscore. No
Ruby isso muda o nome do parâmetro keyword — ArgumentError:
'missing keyword: :_context' quando o Construtor tentava copiar FAQs/
pricing de um assistant existente.
Corrigido pra context: (sem underscore) com rubocop disable de
Lint/UnusedMethodArgument já que essas tools não usam o context.
Hot-patched via docker cp + Puma USR1 antes do deploy pro Rodrigo seguir
testando o Construtor sem esperar build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migra a tabela de preços do PricingTables.rb hardcoded pras tabelas
captain_pricing_categories + captain_pricing_amounts no DB. Mantém a
mesma API pública Captain::Mcp::PricingTables.calculate(...) — código
chama o banco via novos modelos Captain::PricingCategory e
Captain::PricingAmount.
Seed db/seed_pricing_tables.rb faz backfill idempotente pra Dolce Amore
(unit 4) e Express (unit 5) com a mesma estrutura que tava no Ruby.
Adiciona em captain_assistants:
- hermes_subscription_secret (gerado pelo script de provisionamento)
- hermes_port (alocado no range 8650-8699)
- parent_assistant_id (link informativo Hermes → captain_interno parent
pra sombrear FAQs/scenarios via header X-Captain-Assistant-Id)
Adiciona em captain_units: extra_person_fee + currency.
Primeiro milestone do roadmap arquitetural pro Construtor autônomo
(decisões em memory/project_construtor_autonomo_decisions.md).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Antes: inbox.typing_delay funcionava só pro Captain interno
(schedule_internal_response). Hermes ignorava o campo e disparava
OutgoingJob na hora — campo da UI era cosmético pra inboxes Hermes.
Agora:
- schedule_hermes_response cancela jobs OutgoingJob pendentes pra mesma
conversa e enfileira com wait=inbox.typing_delay (debounce window).
- OutgoingJob agrupa todas msgs incoming entre a última resposta real
do agente (ignora reactions) e a msg âncora; dispatch envia o texto
concatenado pro Hermes via novo content_override no Client#dispatch.
Resultado: cliente que digita "Oi" + "quero pernoite Master" em segundos
vê o agente esperar até o buffer vencer e responder UMA vez cobrindo
ambas as falas, em vez de 2 respostas atropelando o pensamento.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OutgoingJob faz retry no DispatchError (até 3x ActiveJob + Sidekiq).
Cada retry chamava AutoReactService.maybe_react! e criava uma reaction
nova — observado em prod 02/05 quando o env var
CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_5 estava faltando, gerando
401 → 6 reações duplicadas no inbox EXPRESS.
Adiciona guard already_reacted? que checa se já existe Message outgoing
com external_source='hermes_auto_react' e in_reply_to=msg.id antes de
criar uma nova. Defesa contra futuro 5xx/timeout do Hermes daemon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marca cada Captain::Assistant com engine ('captain_interno' | 'hermes')
e move o roteamento Hermes do env var pro banco — admin troca engine
re-apontando a inbox no painel, sem deploy. Mantém fallback pras env
vars antigas (CAPTAIN_HERMES_INBOX_IDS etc) durante a migração gradual,
pra não quebrar Valentina antes da re-associação.
Frontend: badge "Hermes" (âmbar) ou "Interno" (cinza) ao lado de cada
assistant no dropdown switcher e no card da listagem, com chaves i18n
em en + pt_BR.
Tabela de preço (pricing_tables.rb): adiciona unit Express (id=5) e
estende a estrutura pra aceitar preço por dia da semana
(mon_wed/thu_sun) — necessário pro Express, retrocompatível com Dolce
Amore (preço único).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: Hermes nao propaga chat_id no metadata do callback. Callback
controller nao conseguia resolver qual session armazenar a resposta.
WARN "no session_key resolvable — ignorando" descartava todas as
respostas do Construtor.
Fix: HermesBuilder::Storage.remember_last_session() grava ultima
session por account quando admin chama /start ou /create. Callback
le essa key via last_session_for(account_id). MVP-safe pra 1 admin
por conta.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Estende AutoReactService com 2 padroes novos:
- GREETING_REGEX (bom dia, boa noite, oi, ola, hello) -> 👋
apenas na PRIMEIRA mensagem da conversa pra nao ficar forcado
- FAREWELL_REGEX (tchau, ate logo, abraço, bjs, flw) -> ❤️
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: controller estava em enterprise/api/v1/accounts/captain/ com namespace
Enterprise:: — convencao Chatwoot eh enterprise/app/controllers/api/v1/...
direto, classe Api::V1::Accounts::Captain::HermesBuilderController. Sem
namespace Enterprise::. 404 acontecia porque rotas registravam Captain::
sem prefixo Enterprise::.
Move controller pro path correto. Remove diretorios vazios criados.
UX: adiciona endpoint POST /start que envia comando-gatilho oculto pro
Construtor comecar fluxo socratico — admin nao precisa digitar primeira
mensagem. Vue mostra empty state com botao "Iniciar criacao" em vez de
exigir mensagem inicial.
i18n keys novas: START + EMPTY_STATE atualizado em pt_BR + en.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pattern matching no Captain dispara reaction <1s sem esperar Codex
processar. Resposta de texto continua vindo do Hermes normalmente —
auto-react eh COMPLEMENTAR.
Padroes detectados (no incoming message):
- "obrigado/valeu/vlw/thanks" -> 🙏
- "ok/fechado/perfeito/blz/combinado" -> 👍
- attachment image (msg sem texto) -> 😍
- attachment audio (msg curta) -> 🙏
Conservative: só dispara em casos CLAROS. Em duvida, deixa pro LLM
decidir via react_to_message tool.
Plugado em Captain::Hermes::OutgoingJob#perform — chamado antes do
dispatch pro Hermes. Falha silenciosa (rescue) — nao bloqueia fluxo.
SOUL.md atualizado: regra de frequencia (~30% das msgs) + nota sobre
auto-react pra LLM nao duplicar reacao em casos cobertos.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tela "Construtor" no painel Captain (acessivel em sidebar pra admins) que
permite criar novo agente Hermes via chat guiado com agente Construtor
(profile Hermes separado).
Backend (admin scope):
- POST /api/v1/accounts/:id/captain/hermes_builder — manda mensagem do
admin pro gateway do Construtor (Hermes na porta 8646)
- GET — retorna historico da sessao (Rails.cache, TTL 4h)
- DELETE /reset — limpa sessao
- POST /webhooks/captain/builder_callback — recebe respostas async do
Construtor via plugin captain-http-callback do Hermes
- HermesBuilder::Storage (Rails.cache) — persiste msgs por session_key
(account_id + user_id) com role/content/created_at
- HermesBuilder::Dispatcher — encaminha pro webhook do Construtor com
HMAC opcional via ENV HERMES_BUILDER_WEBHOOK_SECRET
Frontend:
- Pagina Vue HermesBuilder/Index.vue — chat simples com:
* Lista de mensagens com bubbles user/construtor
* Indicador "digitando..." enquanto aguarda resposta
* Input com Enter pra enviar / Shift+Enter pra nova linha
* Polling 2s pra novas msgs
* Botao Limpar conversa
- API client em api/captain/hermesBuilder.js
- Rota captain_hermes_builder_index (admin only)
- Item no sidebar Captain "Construtor (Hermes)"
- i18n keys CAPTAIN.HERMES_BUILDER em pt_BR + en
UX flow:
Admin abre tela → digita "olá" → Construtor pergunta nome → admin
responde → marca, persona, tabela (com opcao copiar de existente),
regras, FAQs, identidade → resumo → confirmar → Construtor chama
save_agent_spec → JSON salvo em /tmp/agent-specs/<slug>.json pra
revisao posterior. NAO cria filesystem do profile nem registros DB
(etapa SEPARADA, prox sessao).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 tools novas (admin scope, read-only) usadas pelo profile Hermes
"construtor" durante criacao guiada de novos agentes:
- list_assistants: lista todos os assistants da conta com badge engine
(Captain interno vs Hermes), counts de scenarios/FAQs, marca/unidade
- get_assistant_pricing: retorna tabela de preços parsed (Captain::Mcp::
PricingTables se Hermes, scenario fallback se Captain interno)
- get_assistant_faqs: retorna FAQs aprovados em markdown (até 50)
- save_agent_spec: salva JSON da especificacao em /tmp/agent-specs/<slug>.json
(NÃO cria filesystem do profile nem registros DB — só persiste spec
pra revisao/provisionamento separado depois)
Construtor coleta perguntas socraticas, oferece "copiar de existente" pra
acelerar quando marca for igual (Prime VL/AL/ADE preços iguais), e ao
final salva spec.json pra admin revisar antes de provisionar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captain::Hermes::Client (enterprise/app/services/captain/hermes/client.rb):
- text_for_hermes: transcreve audio via Whisper antes de enviar pro Hermes
(reusa Captain::OpenAiMessageBuilderService)
- image_urls_for_hermes: URLs publicas de imagens da message; plugin
captain-webhook do Hermes baixa em /tmp/ e popula event.media_urls pra
vision multimodal (gpt-4o-mini auxiliary)
- contact_history_snapshot: dados eager pro [ctx] (last_reservation_*,
total_conversations, ultima_suite, etc) — memoria do contato direto no
prompt sem precisar tool call
- notify_event + build_event_payload: dispara webhook sintetico pro Hermes
pra eventos do sistema (Pix pago etc) — Valentina manda mensagem
espontanea sem cliente perguntar
Captain::Payments::ConfirmationService:
- Hook notify_hermes_proactively! enfileira NotifyPaymentConfirmedJob
apos confirmacao de Pix, somente se inbox estiver no fluxo Hermes
(Captain interno continua igual sem mudanca)
Captain::Hermes::NotifyPaymentConfirmedJob (NOVO):
- Monta system_message "[SISTEMA: pagamento_confirmado]\n..." e dispara
webhook pro Hermes Valentina
- Valentina (via SOUL.md) interpreta como evento do Captain e manda
mensagem celebrativa pro cliente
Captain::Hermes::DelayedReplyJob (NOVO) — humanizadores:
- Liga indicador "digitando..." (composing) via wuzapi
- Aguarda delay configuravel via Captain::Assistant.config['response_delay']
(modos: none, fixed, typing_simulation com chars_per_second + min/max)
- Posta msg outgoing
- Desliga typing
- Fallback no HermesCallbackController posta direto se class nao carregada
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tools novas em enterprise/app/services/captain/mcp/tools/:
- generate_pix: Pix Inter via PricingTable + fallback link reserva-1001
- check_pix_payment: consulta Inter + dispara ConfirmationService
- send_suite_images: fotos da galeria (Captain::GalleryItem) via wuzapi
- reschedule_reservation: remarca reserva (regra 3h antecedência Dolce)
- update_contact: persiste nome/CPF/email/notas no Contact
- get_contact_history: markdown do histórico do cliente on-demand
- react_to_message: reage com emoji via wuzapi (is_reaction=true)
Captain::Mcp::PricingTables: tabela hardcoded Dolce Amore (8 categorias x 4
periodos + regras de pessoa extra). Backend e fonte de verdade — LLM nao
inventa preco.
add_label_tool: cria Label oficial automaticamente se nao existir, aceita
conversation_id em arguments.
mcp_controller: aceita X-Captain-Account-Id/Assistant-Id/Inbox-Id como
fallback de contexto.
tool_registry: 9 tools ativas.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RubyLLM auto-detecta provider pelo prefixo do nome do modelo (ex:
`gemini-*` → provider Gemini → exige `gemini_api_key`). Quando temos
config dedicada de embedding (CAPTAIN_EMBEDDING_API_KEY) apontando pra
endpoint OpenAI-compatible (ex: Gemini OpenAI-compat em
generativelanguage.googleapis.com/v1beta/openai), queremos que o RubyLLM
mande a request via OpenAI client mesmo que o nome do modelo bata com
outro provider.
Solução: passar provider: :openai e assume_model_exists: true ao chamar
embed quando dedicated_embedding_config? retornar true. Sem isso, o
RubyLLM falha com `Missing configuration for Gemini: gemini_api_key`
mesmo com a key correta setada.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Permite trocar provider de embedding sem afetar o provider de chat. Útil
quando OpenAI key tradicional está fora (ban, billing, etc) mas você
quer usar outro provider OpenAI-compatible só pra embeddings — exemplo
clássico: Gemini OpenAI-compatible em
https://generativelanguage.googleapis.com/v1beta/openai com modelo
gemini-embedding-001 + dimensions=1536 (pra bater com schema pgvector).
Env vars novas (com fallback pro legacy_openai_settings se não setadas):
CAPTAIN_EMBEDDING_API_KEY — API key dedicada pra embeddings
CAPTAIN_EMBEDDING_ENDPOINT — base URL sem /v1 (default herda OpenAI)
CAPTAIN_EMBEDDING_DIMENSIONS — força redução do vector (ex: 1536)
Quando CAPTAIN_EMBEDDING_API_KEY está vazia, comportamento é idêntico ao
de antes (legacy_openai_settings). Backward-compatible.
Também aceita as variáveis via InstallationConfig (UI) ou ENV — ENV tem
precedência (padrão Chatwoot).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implementa servidor MCP (Model Context Protocol) HTTP no Captain pra
o Hermes Agent invocar tools do Captain via `hermes mcp add`. Substrato
pra integração de Nível 2 onde o agente consulta tools quando precisa
executar ações reais (buscar FAQ, adicionar label, futuramente Pix etc).
Arquivos:
- app/controllers/webhooks/captain/mcp_controller.rb
Endpoint POST /webhooks/captain/mcp. Valida HMAC (CAPTAIN_MCP_SECRET),
parseia JSON-RPC, despacha pro Server. Extrai params._captain_context
com multi-tenant ids (conversation_id, inbox_id, account_id, etc).
- enterprise/app/services/captain/mcp/server.rb
Subset MCP suficiente: initialize, tools/list, tools/call, ping,
notifications/initialized. JSON-RPC síncrono (sem SSE).
- enterprise/app/services/captain/mcp/tool_registry.rb
Lista centralizada de tools.
- enterprise/app/services/captain/mcp/tools/base_tool.rb
Interface base pras tools (.name, .description, .input_schema, #call).
- enterprise/app/services/captain/mcp/tools/add_label_tool.rb
Tool 1: aplica label na conversation atual.
- enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb
Tool 2: busca semântica em FAQs (Captain::AssistantResponse via pgvector
cosine). Reaproveita SearchReplyDocumentationService pra paridade com
o caminho legado do Captain.
- config/routes.rb
Rota POST /webhooks/captain/mcp.
Conexão pelo Hermes:
hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp
Auth: HMAC X-Hub-Signature-256 quando CAPTAIN_MCP_SECRET setado.
TODO próxima sprint: generate_pix_tool, send_suite_images_tool. Handoff
fica via automation hoje (UI Chatwoot).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implementa o lado Captain da integração Nível 2 (Hermes como cérebro).
Ativação por inbox via env var CAPTAIN_HERMES_INBOX_IDS — inboxes não
listadas seguem usando o orquestrador interno do Captain (Daniela_Reservas
etc) sem mudança alguma. Princípio "só adiciona, não retira".
Componentes:
- enterprise/app/services/captain/hermes.rb
Módulo helper de config (env vars, URLs, secrets per-inbox).
- enterprise/app/services/captain/hermes/client.rb
Service que monta payload (msg + contexto da conversa/inbox/contato) e
faz POST autenticado via HMAC-SHA256 (X-Hub-Signature-256) no webhook
do Hermes Agent (porta 8644). DispatchError em falha de rede/HTTP.
- enterprise/app/jobs/captain/hermes/outgoing_job.rb
Wrapper Sidekiq do Client. Retry 3x em DispatchError.
- app/controllers/webhooks/captain/hermes_callback_controller.rb
Recebe callback do plugin captain-http-callback do Hermes. Valida HMAC
se CAPTAIN_HERMES_CALLBACK_SECRET setado, identifica conversation pela
última pending da inbox (janela 5min) e cria mensagem outgoing.
- config/routes.rb
Rota POST /webhooks/captain/hermes_callback (fora de /api/v1/accounts).
- enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
Branch novo no schedule_captain_response: se Hermes habilitado pra inbox,
dispara HermesOutgoingJob; senão, fluxo Captain interno como antes.
Env vars (todas opcionais; sem set = Hermes desabilitado em todas inboxes):
- CAPTAIN_HERMES_INBOX_IDS (CSV de inbox.id)
- CAPTAIN_HERMES_WEBHOOK_BASE_URL (default http://172.17.0.1:8644)
- CAPTAIN_HERMES_CALLBACK_SECRET (HMAC validar callbacks de entrada)
- CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_<id> (HMAC assinar saídas)
Limitação: identificação da conversation no callback usa última pending
da inbox dentro de 5min. OK pra PoC com 1 conversa de teste por vez. Em
produção, melhorar mapeando delivery_id ↔ conversation_id em Redis.
Próximo passo manual (admin VPS): criar subscription no Hermes:
hermes webhook subscribe captain-inbox-1 \\
--prompt 'Cliente disse: {message}. Responda como Daniela ...' \\
--deliver http_callback \\
--deliver-chat-id 'http://CAPTAIN_HOST/webhooks/captain/hermes_callback?inbox_id=1'
Depois set CAPTAIN_HERMES_INBOX_IDS=1 + CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_1
no stack do Captain e testar pela inbox Angelina.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Acrescenta valor 'openai_hermes_gateway' ao CAPTAIN_LLM_PROVIDER, sem mexer
nas opções existentes (openai_api e openai_codex_oauth continuam intactos).
Quando ativado, o Captain chama o Hermes Agent rodando em modo gateway HTTP
local (CAPTAIN_HERMES_GATEWAY_URL, default http://host.docker.internal:9877).
O Hermes faz o roteamento multi-modelo (Codex/Anthropic/Gemini) usando o
OAuth dele em ~/.hermes/auth.json — o Captain não precisa fazer OAuth direto.
Configs novas em installation_config.yml:
- CAPTAIN_HERMES_GATEWAY_URL — URL do gateway (default host.docker.internal:9877)
- CAPTAIN_HERMES_GATEWAY_MODEL — modelo no formato <provider>/<model>
- CAPTAIN_HERMES_GATEWAY_API_KEY — opcional, dummy se gateway local não exige
Embeddings e Files API continuam apontando pra OpenAI tradicional via
legacy_openai_settings — Hermes Gateway não expõe esses endpoints.
Specs cobrem: dummy key, custom api_key override, custom model, defaults,
trailing slash strip, light_model por provider, hermes_gateway? predicate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pequenos ajustes em Captain::Unit (app + enterprise), migration de seed
inicial dos prompts Jasmine/Daniela, schema regenerado, e atualização do
README de seed_prompts pra refletir o estado atual dos modelos.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Anon key não tinha permissão de INSERT em reserva_hotel.unidades — RLS
exige authenticated + tenant_member, não atendido. POST direto falhava
sem feedback útil.
Solução: RPC reserva_hotel.provision_unidade(...) com SECURITY DEFINER
que faz upsert idempotente bypassando RLS, com validações de tenant +
marca dentro da função. EXECUTE granted to anon.
Service agora chama /rpc/provision_unidade em vez de POST /unidades.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hook after_commit on:create no Captain::Unit dispara
ProvisionUnitInSupabaseJob, que upserta a unit em reserva_hotel.unidades
via Supabase REST (UNIQUE on tenant_id+chatwoot_unit_id) e grava IDs no
Captain::Unit (supabase_unit_id, supabase_tenant_id, supabase_marca_id).
Sem isso, criar nova unidade no painel Pix não habilitava roleta — a row
no Supabase ficava ausente e OfferService caía em "tenant não resolvido".
Inclui rake captain:reprovision_unit_in_supabase[id] + provision_all
pra reconciliação manual e migration retroativa.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supabase REST manda response gzip por default. Faraday default não tem
middleware de descompressão, então JSON.parse(gzip_bytes) explodia em
JSON::ParserError, capturado pelo rescue → array vazio silencioso.
OfferService#fetch_unidade retornava [] mesmo com row presente,
caindo em "Sem unidade vinculada — tenant não resolvido".
Fix em offer_service, weekly_report_service, notify_revealed_job e
notify_revealed_scheduler_job. (get_reserva_preco_tool já tinha o fix.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CaptainInbox.belongs_to :captain_unit (não existe método .unit).
OfferService quebrava antes de criar draw — roleta nunca disparava
em prod mesmo após pix confirmado. Mesmo bug em get_reserva_preco_tool
e create_reservation_intent_tool (silenciosamente caíam em fallback nil).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dois bugs que faziam o cohort retornar 500 e a página de Retenção mostrar
"Falha ao carregar cohort":
1. `Captain::AssistantPolicy` não tinha `cohort?` → Pundit batia em
NoMethodError no `check_authorization`. Adicionado como leitura pública
da assistente, igual `show?`/`playground?`.
2. `RetentionCohortService#cohort_activity` chamava `exec_query(sql, name, [@account.id])`
passando array de valores onde a API espera bind objects. A SQL ainda
interpolava `account_id = $1` direto na string (sem placeholder ligado).
Migrado pra `ActiveRecord::Base.sanitize_sql_array` com `?`, igual ao
resto da base. Mantém parametrização e remove acoplamento com posicional.
Validado em prod via hot-patch (USR2): GET retention/cohort agora 200 com
3 cohorts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sem isso o Codex devolvia texto puro e o reaction_emoji do JSON
estruturado nunca chegava ao ResponseBuilderJob — quebrava a
ferramenta de reagir mensagens com emoji.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adiciona sobrescrita de modelo no proxy. Motivação: o RubyLLM valida o modelo
contra um catalog interno antes de enviar a call. Modelos novos (gpt-5.4,
gpt-5.3-codex) ainda não estão nesse catalog e geram RubyLLM::ModelNotFoundError.
Com CAPTAIN_CODEX_MODEL_OVERRIDE definida, o Translator substitui o modelo do
body antes de enviar ao Codex. Captain continua passando um modelo reconhecido
(gpt-5.2), mas o Codex recebe o modelo real (gpt-5.4).
Exemplo:
InstallationConfig.find_or_initialize_by(name: "CAPTAIN_CODEX_MODEL_OVERRIDE")
.update!(value: "gpt-5.4", locked: false)
Validado: curl → proxy → Codex retorna "model":"gpt-5.4" no response.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve duas camadas de problema identificadas em teste end-to-end:
1. Embeddings falhavam com HTTP 404 (/codex/v1/embeddings não existe).
Solução: Captain::Llm::EmbeddingService sempre usa OpenAI tradicional
via Llm::Config.with_api_key(legacy_settings). ProviderConfig expõe
legacy_openai_settings pra isso.
2. Servidor Codex ocasionalmente responde com response.failed +
code=server_error (instabilidade transitória). Client agora retenta
até 2x com backoff exponencial (0.5s, 1.5s) em erros retryable:
HTTP 5xx, server_error no response.failed, ou stream inacabado.
Outras correções nesta etapa:
- Scenario#agent_model: em modo Codex, ignora CAPTAIN_OPEN_AI_MODEL_SCENARIO
(que pode ter gpt-4o legado) e usa ProviderConfig.model.
- ExtractionService/ContradictionCheckerService/TranslateQueryService:
trocam constantes hardcoded gpt-4o-mini/gpt-4.1-nano por
ProviderConfig.light_model (respeitando o provider ativo).
- ProviderConfig.DEFAULT_CODEX_MODEL agora é gpt-5.2 (reconhecido pelo
RubyLLM; gpt-5.4 não está no catalog do gem).
Validado ponta-a-ponta: WhatsApp → Chatwoot → Jasmine → handoff Daniela
→ faq_lookup com embedding OK → resposta com preços corretos.
Docs em docs/captain-codex-oauth.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adiciona o toggle openai_api | openai_codex_oauth. Por padrão mantém
comportamento legado (API key OpenAI tradicional). Quando mudamos pra
openai_codex_oauth, os clientes (RubyLLM + Agents gem) passam a
apontar para o proxy interno em http://localhost:3000/codex,
configurável via CAPTAIN_CODEX_PROXY_URL.
- Captain::Llm::ProviderConfig: single source of truth de api_key,
api_base e model, baseado em CAPTAIN_LLM_PROVIDER
- config/initializers/ai_agents.rb refatorado
- lib/llm/config.rb refatorado
- 8 specs do ProviderConfig passando
- Fallback seguro: api_key dummy ('codex-oauth') quando usando proxy
(o proxy ignora Authorization e usa OAuth interno)
NÃO mexe no Llm::LegacyBaseOpenAiService (PDF/Files API). Esse
continua sempre na API tradicional porque o endpoint Codex não
expõe Files API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex endpoint retorna HTTP 400 "Instructions are required" quando o
campo vem ausente. Agora sempre incluímos o campo — string com espaço
quando não há system message no request.
Validado end-to-end: curl → /codex/v1/chat/completions → proxy traduz
→ Codex devolve streaming SSE → proxy agrega → JSON Chat Completions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exposes two JSON endpoints under /api/v1/accounts/:id/captain/reports:
- GET /retention — aggregate KPIs (active/recurring/sleeping/at-risk/
churned, new vs returned in period, Pix generated/paid/conversion,
retention rates at 30d and 90d)
- GET /retention/cohort — monthly cohort matrix, 12 months lookback,
12 months of offset. Each cell is % of the cohort that interacted in
month M+N. SQL-aggregated with DATE_TRUNC + DISTINCT so it is a
single query even on large histories.
Lays the data + job foundation for tracking customer interactions,
recurrence, and Pix conversion on Contact. Design decisions negotiated
with Rodrigo (see docs to come):
Rules:
- Gap of 30h from last message defines separate interactions
- Qualified interaction = >=2 customer msgs + >=2 attendant msgs,
both with textual content (>= 2 letters)
- One-shot consultation = >=1+1 but below the qualified threshold
(tracked as secondary KPI)
- Excludes contacts labeled `equipe_interna`
- is_recurring = interactions_count >= 2
- pix_generated_count counts all PixCharges; reservations_paid_count
only counts those with status = paid
Surface area:
- Migration adds denormalized stats to contacts + indexes for fast filtering
- Captain::ContactStats::InteractionCalculatorService computes the stats
for a single contact (pure, no persistence)
- Captain::Retention::RecalculateContactStatsJob persists them for one
contact (idempotent)
- Captain::Retention::RecalculateAllContactStatsJob runs daily at 3am BRT,
enqueues per-contact jobs for everyone active in the last 120 days
- Event-driven refresh: CaptainListener#conversation_resolved enqueues
recalc; Captain::PixCharge after_create/after_update enqueues recalc
on status change
No UI yet — that's the next layer.
Previous commit made the extractor reject any reservation-shaped fact
without a literal payment confirmation in the conversation. That
killed the useful middle ground: a customer who requests a Pix and
hasn't paid yet is still a concrete signal worth remembering (for
follow-up, interest mapping, CRM). We were going from "hallucinated
reservation" to "nothing remembered".
Add the intermediate pattern:
- Payment confirmed → "Reservou X para Y em DD/MM/AAAA"
- Pix generated, no payment yet → "Solicitou Pix para X em DD/MM/AAAA, aguardando pagamento"
- Just a price quote → nothing
The "aguardando pagamento" suffix is required so the downstream recall
never confuses it with a closed reservation.
Three UX bugs from staging testing:
1. Duplicate Pix link in WhatsApp — the tool's formatted_message
embedded the full link + instructions, so the LLM copied it into
its own response on top of the dedicated link message sent by
dispatch_direct_link_message. The tool now returns a short
summary with no URL; dispatch is the single source of the link.
2. "Reserva confirmada!" sent before payment — the scenario prompt
used the word "confirmação" loosely, which the LLM read as the
reservation being closed. Now the prompt forces "pré-reserva /
aguardando pagamento" until the Pix is actually paid, and the
dispatched link message explains that the reservation is only
secured after payment clears.
3. Memory extraction wrote "Reservou Hidromassagem para pernoite
em 22/04/2026" when the customer only received a Pix link and
replied "obrigado". Tightened the extraction prompt so
padrao_comportamental of a reservation requires a literal
payment confirmation — Pix generated alone no longer qualifies.
When Inter integration fails ("Unit not configured for Pix", missing
certs, etc.), the tool was returning success=true with the error
message as formatted_message. The LLM interpreted that as success and
hallucinated "Pix generated" to the customer — and never triggered the
generate_reservation_link fallback.
Switch the rescue path from tool_feedback_response (success=true) to
error_response (success=false) so the Daniela scenario correctly falls
back to the reservation-link tool as documented in her prompt.