From f6488ce2decb905eb4631fec5015fb4863569902 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Wed, 22 Apr 2026 09:50:23 -0300 Subject: [PATCH] feat(retention): foundation for customer retention metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- config/schedule.yml | 8 + ...2094015_add_retention_stats_to_contacts.rb | 29 ++++ db/schema.rb | 14 +- .../recalculate_all_contact_stats_job.rb | 46 ++++++ .../recalculate_contact_stats_job.rb | 52 +++++++ enterprise/app/listeners/captain_listener.rb | 3 + enterprise/app/models/captain/pix_charge.rb | 18 +++ .../interaction_calculator_service.rb | 137 ++++++++++++++++++ 8 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260422094015_add_retention_stats_to_contacts.rb create mode 100644 enterprise/app/jobs/captain/retention/recalculate_all_contact_stats_job.rb create mode 100644 enterprise/app/jobs/captain/retention/recalculate_contact_stats_job.rb create mode 100644 enterprise/app/services/captain/contact_stats/interaction_calculator_service.rb diff --git a/config/schedule.yml b/config/schedule.yml index 19088ce21..667882927 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -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: '* * * * *' diff --git a/db/migrate/20260422094015_add_retention_stats_to_contacts.rb b/db/migrate/20260422094015_add_retention_stats_to_contacts.rb new file mode 100644 index 000000000..4b4ca852a --- /dev/null +++ b/db/migrate/20260422094015_add_retention_stats_to_contacts.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 5bca63ef8..c98023e90 100644 --- a/db/schema.rb +++ b/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 diff --git a/enterprise/app/jobs/captain/retention/recalculate_all_contact_stats_job.rb b/enterprise/app/jobs/captain/retention/recalculate_all_contact_stats_job.rb new file mode 100644 index 000000000..37b98e2f8 --- /dev/null +++ b/enterprise/app/jobs/captain/retention/recalculate_all_contact_stats_job.rb @@ -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 diff --git a/enterprise/app/jobs/captain/retention/recalculate_contact_stats_job.rb b/enterprise/app/jobs/captain/retention/recalculate_contact_stats_job.rb new file mode 100644 index 000000000..3a932c8fd --- /dev/null +++ b/enterprise/app/jobs/captain/retention/recalculate_contact_stats_job.rb @@ -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 diff --git a/enterprise/app/listeners/captain_listener.rb b/enterprise/app/listeners/captain_listener.rb index 2620c769f..4e264e84e 100644 --- a/enterprise/app/listeners/captain_listener.rb +++ b/enterprise/app/listeners/captain_listener.rb @@ -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 diff --git a/enterprise/app/models/captain/pix_charge.rb b/enterprise/app/models/captain/pix_charge.rb index f6960e16b..93ec283b8 100644 --- a/enterprise/app/models/captain/pix_charge.rb +++ b/enterprise/app/models/captain/pix_charge.rb @@ -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? diff --git a/enterprise/app/services/captain/contact_stats/interaction_calculator_service.rb b/enterprise/app/services/captain/contact_stats/interaction_calculator_service.rb new file mode 100644 index 000000000..004a89653 --- /dev/null +++ b/enterprise/app/services/captain/contact_stats/interaction_calculator_service.rb @@ -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