Bug: find_or_initialize_by(name: ...) batia em captain_interno existente
com mesmo nome (ex: 'Juliana') e SOBRESCREVIA engine='hermes'. Resultado:
Juliana captain_interno virou Hermes, atendimento legado quebrou.
Fix: chave passa a ser (account_id, hermes_profile_name=slug). Auto-sufixa
nome com ' · Hermes' se colidir com captain_interno do mesmo nome (a não
ser que já tenha 'Hermes' no nome). Também grava captain_unit_id no
record (relação direta agora que existe a coluna).
Reverti manualmente Juliana id=1 + criei id=10 'Juliana · Hermes' fix in
DB. Future provisionamentos pelo Construtor usam o caminho corrigido.
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>
Bash script em bin/hermes-provision (instalado em /usr/local/bin/ na VPS)
que recebe spec JSON via stdin e provisiona um agente Hermes ponta-a-ponta:
- Valida spec (slug regex, preços 0-5000, períodos/buckets do catálogo)
- Aloca porta livre no range 8650-8699
- Gera HMAC secret via openssl rand
- Cria Captain::Unit (find_or_create), Captain::PricingCategory/Amount,
Captain::Assistant (engine=hermes) via docker exec rails runner
- Copia template profile da Valentina, patcheia config.yaml com porta +
X-Captain-Assistant-Id (parent_id se setado, senão self id)
- Escreve SOUL.md/SKILL.md do spec
- Gera webhook_subscriptions.json com secret
- Cria systemd unit hermes@<slug>.service e enable+start
- rsync profile pra repo de backup git local
- Idempotente: re-rodar com mesmo slug não duplica nada (find_or_create)
- --dry-run valida sem escrever
- --rollback <slug> destrói tudo (DB + systemd + filesystem)
Construtor (Hermes daemon) chama via terminal skill nativa:
echo '<spec>' | /usr/local/bin/hermes-provision
Próximo passo: atualizar SOUL.md/SKILL.md do Construtor pra invocar
o script ao final do fluxo socrático.
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>
Componente Button (components-next/button) provavelmente nao propaga
@click direito ou tem quirk com variant="primary" (nao esta em
VARIANT_OPTIONS que aceita solid|outline|faded|link|ghost). Botao
"Iniciar criação" nao disparava startSession.
Troca por <button> HTML simples com classe start-button estilizada.
Garante que @click vai direto pro handler.
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>
Convencao Chatwoot pra features Captain novas: keys no root level
(CAPTAIN_HERMES_BUILDER, igual a CAPTAIN_ROLETA, CAPTAIN_FUNNEL etc) e
nao como sub-key de CAPTAIN.
Move CAPTAIN.HERMES_BUILDER -> CAPTAIN_HERMES_BUILDER em pt_BR e en.
Atualiza referencias t() no Vue.
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>
Bug reportado por cliente real: "valor extra não é a partir da 3ª pessoa?
Porque aí é para casal, ou seja sempre vai ter duas pessoas".
Cliente está certo. A base do quarto pra casal JÁ INCLUI 2 pessoas; taxa
extra começa na 3ª pessoa, não na 2ª. A documentação anterior (modelo +
skill Hermes + scenario 21 em prod) tinha "a partir da 2ª pessoa", o que
fazia Valentina cobrar extra pra ambos do casal — erro.
Mudanças:
- Texto da regra: "a partir da 3ª pessoa" pra Apartamento/Suítes/Mini
Chalé 45, com nota explícita "base do quarto já inclui 2 pessoas".
- Exemplo de cálculo refeito: 4 pessoas Suíte Master pernoite sex/sáb =
180 + (2 × 45) = R$ 270 (era 180 + 3×45 = 315, errado).
Categorias maiores (Chalé 2 Suítes, Suíte Ouro, Chalé Master 4 Suítes)
mantidas como antes (4ª e 8ª pessoa) — Rodrigo precisa revisar essas
separadamente já que cada uma tem capacidade diferente.
Aplicado em 3 lugares:
- db/seed_prompts/_modelos/scenarios/jasmine_dolce_amore__daniela_reservas.md
- /root/.hermes/profiles/valentina/skills/dolce-amore-reservas/SKILL.md
- DB do Captain prod scenario 21 (via update_columns, +363 chars)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes Agent (cliente MCP) usa `--auth header` que envia
`Authorization: Bearer <token>` — padrão MCP. Antes o Captain MCP server
só aceitava HMAC-SHA256 (X-Hub-Signature-256), incompatível com o que
Hermes manda nativamente.
Agora aceita qualquer um dos 2 modos:
- Bearer token (recomendado, padrão MCP) — Hermes envia automaticamente
- HMAC-SHA256 do body — pra clientes que preferem assinar payload
Ambos validados com ActiveSupport::SecurityUtils.secure_compare contra
o mesmo CAPTAIN_MCP_SECRET. Sem secret = validação desabilitada (PoC/dev).
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>
Hermes (e LLMs default em geral) emitem **negrito** no formato markdown
padrão. WhatsApp usa formato próprio: *negrito* (single asterisk). Sem
conversão, o cliente vê asteriscos literais no WhatsApp, parecendo bug.
Defesa em camadas:
1. SOUL.md da Valentina foi atualizado com regra explícita de formato
WhatsApp (single asterisk pra bold, underscore pra itálico, etc).
2. Este controller faz normalização defensiva no callback recebido do
Hermes: regex `**texto**` -> `*texto*` antes de criar a mensagem
outgoing. Não afeta o resto do conteúdo.
normalize_for_whatsapp() é trivialmente reversível e idempotente
(executar 2x é igual a 1x).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plugin pro Hermes Agent que SUBSTITUI o WebhookAdapter built-in pra
suportar session_chat_id estável derivado de campo no payload.
Por que existe
--------------
O WebhookAdapter built-in monta a chave de sessão como:
session_chat_id = f"webhook:{route}:{delivery_id}"
delivery_id é único por POST → cada msg cria sessão nova no Hermes. OK pra
webhooks one-shot, ERRADO pra integração de chat onde múltiplas mensagens
da mesma conversa precisam compartilhar memória de sessão.
Como funciona
-------------
Quando o caller (Captain) inclui `conversation_id` ou `hermes_session_id`
no payload, o plugin reescreve chat_id pra:
session_chat_id = f"webhook:{route}:session:{conversation_id}"
Mesma conversation_id em múltiplas POSTs → mesma sessão Hermes →
contexto e memória preservados. Sem o campo, fallback ao comportamento
default (session nova por POST). 100% backward-compatible.
Implementação
-------------
- kind: platform — registra com name="webhook" pra substituir built-in
(Hermes prioriza platform_registry sobre código built-in em
gateway/run.py:_create_adapter)
- Herda WebhookAdapter — só override `handle_message` (rewrite chat_id)
e `connect` (recupera gateway_runner via _gateway_runner_ref pq o
plugin path não seta isso explicitamente)
- Outros adapters (HMAC, rate limit, idempotency, parsing, deliver
dispatch) — herdados sem cópia
Validado end-to-end na VPS (profile valentina):
- POST com conversation_id=99999 (msg 1) → session:99999 criada
- POST com conversation_id=99999 (msg 2) → MESMA session reutilizada
- Hermes responde via Codex em ~10s (2 turnos cumulativos)
- http_callback faz POST de volta no Captain (HTTP 200)
- Logs mostram: [captain-webhook] Stable session: ... -> session:99999
Combinado com captain-http-callback, completa o ciclo Captain ↔ Hermes:
Captain manda webhook com conversation_id → Hermes processa em sessão
estável → http_callback POSTa resposta de volta → Captain envia ao
WhatsApp.
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>
Adiciona plugin externo pro Hermes Agent que entrega a resposta do agente
como POST HTTP a uma URL configurável — em vez de empurrar pra plataforma
de mensageria (Telegram, Slack, etc) como o Hermes faz por default.
Por quê:
O Hermes nativamente entrega respostas em plataformas conhecidas. Quando
integramos o Hermes como cérebro de outro backend (Captain / Chatwoot),
precisamos da resposta de volta via HTTP pro backend continuar o fluxo
(mandar pro cliente WhatsApp, atualizar conversa, etc). O Hermes não tem
deliver type "http_callback" built-in, então criamos via API de plugin
oficial deles (kind: platform).
Arquivos:
- plugin.yaml — manifest (kind=platform)
- __init__.py — entrypoint (re-exporta register)
- adapter.py — HttpCallbackAdapter implementando BasePlatformAdapter
- README.md — uso, formato do POST, signing HMAC opcional
Como funciona:
1. Backend (Captain) → POST /webhooks/<rota> no Hermes (entrada)
2. Hermes processa via Codex/Anthropic/Gemini conforme config dele
3. Hermes invoca este plugin (deliver=http_callback)
4. Plugin POSTa resposta na URL configurada via --deliver-chat-id
5. Backend recebe e roteia pro destinatário real
Validado end-to-end no Hermes da VPS com:
- Subscription criada via `hermes webhook subscribe ... --deliver http_callback`
- POST simulando msg do cliente → resposta chegou no servidor de teste
em ~11s (tempo de processamento via subscription Codex)
- Plugin enabled e descoberto via `hermes plugins list`
Próximo passo (separado, em outro PR): cliente Captain (outgoing + incoming
endpoint) que conecta o Captain ao Hermes via este plugin.
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>
Dolce Amore (Valentina, assistant 6):
- Novo assistant principal + 5 cenários (daniela_reservas, disponibilidade_suites,
maria_fotos, outras_unidades, reclamacoes_ouvidoria) com tabela de preços
motel-first (Apartamento, Master, Luxo, Temática, Mini Chalé 45, Chalé 2 Suítes,
Chalé Master 4 Suítes, Suíte Ouro), regras Inter Pix, e tom adequado a motelaria.
- Cross-reference da Dolce nas 4 outras unidades (Express, PrimeAL, PrimeVL, Qnn01)
no scenario outras_unidades, com aviso "use só se cliente perguntar por Natal".
Handoff silencioso PrimeAL + Dolce (Bianca + Valentina):
- Mensagem ÚNICA "Um momento." substitui as antigas frases robotizadas
("vou te encaminhar pra atendente local...").
- Nova regra "ROTEIE PRO CENÁRIO PRIMEIRO": orquestradora deve sempre tentar
rotear pra cenário (handoff_to_daniela_reservas, handoff_to_maria_fotos, etc)
antes de considerar handoff humano.
- Nova regra "NA DÚVIDA, TRANSFERE": após descartar todos os 6 cenários, se a
pergunta não cabe em nenhum deles, handoff silencioso pra humano.
- Proibição explícita de mencionar nomes de cenário (Daniela, Maria) pro cliente.
- maria_fotos: REGRA #0 — se foto pedida não está na galeria (numeração inexistente,
característica fora do mapa, área não-suíte), responde "Um momento." + handoff
em vez de oferecer alternativa.
- daniela_reservas: novo gatilho de handoff "pergunta sobre reserva fora do prompt".
Rollback documentado em docs/captain/rollbacks/2026-05-01_handoff_silencioso.md
com snapshot completo dos prompts ANTES da mudança em prompts_snapshot.json,
permitindo reversão via update_columns.
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>
BotReports agora carrega inboxes no mount e passa navigate-on-entity-filter=false
pro ReportFilters, que ganha prop pra atualizar a URL com os filtros aplicados
sem disparar router.push pra outra rota. Permite compartilhar/recuperar visão
filtrada do BotReports via URL.
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>
Primeira onda do roadmap de indicadores executivos do Grupo Nova. Mede
ADOÇÃO DO CANAL DIGITAL, não a operação total — banner explícito alerta
que reservas fechadas manualmente na recepção ainda não estão capturadas
(Onda 1B vai adicionar marcação manual via botão na conversa).
Backend:
- V2::Reports::ConversionFunnelBuilder — leads (novo/retorno/total),
reservas (criadas != draft, pagas in active/completed/confirmed),
taxas de conversão. Filtro opcional por inbox.
- V2::Reports::InboxBenchmarkingBuilder — uma linha por inbox com
brand_name (via Captain::UnitInbox -> Unit -> Brand)
- Endpoints GET /reports/conversion_funnel e /reports/inbox_benchmarking
- RSpec do ConversionFunnelBuilder
Frontend:
- Rota top-level Reports → Painel Diretoria
- DirectoryDashboard.vue: banner de adoção + filtros + cards + funil + tabela
benchmarking agrupada por marca com variação vs média
- API client getConversionFunnel + getInboxBenchmarking
- i18n EN + PT
Memórias suporte: feedback_metricas_adocao_canal.md + project_painel_diretoria_roadmap.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Engrenagem fechada e nomes que casam com o que a métrica mede:
Cards superiores (taxas):
- "Tempo de resolução" -> "Resolvidas pelo bot %"
Tooltip: bot resolveu sozinho (sem humano via Chatwoot ou WhatsApp) / total
- "Taxa de entrega" -> "Transferidas pra humano %"
Tooltip: agora soma auto (Jasmine chamou) + manual (humano respondeu sem
handoff explícito). Junto com a resolução, fecha ~100%.
Cards de detalhe (segunda linha, contagem absoluta):
- "Resolvidas pelo bot" — quantas o bot fechou sozinho
- "Transferência automática (Jasmine)" — bot_handoff explícito (loop, timeout,
max turns, intent)
- "Tomada manual (agente)" — humano respondeu (UI ou WhatsApp echo) SEM a
Jasmine ter chamado bot_handoff. Era o "bucket fantasma" antes.
Backend:
- BotMetricsBuilder.metrics inclui bot_resolutions_count, auto_handoffs_count,
manual_takeovers_count
- handoff_rate agora é (auto + manual) / total — daí a engrenagem fechar
- manual_takeovers_count: conversas com mensagem outgoing humana
(sender_type='User' OR NULL) MENOS as que tiveram conversation_bot_handoff
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hoje as métricas e séries do BotReports agregam toda a conta — não dá pra ver
"a Jasmine da PrimeAL está errando mais que a do Qnn01". Cada unidade tem
prompt próprio, então um sintoma localizado precisa de medição localizada.
Backend:
- Inbox#has_many :reporting_events (relação inversa que faltava)
- BotMetricsBuilder aceita inbox_id e filtra bot_conversations + base_reporting_events
- bot_metrics endpoint passa inbox_id pelos params permitidos
- count_report_builder já suporta scope=inbox; agora funciona pra
bot_resolutions_count e bot_handoffs_count graças à relação acima
Frontend:
- BotReports.vue: ReportFilters com filter-type='inboxes' e dropdown ativo
- Quando uma inbox é escolhida, requestPayload inclui inboxId/type/id e os
fetches (BotMetrics + ReportContainer) passam o filtro
- API client getBotMetrics aceita inboxId; getBotSummary aceita type+id
- Sem inbox selecionada: comportamento antigo (agregação da conta)
Bonus na rake task de retroativo:
- rebuild_bot_resolved.rake: Message.unscope(:order) pra evitar conflito
PG::InvalidColumnReference (DISTINCT + ORDER BY default scope)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug do BotReports descoberto pelo Rodrigo:
- A regra de "conversation_bot_resolved" só desqualificava conversas com
outgoing.sender_type='User' (atendente respondendo pelo Chatwoot UI)
- Mas mensagem outgoing vinda do webhook WhatsApp com IsFromMe=true (atendente
respondeu direto pelo celular do hotel) é gravada com sender=nil
- Resultado: a Jasmine ganhava crédito mesmo quando humano respondia fora
do Chatwoot. Taxa de resolução pelo bot inflada.
Fix prospectivo:
- ReportingEventListener#create_bot_resolved_event agora desqualifica via
human_outgoing_messages? (sender_type='User' OU sender_type IS NULL)
- Captain::Assistant (a Jasmine) usa sender_type='Captain::Assistant' e segue
fora do filtro, como antes
- Spec novo cobrindo o caso WhatsApp echo
Retroativo:
- lib/tasks/rebuild_bot_resolved.rake — task idempotente que purga
reporting_events de conversation_bot_resolved gerados sob a regra antiga.
- DRY-RUN por padrão, APPLY=true pra deletar, ACCOUNT_ID pra restringir,
SNAPSHOT_PATH pra trilha de auditoria
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>