iachat/enterprise/app/services/captain/reports/conversion_funnel_service.rb
Rodribm10 cfffea9c16 feat(captain): semantic memory fixes + roleta + reclamações + analytics
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>
2026-04-21 15:36:25 -03:00

166 lines
5.4 KiB
Ruby

# frozen_string_literal: true
# Funil de conversão 5-etapas: perguntou preço → recebeu preço →
# reserva iniciada → Pix gerado → Pix pago. Identifica drop-off.
class Captain::Reports::ConversionFunnelService
DEFAULT_PERIOD_DAYS = 30
MAX_PERIOD_DAYS = 180
PRICE_QUESTION_RE = /(pre[çc]o|quanto\s+custa|valor\b|tabela|or[çc]amento|qual\s+o\s+pre)/i
PRICE_ANSWER_RE = /r\$\s*\d|\b\d{2,3}\s*reais\b|\bcus(ta|tam)\b.*\d/i
SUITE_RE = /\b(alexa|stilo|est[íi]lo|hidro(massagem)?|banheira|jacuzzi|of[uú]r[óo])\b/i
STAGES = [
{ key: 'price_inquiry', label: 'Perguntou preço' },
{ key: 'price_answered', label: 'Recebeu cotação' },
{ key: 'reservation_drafted', label: 'Reserva iniciada' },
{ key: 'pix_generated', label: 'Pix gerado' },
{ key: 'pix_paid', label: 'Pix pago' }
].freeze
def initialize(account:, period_days: DEFAULT_PERIOD_DAYS)
@account = account
@period_days = [period_days.to_i, MAX_PERIOD_DAYS].min
@period_days = DEFAULT_PERIOD_DAYS if @period_days <= 0
@period_end = Time.current
@period_start = @period_end - @period_days.days
end
def call
conversations = scope_conversations
return empty_report if conversations.empty?
analyzed = conversations.map { |conv| analyze_conversation(conv) }
funnel = build_funnel(analyzed)
by_suite = build_by_suite(analyzed)
{
period_start: @period_start.iso8601,
period_end: @period_end.iso8601,
period_days: @period_days,
total_conversations_analyzed: analyzed.size,
funnel: funnel,
by_suite: by_suite,
top_drop_off: compute_top_drop_off(funnel)
}
rescue StandardError => e
Rails.logger.error("[ConversionFunnelService] #{e.class}: #{e.message}")
empty_report(error: e.message)
end
private
def empty_report(error: nil)
{
period_start: @period_start.iso8601,
period_end: @period_end.iso8601,
period_days: @period_days,
total_conversations_analyzed: 0,
funnel: STAGES.map { |s| s.merge(count: 0, conversion: nil) },
by_suite: {},
top_drop_off: nil,
error: error
}.compact
end
# Conversas do período em que Captain::Assistant participou.
def scope_conversations
@account.conversations
.joins(:messages)
.where('conversations.created_at >= ? AND conversations.created_at <= ?', @period_start, @period_end)
.where(messages: { sender_type: 'Captain::Assistant' })
.distinct
.includes(:messages)
end
# Para cada conversa, determina o MAX stage alcançado + a categoria mencionada.
# rubocop:disable Metrics/AbcSize
def analyze_conversation(conv)
messages = conv.messages.where(private: false).order(:created_at).to_a
incoming = messages.select { |m| m.message_type == 'incoming' }
outgoing = messages.select { |m| m.message_type == 'outgoing' && m.sender_type == 'Captain::Assistant' }
max_stage = nil
max_stage = 'price_inquiry' if incoming.any? { |m| PRICE_QUESTION_RE.match?(m.content.to_s) }
max_stage = 'price_answered' if max_stage && outgoing.any? { |m| PRICE_ANSWER_RE.match?(m.content.to_s) }
reservation = Captain::Reservation.where(conversation_id: conv.id).order(created_at: :asc).first
if reservation
max_stage ||= 'reservation_drafted'
max_stage = 'reservation_drafted' if STAGES.index { |s| s[:key] == max_stage } < 2
end
max_stage = 'pix_generated' if reservation && pix_charge?(reservation) && (STAGES.index { |s| s[:key] == max_stage }.to_i < 3)
max_stage = 'pix_paid' if reservation && reservation.payment_status.to_s == 'paid'
{
conversation_id: conv.id,
max_stage: max_stage,
suite: detect_suite(messages)
}
end
# rubocop:enable Metrics/AbcSize
def pix_charge?(reservation)
Captain::PixCharge.exists?(reservation_id: reservation.id)
end
def detect_suite(messages)
messages.each do |m|
next if m.content.blank?
md = SUITE_RE.match(m.content)
next unless md
word = md[1].downcase
return 'Alexa' if word == 'alexa'
return 'Stilo' if %w[stilo estilo estílo].include?(word)
return 'Hidromassagem' if %w[hidro hidromassagem banheira jacuzzi ofuro ofurô].include?(word)
end
nil
end
def build_funnel(analyzed)
# Conta cumulativamente: quem chegou em "reservation_drafted" também conta em "price_answered" e "price_inquiry".
stage_order = STAGES.pluck(:key)
counts = Hash.new(0)
analyzed.each do |row|
max_index = stage_order.index(row[:max_stage])
next unless max_index
stage_order[0..max_index].each { |k| counts[k] += 1 }
end
previous = nil
STAGES.map do |stage|
count = counts[stage[:key]]
conversion = previous&.positive? ? (count.to_f / previous).round(3) : nil
previous = count
stage.merge(count: count, conversion: conversion)
end
end
def build_by_suite(analyzed)
grouped = analyzed.group_by { |row| row[:suite] }.reject { |k, _| k.nil? }
grouped.transform_values { |rows| build_funnel(rows).map { |s| s.slice(:key, :label, :count) } }
end
def compute_top_drop_off(funnel)
drops = funnel.each_cons(2).filter_map do |from, to|
next nil unless from[:count].positive?
lost = from[:count] - to[:count]
pct = lost.to_f / from[:count]
{ from: from[:key], to: to[:key], lost: lost, drop_pct: pct.round(3) }
end
return nil if drops.empty?
drops.max_by { |d| d[:drop_pct] }
end
end