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>
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>
- 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.
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>
Inboxes without portal_id were crashing with NoMethodError on save,
blocking landing host creation via UI for any inbox without a portal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces stub Settings.vue with full implementation: anti-spam guard
form (quiet hours, interval, pause-on-reply, opt-out label) and a
collapsible ConciergeUnitCard per unit (inbox selector, persona name,
knowledge base, key-value variables). Adds CONCIERGE_CONFIGURED /
CONCIERGE_NOT_CONFIGURED i18n keys to en + pt_BR.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements Task 13 — replaces the stub History.vue with a real paginated
table filtered by status, and adds DeliveryPreviewModal to show rendered_body.
Also extends i18n keys (TOTAL, PAGINATION, MODAL labels) in en + pt_BR.
Adds CAPTAIN_LIFECYCLE block (en + pt_BR) to captain.json with full
key set for Rules, Wizard, Settings, History and sidebar entry.
Also stages pre-existing uncommitted additions to captain.json from
prior work (KPI, PILLS, QUICK_DATE, CARD, ACTIONS extras for
CAPTAIN_RESERVATIONS) — those were already in the working tree and
belong to the same feature branch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Os stubs de lifecycle criados na task anterior estavam em app/controllers/
causando futura colisão de redefinição de classe quando os controllers reais
forem implementados em enterprise/app/controllers/ (tasks 4-6). Move os 3
stubs para o enterprise path onde vivem todos os controllers Captain.
Routing spec: 7 examples, 0 failures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wires 3 new captain namespace resources (lifecycle_rules, lifecycle_config,
lifecycle_deliveries) and a member action `patch :concierge` on units.
Includes stub controllers (to be expanded in Tasks 4-7) and passing routing spec.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Existem 3 mecanismos de vinculo inbox-unit no fork:
1. captain_inboxes.captain_unit_id (fluxo Captain)
2. captain_unit_inboxes (tabela join pura, usada pela UI nova)
3. captain_units.inbox_id (legado direto)
O serializer lia so o #1 e retornava null nas outras, fazendo a UI
mostrar 'Nenhuma unidade vinculada' mesmo quando havia vinculo via
#2 ou #3. Agora o jbuilder cai em cascata nas 3 fontes.
Cobre ambos os caminhos (generate_pix_tool e PublicReservationsController):
toda reserva criada recebe um after_create_commit que posta uma mensagem
privada na conversa com os detalhes (suite, check-in, valores, ID).
Remove a criacao duplicada do PublicReservationsController.
- Controller grava cpf/ultima_suite/ultima_permanencia/ultima_reserva_em/total_reservas
em contact.custom_attributes (aparece no painel lateral do Chatwoot)
- GenerateReservationLinkTool exige marca/unidade/categoria/permanencia/checkin_at;
retorna erro se Jasmine chamar sem esses dados
Smoke test revelou que o inbox do tipo whatsapp valida source_id com
regex ^\d{1,15}\z. Trocar UUID por telefone em digitos (phone_digits)
e normalizar phone_number pra +phone_digits antes de criar o contato.
- attribution_matcher_service: window 10min → 30min (mais realista para jornada do lead)
- LandingHostsConfig.vue: strip automático de https://, www e trailing slash antes de salvar