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.
The orchestrator prompt (Jasmine) and scenario instruction (Daniela)
live in the database. When we merge this branch to main and deploy to
production, the prod DB will keep its OLD prompts — the new ones would
only exist in staging. That defeats the point of merging.
Fix: commit the current staging prompts as .md files under
db/seed_prompts/ and add a data migration that syncs them into the DB
on deploy. Idempotent (no-ops when content already matches).
From now on, prompt changes follow the same workflow as code: edit the
.md file, migration resyncs on deploy. The DB row becomes a mirror of
the file, not the source of truth.
- RetentionSummaryBadge in the "Previous conversations" sidebar:
tiered status (First contact / Active / Recurring / Sleeping /
At risk / Inactive) + counts of interactions, one-shots, Pix.
- Retention tab in Captain Reports: KpiCards, FlowCard, CohortMatrix
(12x13 heatmap with CSV export).
- Five new filters on the contacts list: recurring, last interaction,
days since, interactions count, reservations paid.
- Full pt_BR + en i18n under CAPTAIN_REPORTS.RETENTION.*
- Spec for InteractionCalculatorService covering gap behavior,
one-shot classification, internal-label exclusion, multi-conversation
grouping across the 30h window.
- Docs: docs/captain-retention-indicators.md with business rules,
column reference, endpoint shape, and backup SQL queries.
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.
Root cause of the staging test failure:
- Tool asked for CPF then name separately, two back-and-forth turns.
- When the user replied with just "Rodrigo Borba Machado" (no "nome:"
prefix), NAME_WITH_LABEL_REGEX didn't match, so the contact.name
stayed as the emoji "😅‼️". The tool kept returning missing_name and
the LLM eventually hallucinated success without another generate_pix
call.
Changes:
- missing_identity_response combines nome + CPF into one prompt when
both are missing.
- extract_name_from_qa_pattern finds the last outgoing message asking
for "nome completo" and takes the next incoming message as the name
candidate.
- extract_name_run_from_text pulls the leading alphabetic run from the
message so "Rodrigo Borba Machado, 00251938131" parses the name
correctly alongside the CPF.
Consolida o trabalho desta branch de abril/2026 em um bloco pronto pra
testar em staging antes do merge pra main.
## Correções de memória semântica
- ExtractionService: Princípio Zero + Regra de Ouro (ação consumada vs intenção).
- Cenário Daniela_Reservas: Passo 0 de classificação (consulta/intenção/fora).
## Roleta da Sorte (end-to-end)
- Schema Supabase + 7 RPCs atômicas (server-side, idempotentes).
- Services: Offer, Redeem, WeeklyReport.
- Jobs: OfferRouletteJob (hook em ConfirmationService após Pix pago),
NotifyRevealed + Scheduler de fallback.
- Tool manual GenerateRoletaLinkTool + endpoint público /roleta/notify.
- Dashboard /captain/roleta com Resgate + Relatório + anomaly detection.
## Cenário Reclamacoes_Ouvidoria
- Triagem P1-P4, framework LAST, Three-level listening, Self-check.
- Sem compensação material, detecção de cliente frustrado eleva prioridade.
## Analytics
- Funil de conversão /captain/funnel: 5 etapas via regex, zero LLM.
- Detector de churn via ChurnOutreach* (cron dias úteis 10h-17h BRT).
## Trabalho pré-existente incluído
- Captain Executive Reports (ceo_digest, mattermost_delivery).
- get_reserva_preco_tool, Lifecycle ajustes, Reservations UI polimentos.
## Outros
- .gitignore: patterns pra credenciais.
- Migrations de scenarios idempotentes.
- i18n completa pt_BR+en pra roleta/funnel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Observed incident 2026-04-19 14:34: ResponseBuilderJob sat 156s
'Performing' in Sidekiq without ever emitting [Captain V2] Agent result,
while the client waited on WhatsApp. The runner.run() call never
returned — presumably an HTTP hang on the LLM side (OpenAI slow,
network flake, or retry storm inside ruby-llm).
Post-hoc protections (tool_loop_detected, max_turns) can't fire because
they only inspect result after run() returns. Adding a 45s hard timeout
on the run() block guarantees we bail out, trigger bot_handoff, and
respond to the client instead of hanging forever.
Rescue Timeout::Error separately so the log message is specific and
the user-facing message says "demorou mais do que o esperado".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Problema observado em teste real 2026-04-19 11:24:
usuário forneceu suíte+data+hora pra Daniela. Em vez de chamar
generate_pix, Daniela chamou handoff_to_jasmine. Jasmine respondeu
"Vou te transferir pra Daniela..." — mentira, a conversa ficou
parada com a Jasmine.
Sequência dentro de UM único run:
jasmine.handoff_to_daniela_reservas_agent
-> daniela.handoff_to_jasmine (!)
-> jasmine responde "vou te transferir..."
O prompt da Daniela tem "🚨 NUNCA FAÇA HANDOFF DE VOLTA PRA JASMINE"
mas o LLM ignora a proibição quando a ferramenta está registrada.
A única solução robusta é não registrar a ferramenta.
Historicamente tivemos medo de remover a back-edge porque sem ela
a Daniela (quando confusa) ficava em loop chamando faq_lookup —
incidente que queimou créditos reais. Esse medo não vale mais:
commit f3f8a8d5c adicionou TOOL_LOOP_THRESHOLD=3 +
MAX_TURNS_PER_MESSAGE=15 que disparam bot_handoff automático em
qualquer loop de tool. A proteção contra runaway existe por
OUTRA via agora, então podemos remover a back-edge com segurança.
Efeito esperado:
- scenario termina a resposta sozinho (sem ping-pong)
- scenario confuso/em loop -> rate limit corta -> humano recebe
Memory: atualizado feedback_never_touch_captain_without_safety_caps.md
refletindo a nova invariante.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Três camadas de proteção contra runaway token burn no AgentRunnerService:
1. MAX_TURNS_PER_MESSAGE = 15
Cap dentro de uma única chamada run(). Já estava aplicado;
agora extraído como constante nomeada.
2. MAX_TURNS_PER_CONVERSATION = 30
Cap ao longo da vida da conversa. Contador em
conversation.custom_attributes['captain_turn_count']. Ao atingir,
dispara bot_handoff automático e responde com mensagem de
transferência pra humano.
3. TOOL_LOOP_THRESHOLD = 3
Detecta a mesma (tool_name, args) invocada 3+ vezes no resultado
de um único run (sintoma do loop faq_lookup que queimou tokens
em 2026-04-19). Ao detectar: dispara bot_handoff e aborta o turno.
trigger_bot_handoff! aciona conversation.bot_handoff! quando
disponível, removendo a conversa do pipeline automático.
Motivação: dois incidentes reais de queima de crédito OpenAI em
2026-04-19. Ver memory/feedback_never_touch_captain_without_safety_caps.md
pras invariantes completas.
Tests atualizados: mock_result agora stuba :messages (usado pelo
novo tool_loop_detected?) e max_turns esperado é 15.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>