feat(retention): foundation for customer retention metrics

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.
This commit is contained in:
Rodribm10 2026-04-22 09:50:23 -03:00
parent 08a06c6528
commit f6488ce2de
8 changed files with 306 additions and 1 deletions

View File

@ -28,6 +28,14 @@ captain_retention_churn_outreach_scheduler_job:
class: 'Captain::Retention::ChurnOutreachSchedulerJob'
queue: scheduled_jobs
# Recalcula stats de retenção (interações, Pix, reservas, recorrência) de
# todos os contatos ativos nos últimos 120 dias. Roda diariamente às 3am BRT
# (6am UTC) — fora do horário comercial pra não competir com atendimento.
captain_retention_recalculate_all_contact_stats_job:
cron: '0 6 * * *'
class: 'Captain::Retention::RecalculateAllContactStatsJob'
queue: scheduled_jobs
# executed every minute.
trigger_scheduled_messages_job:
cron: '* * * * *'

View File

@ -0,0 +1,29 @@
class AddRetentionStatsToContacts < ActiveRecord::Migration[7.1]
# Desnormaliza estatísticas de retenção/recorrência no próprio contato.
# Atualizado por Captain::ContactStats::RecalculateJob (diário) + hooks
# incrementais quando conversa ou PixCharge muda de estado.
#
# Colunas:
# - interactions_count: interações qualificadas (≥2 msg cliente + ≥2 msg Jasmine, gap 30h)
# - one_shot_consultations_count: consultas ≥1+1 que não atingiram o limiar de qualificada
# - first_interaction_at / last_interaction_at: range da presença do cliente
# - pix_generated_count: quantos Pix foram gerados (sinal de intenção)
# - reservations_paid_count: quantos Pix foram efetivamente pagos (reserva real)
# - is_recurring: true se interactions_count >= 2
# - days_since_last_interaction: materializado pra filtros rápidos sem funções em tempo real
def change
add_column :contacts, :interactions_count, :integer, default: 0, null: false
add_column :contacts, :one_shot_consultations_count, :integer, default: 0, null: false
add_column :contacts, :first_interaction_at, :datetime
add_column :contacts, :last_interaction_at, :datetime
add_column :contacts, :pix_generated_count, :integer, default: 0, null: false
add_column :contacts, :reservations_paid_count, :integer, default: 0, null: false
add_column :contacts, :is_recurring, :boolean, default: false, null: false
add_column :contacts, :days_since_last_interaction, :integer
add_index :contacts, :last_interaction_at
add_index :contacts, :is_recurring
add_index :contacts, :days_since_last_interaction
add_index :contacts, %i[account_id is_recurring last_interaction_at], name: 'idx_contacts_account_recurring_last'
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_04_21_120002) do
ActiveRecord::Schema[7.1].define(version: 2026_04_22_094015) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@ -1207,16 +1207,28 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_21_120002) do
t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false
t.bigint "company_id"
t.integer "interactions_count", default: 0, null: false
t.integer "one_shot_consultations_count", default: 0, null: false
t.datetime "first_interaction_at"
t.datetime "last_interaction_at"
t.integer "pix_generated_count", default: 0, null: false
t.integer "reservations_paid_count", default: 0, null: false
t.boolean "is_recurring", default: false, null: false
t.integer "days_since_last_interaction"
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "is_recurring", "last_interaction_at"], name: "idx_contacts_account_recurring_last"
t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" }
t.index ["account_id"], name: "index_contacts_on_account_id"
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["blocked"], name: "index_contacts_on_blocked"
t.index ["company_id"], name: "index_contacts_on_company_id"
t.index ["days_since_last_interaction"], name: "index_contacts_on_days_since_last_interaction"
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
t.index ["is_recurring"], name: "index_contacts_on_is_recurring"
t.index ["last_interaction_at"], name: "index_contacts_on_last_interaction_at"
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin
t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id"
end

View File

@ -0,0 +1,46 @@
# Scheduler diário que recalcula os stats de retenção de todos os contatos
# relevantes. Evita varrer a base inteira quando não há necessidade: só processa
# contatos que:
# (a) receberam ou enviaram mensagem nos últimos 120 dias (cobertura normal), OU
# (b) estão como "ativos" ou "adormecidos" hoje (last_interaction_at nos últimos
# 120 dias) — garante que a transição pra "inativo" seja capturada mesmo
# se não houve conversa nova.
#
# Batch processado em grupo de 200 contacts por vez pra não estourar a fila.
class Captain::Retention::RecalculateAllContactStatsJob < ApplicationJob
queue_as :scheduled_jobs
LOOKBACK = 120.days
BATCH_SIZE = 200
def perform
total = 0
contact_ids_to_recalc.find_in_batches(batch_size: BATCH_SIZE) do |batch|
batch.each do |contact_id|
Captain::Retention::RecalculateContactStatsJob.perform_later(contact_id)
total += 1
end
end
Rails.logger.info "[Captain::Retention] Enqueued recalc for #{total} contacts"
end
private
def contact_ids_to_recalc
since = LOOKBACK.ago
active_from_messages = Contact
.joins(conversations: :messages)
.where('messages.created_at >= ?', since)
.distinct
.pluck(:id)
active_from_stats = Contact
.where('last_interaction_at >= ?', since)
.pluck(:id)
Contact.where(id: (active_from_messages + active_from_stats).uniq).select(:id)
end
end

View File

@ -0,0 +1,52 @@
# Recalcula os stats desnormalizados de retenção em um único contato.
# Enfileirado por:
# - RecalculateAllContactStatsJob (scheduler diário — processa todo contato ativo)
# - Hooks event-driven: Conversation resolvida, PixCharge atualizado (paid/expired)
#
# Idempotente: pode rodar quantas vezes quiser sem efeito colateral indevido.
class Captain::Retention::RecalculateContactStatsJob < ApplicationJob
queue_as :low
def perform(contact_id)
contact = Contact.find_by(id: contact_id)
return if contact.nil?
interaction_stats = Captain::ContactStats::InteractionCalculatorService.new(contact: contact).call
pix_stats = calculate_pix_stats(contact)
days_since = days_since_last(interaction_stats[:last_interaction_at])
# rubocop:disable Rails/SkipsModelValidations
contact.update_columns(
interactions_count: interaction_stats[:interactions_count],
one_shot_consultations_count: interaction_stats[:one_shot_consultations_count],
first_interaction_at: interaction_stats[:first_interaction_at],
last_interaction_at: interaction_stats[:last_interaction_at],
is_recurring: interaction_stats[:is_recurring],
days_since_last_interaction: days_since,
pix_generated_count: pix_stats[:pix_generated_count],
reservations_paid_count: pix_stats[:reservations_paid_count]
)
# rubocop:enable Rails/SkipsModelValidations
end
private
# pix_generated_count: número de PixCharge emitidas (independente de status)
# reservations_paid_count: PixCharges com status 'paid' (reserva real)
def calculate_pix_stats(contact)
base = Captain::PixCharge
.joins(:reservation)
.where(captain_reservations: { contact_id: contact.id })
{
pix_generated_count: base.count,
reservations_paid_count: base.where(status: 'paid').count
}
end
def days_since_last(last_at)
return nil if last_at.blank?
((Time.current - last_at) / 1.day).floor
end
end

View File

@ -6,6 +6,9 @@ class CaptainListener < BaseListener
return if conversation.blank?
Captain::ContactMemories::ExtractFromConversationJob.perform_later(conversation.id)
# Recalcula indicadores de retenção (interações, recorrência, days_since)
# agora que a conversa se encerrou e temos estado estável.
Captain::Retention::RecalculateContactStatsJob.perform_later(conversation.contact_id) if conversation.contact_id.present?
assistant = conversation.inbox.captain_assistant

View File

@ -42,6 +42,8 @@ class Captain::PixCharge < ApplicationRecord
validates :unit_id, presence: true
after_create_commit :post_internal_pix_sent_note
after_create_commit :enqueue_retention_recalc
after_update_commit :enqueue_retention_recalc_on_status_change
def expires_at
return nil unless created_at
@ -82,6 +84,22 @@ class Captain::PixCharge < ApplicationRecord
Rails.logger.warn("[Captain::PixCharge] failed to post sent note: #{e.class} - #{e.message}")
end
# Recalcula stats de retenção do contato sempre que um Pix novo aparece
# (incrementa pix_generated_count) ou muda de status pra paid/expired/failed
# (afeta reservations_paid_count).
def enqueue_retention_recalc
contact_id = reservation&.contact_id
return if contact_id.blank?
Captain::Retention::RecalculateContactStatsJob.perform_later(contact_id)
end
def enqueue_retention_recalc_on_status_change
return unless saved_change_to_status?
enqueue_retention_recalc
end
# Retorna o valor original da cobrança a partir do payload da Inter
def original_value
if raw_webhook_payload.present?

View File

@ -0,0 +1,137 @@
# Calcula estatísticas de interação e recorrência de um contato.
#
# Regras (negociadas com Rodrigo em 2026-04-22):
# - Gap de 30h entre a última msg de uma interação e a próxima msg do contato
# define o limite entre interações distintas. Abaixo de 30h é a mesma "sessão".
# - Interação QUALIFICADA: ≥ MIN_CUSTOMER_MSGS mensagens do contato E
# ≥ MIN_BOT_MSGS mensagens de atendimento (bot ou humano), cada uma com
# conteúdo textual (>= 2 letras, não só emoji/sticker/anexo).
# - Interação ONE-SHOT: teve troca (≥1 de cada lado) mas não atingiu o
# limiar de qualificada. É registrada como métrica secundária.
# - Conversas silenciosas (sem resposta) não viram interação de nenhum tipo.
# - Contatos com label 'equipe_interna' são excluídos de todas as métricas
# (marca manual pra filtrar gerentes, testes, etc).
#
# O service é puro — não persiste. RecalculateJob cuida da persistência.
class Captain::ContactStats::InteractionCalculatorService
INTERACTION_GAP = 30.hours
INTERNAL_LABEL = 'equipe_interna'.freeze
MIN_CUSTOMER_MSGS = 2
MIN_BOT_MSGS = 2
EMPTY_STATS = {
interactions_count: 0,
one_shot_consultations_count: 0,
first_interaction_at: nil,
last_interaction_at: nil,
is_recurring: false
}.freeze
def initialize(contact:)
@contact = contact
end
def call
return EMPTY_STATS.dup if internal?
messages = load_relevant_messages
return EMPTY_STATS.dup if messages.empty?
groups = group_into_interactions(messages)
qualified_count, one_shot_count = classify_groups(groups)
{
interactions_count: qualified_count,
one_shot_consultations_count: one_shot_count,
first_interaction_at: groups.first[:first_at],
last_interaction_at: groups.last[:last_at],
is_recurring: qualified_count >= 2
}
end
private
def internal?
@contact.label_list.include?(INTERNAL_LABEL)
rescue StandardError
false
end
# Puxa toda msg pública não-falha de conversas do contato.
# Inclui msgs de Contact (cliente) e outgoing (bot ou agente humano).
# Ordem cronológica global pra reconstruir linhas do tempo.
def load_relevant_messages
Message
.joins(:conversation)
.where(conversations: { contact_id: @contact.id })
.where(private: false)
.where.not(status: :failed)
.where(message_type: %i[incoming outgoing])
.order(:created_at)
end
# Varre mensagens na ordem cronológica e abre uma nova interação sempre
# que o intervalo entre a mensagem atual e a última da interação corrente
# for maior que INTERACTION_GAP.
def group_into_interactions(messages)
groups = []
current = nil
messages.each do |msg|
if current.nil? || (msg.created_at - current[:last_at]) > INTERACTION_GAP
current = { first_at: msg.created_at, last_at: msg.created_at, messages: [msg] }
groups << current
else
current[:messages] << msg
current[:last_at] = msg.created_at
end
end
groups
end
def classify_groups(groups)
qualified = 0
one_shot = 0
groups.each do |group|
customer = group[:messages].count { |m| qualified_customer_message?(m) }
bot = group[:messages].count { |m| qualified_bot_message?(m) }
next if customer.zero? || bot.zero?
if customer >= MIN_CUSTOMER_MSGS && bot >= MIN_BOT_MSGS
qualified += 1
else
one_shot += 1
end
end
[qualified, one_shot]
end
def qualified_customer_message?(msg)
msg.message_type == 'incoming' &&
msg.sender_type == 'Contact' &&
text_content?(msg)
end
# Resposta de atendimento conta se é outgoing da Jasmine (Captain::Assistant)
# ou de um agente humano (User). Mensagens de sistema (sender_type nil ou
# AgentBot) ficam de fora — são transferências/automações, não engajamento.
def qualified_bot_message?(msg)
return false unless msg.message_type == 'outgoing'
%w[Captain::Assistant User].include?(msg.sender_type) && text_content?(msg)
end
# Conteúdo textual válido: pelo menos 2 letras. Exclui emoji-só, sticker,
# anexo sem legenda, placeholder "Message without content".
def text_content?(msg)
content = msg.content.to_s.strip
return false if content.blank?
return false if content == 'Message without content'
content.scan(/\p{L}/).length >= 2
end
end