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:
parent
08a06c6528
commit
f6488ce2de
@ -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: '* * * * *'
|
||||
|
||||
29
db/migrate/20260422094015_add_retention_stats_to_contacts.rb
Normal file
29
db/migrate/20260422094015_add_retention_stats_to_contacts.rb
Normal 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
|
||||
14
db/schema.rb
14
db/schema.rb
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user