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>
Bugs originais (tabela Reports → Inbox → unidade específica → Novas × Retorno):
1. Backend recebia sempre inbox_id=1 — useFunctionGetter('inboxes/getInboxById', route.params.id)
passava string crua, não Ref reativa, então o getter ficava travado no ID inicial
2. UX: tab não mostrava qual unidade estava sendo filtrada
Correções:
- InboxReportsShow.vue: passa inboxIdParam computed pra useFunctionGetter (reativo agora)
- Passa inbox.name como prop pro InboxLeadsReport
- InboxLeadsReport.vue: header com título + label "Caixa de entrada: <nome>" no topo
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mede por inbox/período: leads novos (1ª conversa do contato em qualquer
inbox da rede), retorno (conversa anterior resolved há >24h) e outras
(conversa anterior open ou resolved <24h). Categorias somadas batem com
o conversations_count nativo do report — bucket "outras" garante o
fechamento.
- Novo builder V2::Reports::InboxLeadsSummaryBuilder com CTE única
- Endpoint GET /api/v2/accounts/:id/reports/inbox_leads_summary
- Tabs no InboxReportsShow (Visão Geral | Novas × Retorno)
- Componente InboxLeadsReport com 3 metric cards + barras empilhadas
- API client + Pinia (state/getters/actions/mutations)
- i18n en + pt_BR
- RSpec do builder cobrindo classificação e isolamento por inbox
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 1001_noites/precos_qnn01.md → precos_marca_1001_noites.md
- express/precos_express_al.md → precos_marca_express.md
- prime/precos_marca_prime.md (já estava OK)
Conteúdo reorganizado pra deixar explícito:
- Preços são iguais entre unidades da mesma marca (per feedback_prompt_scope_by_brand)
- Variação por unidade é só na DISPONIBILIDADE de categorias (ex: Pole Dance só em algumas 1001 Noites; Qnn01 não tem)
- Lista unidades cadastradas vs pendentes no Captain
- Aponta sync espelhado no Obsidian (Grupo 1001 Innova/Tabelas de Preço/)
README.md atualizado refletindo nova estrutura.
Não muda comportamento da Jasmine — só doc.
Cria docs/captain/historico_fixes.md com registro estruturado (YAML) das
correções aplicadas nos prompts/infra do Captain. Pré-popula com os 2 fixes
de hoje (v99 - preços Express/Qnn01 + sync automático; v100 - comportamento
humano + valores curto).
Pra que serve:
- Auditoria humana (saber o que mudou quando e por quê)
- Defesa contra Captain Reviewer redescobrir bug já corrigido (comparar
data da conversa-fonte com data do fix correspondente)
- Base pra eventual Nível 3 do Reviewer (filtrar conversas anteriores ao
último commit em _modelos/ via git log)
Não muda comportamento da Jasmine — é doc + infra passiva.
Bug raiz observado em conversa real (Rayssa Lorranny / PrimeAL, 25/04 12:44):
- Jasmine alucinou "não tenho a tabela exata por horas aqui neste momento"
- Pediu "qual dia?" quando cliente disse só "Valores" (deveria mostrar tabela completa)
- Mencionou "tabela qui-dom/feriado" entre parênteses na resposta (entrega que é IA)
3 mudanças aplicadas nos 4 daniela_reservas (PrimeAL, PrimeVL, Qnn01, Express):
1. Nova seção "🤖➡️👤 SE COMPORTE COMO HUMANA" no topo, com lista de frases
proibidas (todas que entregam IA) e exemplos de substituição humana
2. Nova "🚨 REGRA DE OURO — VALORES CURTO" antes de B): cliente disse só
"valor"/"valores"/"preço" sem especificar → manda tabela completa direto,
nunca pergunta dia/suíte primeiro
3. 3 proibições novas em "🚫 Proibições": dizer que não tem tabela,
mencionar "tabela qui-dom/seg-qua" na resposta, responder pergunta com pergunta
Inclui também o questionario_nova_unidade.md já postado no Mattermost top-team.
Refs: análise da conversa Rayssa Lorranny / Operacoes 2026-04-25
- Express: adiciona Singles/Família/Singles Duplo, Master qui-dom 5h, diárias preço único
- Qnn01: renomeia Master→Luxo, remove Pole Dance e 12h, Hidromassagem preço único
- PrimeAL/PrimeVL: estrutura seg-qua + qui-dom, Pernoite Especial Prime, hora excedente
- Adiciona docs/precos/ com tabelas oficiais por marca pra consulta humana
- Implementa rake task captain:sync_prompts lendo de _modelos/
- Liga a task no chatwoot_prepare → sync automático em todo deploy
Refs ROD-14 (Captain Review 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wuzapi rejeita data URI com mime real (application/pdf) e exige
'data:application/octet-stream;base64,'. O tipo é inferido do FileName.
Imagens continuam usando o mime original.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wuzapi retornou 400 'missing Document in Payload'. O campo correto pra
PDF é Document + FileName. Mantém Body/Filename pra fallback em versões
antigas.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wuzapi (asternic) atual usa /chat/send/document pra arquivos. O endpoint
/chat/send/file não existe (404), então PDF nunca chegava — propagado
como ECONNRESET pelo proxy. Mantém os antigos como fallback.
Também trata Errno::ECONNRESET como ConnectionError no http handler pra
ativar a cadeia de fallback se voltar a acontecer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ActionController::Parameters não tem .any?; chamar sem to_h quebra
com NoMethodError (500) ao salvar agente. Bug introduzido em b69fa21e5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Admin configura em Account Settings → Agents → Editar quais inboxes
disparam o banner agressivo (reopened + inatividade) pra cada agente.
- user.ui_settings.aggressive_alert_inbox_ids: null (todas) | [] (nenhuma) | [1,2,3]
- Filtro aplicado no actionCable.feedInactivityTracker, maybeTriggerAggressiveAlert
e no hydrate do AggressiveConversationBanner
- Backend aceita ui_settings no agents#update e serializa em _agent.json.jbuilder
- UI no EditAgent com toggle "todas inboxes" + multiselect
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
O hidrate do inactivityAlertTracker lia conv.messages pra achar a última
mensagem do cliente. Mas o serializer da listagem só expõe 1 mensagem nesse
array (e pode ser uma activity, que é filtrada), então conversas em 'open'
com cliente esperando resposta não entravam no tracker e o banner de 5/15/28
minutos nunca disparava.
Fix: findLastNonActivityMessage agora usa conv.last_non_activity_message
primeiro (campo dedicado do payload, já pré-filtrado pelo backend) e só
cai em conv.messages como fallback.
Também adicionada flag de debug opt-in em window.__AGGRESSIVE_DEBUG__ pra
facilitar diagnóstico futuro do tracker direto do DevTools.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug reportado em 2026-04-24: só o banner vermelho status→open
funcionava. 5/15/28min nunca disparavam.
Causa: o tracker só era alimentado via websocket ao vivo
(message.created). Se a msg do cliente chegou ANTES da aba carregar
(ou depois de F5), o tracker ficava vazio, setInterval nunca começava,
thresholds nunca disparavam.
Fix:
- Nova função `hydrateFromConversations(convs)` no tracker. Varre
conversas em 'open', pega a última msg não-activity, se for de
Contact registra com timestamp REAL (msg.created_at), não Date.now().
Isso fecha o gap de tempo: se o cliente falou 7min atrás, o YELLOW
já dispara na hora.
- AggressiveConversationBanner.vue tem agora `watch: allConversations`
chamando hydrate toda vez que a lista muda (boot + F5 + navegação).
- parseCreatedAt() suporta Unix seconds + ISO.
- findLastNonActivityMessage() ignora mensagens de sistema.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A regra de 24h é da Meta Cloud API. Providers que conectam via
WhatsApp Web (baileys, zapi — já isentos; wuzapi, evolution — agora
isentos) permitem mensagem livre a qualquer momento.
Antes: agente via "Você só pode responder a esta conversa usando um
modelo de mensagem devido a Restrições de janela de mensagem de 24 horas"
e ficava bloqueado de digitar.
Agora: MessageWindowService.messaging_window retorna nil pros 4
providers Web, o que faz can_reply? retornar true incondicionalmente.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Banner agressivo passa de uma notificação só ("status→open") pra
um sistema de escalada baseado em inatividade quando o cliente é
o último a falar.
Níveis:
- 5 min sem resposta → AMARELO, sem som
- 15 min sem resposta → LARANJA, beep 1x + notificação do SO
- 28 min sem resposta → VERMELHO pulsante + som em loop infinito
- status→open (reabertura) → VERMELHO imediato
Por conversa, o banner mostra um item com nome do contato, inbox
e contexto ("reabriu agora" / "15 min sem resposta"). Headline
grande e explicação clara sobre como limpa.
Comportamento do × dismiss:
- Antes: apagava o alerta de vez. Agente podia "fingir que viu".
- Agora: esconde temporariamente. Volta quando escalar (próximo
threshold) ou nova mensagem. A única forma de LIMPAR de vez é
responder o cliente (tracker detecta msg outgoing do User ou
AgentBot e chama dismissForReply).
Permissões:
- account.settings.aggressive_alert_enabled (master switch admin)
- user.ui_settings.aggressive_alert_enabled (toggle do próprio agente)
- Default true pros dois; um false em qualquer bloqueia alertas.
Settings UI:
- Conta → General: novo card "Alerta agressivo (master switch)"
- Perfil do usuário: novo card "Receber alertas agressivos"
Arquivos:
- helper/aggressiveAlert.js: multi-level state, hide vs dismiss-for-reply
- helper/inactivityAlertTracker.js: timer único, thresholds declarativos
- helper/actionCable.js: hook em onMessageCreated (feed tracker) +
isAggressiveAlertEnabled() + limpa tracker em status_changed != open
- components/app/AggressiveConversationBanner.vue: variantes de cor,
headline grande, explanation, × temp-hide
- account.rb + accounts_controller.rb: store_accessor + permitted
- settings UI components (account + profile): switches auto-persist
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Race condition: quando o próprio usuário reabre a conversa, o dispatch
HTTP (toggleStatus) comita CHANGE_CONVERSATION_STATUS no Vuex ANTES do
broadcast actionCable chegar. Aí o check previousStatus === 'open'
bloqueava o alerta porque o store já estava em status=open.
Broadcast conversation.status_changed (app/listeners/action_cable_listener.rb
linha 103) só é emitido em transição real. Conversa nova entra via
onConversationCreated, não por status_changed. Não precisa do lookup.
Removido: getConversationById + guarda early-return por previousStatus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Banner persistente + som em loop + OS notification + title flash
+ vibração mobile quando conversa transiciona pra 'open' vindo de
pending/snoozed/resolved. Exige interação pra dismissar — atendente
não perde evento de reabertura.
- AggressiveConversationBanner.vue: banner full-width no topo,
dismissable, mostra nome do contato + inbox + status anterior
- aggressiveAlert.js: manager do som (loop infinito), title flash
(intervalo 1s), Notification API (requireInteraction: true),
navigator.vibrate (padrão 500-200-500-200-500)
- actionCable.onStatusChange: detecta transição pra 'open' e dispara
trigger via BUS_EVENTS (só se status anterior ≠ open, pra não
alertar conversa nova criada já em open)
- i18n pt_BR + en: chaves de notificação (title/body/dismiss)
- busEvents: AGGRESSIVE_ALERT_TRIGGER + AGGRESSIVE_ALERT_DISMISS
Camada 1 da feature. Camada 2 (escalation SMS/WhatsApp se não
dismissar em X segundos) fica pra outro PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Gerados usando o modelo validado do PrimeAL como base, adaptando:
- Nome do hotel, suítes, links (WhatsApp/Maps), saudação por unidade
- Tabela de preços específica de cada unidade
- Lista de outras unidades (exclui a própria, inclui as outras 8)
- Observação de atendimento exclusivo por unidade
Particularidades por unidade:
- Qnn01: 4 suítes (Standard/Master/Pole Dance/Hidromassagem), tabela seg-qua + qui-dom, tem 12h
- PrimeVL: 3 suítes (Stilo/Alexa/Hidromassagem), tabela seg-qua + qui-dom-feriado, tem 1h e hora excedente
- Express: 2 suítes (Standard/Master), tabela seg-qua + qui-dom, redireciona pra Prime quando cliente pede hidro
reclamacoes_ouvidoria.md é idêntico nas 4 unidades (framework LAST é universal).
Testado em staging: aplicado nos 3 assistants respectivos, scenarios novos criados (outras_unidades + Reclamacoes_Ouvidoria), FAQs de blocos de prompt deletados, FAQs de preço duplicados removidos. Aguardando validação via WhatsApp real.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Renomeia _prod_snapshot → _producao_atual (refletindo melhor o papel: snapshot do que está rodando hoje em prod, só leitura)
- Renomeia _staging_current → _modelos (modelos aperfeiçoados que vão virar nova prod)
- Todos os arquivos em _modelos/ agora usam o prefixo jasmine_<slug>__ (ex: jasmine_primeal.md), seguindo a mesma convenção já usada em _producao_atual/
- Atualiza README com a nova convenção e checklist de validação por unidade
Isso prepara a estrutura pra adicionar modelos das outras 3 unidades (Qnn01, PrimeVL, Express).
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>
PoC validado com conta ChatGPT Plus e client_id do Hermes. Device flow
OAuth funciona, gera access_token + refresh_token auto-refresh. Chat e
function calling funcionaram em gpt-5.4, gpt-5.4-mini, gpt-5.2 e
gpt-5.3-codex.
Descobertas pro adapter final:
- Endpoint: /responses (não /chat/completions)
- Streaming obrigatório (stream: true)
- store: false obrigatório
- Sem temperature/top_p (modelos reasoning)
- input[] no lugar de messages[]
- instructions top-level no lugar de system role
- Tools sem wrapping function: {}
- Output via events response.output_item.done (não response.completed)
Pasta scripts/captain_codex_poc/ está excluída do Rubocop (scripts
standalone, não rodam em contexto Rails).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reorganized db/seed_prompts/ into three clear bins:
_prod_snapshot/ — 16 prompts pulled from iachat_production
(4 Jasmines + 12 scenarios). Read-only baseline.
_staging_current/ — 6 prompts active in iachat-v2 right now
(Jasmine + 5 scenarios, including
outras_unidades and Reclamacoes_Ouvidoria
which were created on this branch).
target/ — empty for now. Source of truth: the seed
migration only writes from here. Files we
review and approve land here, then deploy
pushes them to prod.
Updated the seed migration to walk target/ and to support both
generic scenarios (apply to every unit) and unit-scoped scenarios
(file prefixed with assistant slug, only that unit). Empty files
are skipped — useful for staged rollouts.
This guarantees no prompt ships to prod by accident: only what
ends up in target/ is applied.