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>
260 lines
8.9 KiB
Ruby
260 lines
8.9 KiB
Ruby
# Consolida os insights semanais de uma conta em um digest executivo
|
|
# pronto pra enviar ao CEO (Mattermost, email, etc).
|
|
#
|
|
# Conceito de "unidade" no digest = 1 inbox (canal) do Chatwoot.
|
|
# Captain::Unit (marca) é usado como fallback quando não há insights por inbox.
|
|
# Retorna um hash com rankings, variações WoW e blocos temáticos
|
|
# (sem formatação — formatação fica nos adapters de delivery).
|
|
# rubocop:disable Metrics/ClassLength
|
|
class Captain::Reports::CeoDigestService
|
|
def initialize(account:, period_start: nil, period_end: nil)
|
|
@account = account
|
|
@period_end = period_end || Date.yesterday
|
|
@period_start = period_start || (@period_end - 6.days)
|
|
end
|
|
|
|
def call
|
|
insights = fetch_insights(@period_start, @period_end)
|
|
return empty_digest if insights.empty?
|
|
|
|
previous_insights = fetch_insights(@period_start - 7.days, @period_end - 7.days)
|
|
build_digest(insights, previous_insights)
|
|
end
|
|
|
|
def build_digest(insights, previous_insights)
|
|
ctx = build_context(insights, previous_insights)
|
|
header_block(ctx).merge(unit_blocks(ctx)).merge(aggregate_blocks(ctx))
|
|
end
|
|
|
|
def build_context(insights, previous_insights)
|
|
unit_insights = pick_unit_scope(insights)
|
|
{
|
|
insights: insights,
|
|
unit_insights: unit_insights,
|
|
prev_unit_insights: pick_unit_scope(previous_insights),
|
|
global: global_insight(insights),
|
|
prev_global: global_insight(previous_insights),
|
|
aggregate_source: unit_insights.presence || insights
|
|
}
|
|
end
|
|
|
|
def header_block(_ctx)
|
|
{
|
|
account_id: @account.id,
|
|
account_name: @account.name,
|
|
period_start: @period_start,
|
|
period_end: @period_end
|
|
}
|
|
end
|
|
|
|
def unit_blocks(ctx)
|
|
{
|
|
totals: build_totals(ctx[:unit_insights], ctx[:prev_unit_insights], ctx[:global], ctx[:prev_global]),
|
|
unit_ranking: build_unit_ranking(ctx[:unit_insights], ctx[:prev_unit_insights]),
|
|
ai_performance: build_ai_performance(ctx[:unit_insights]),
|
|
satisfaction: build_satisfaction(ctx[:unit_insights]),
|
|
period_summaries: build_period_summaries(ctx[:unit_insights])
|
|
}
|
|
end
|
|
|
|
def aggregate_blocks(ctx)
|
|
source = ctx[:aggregate_source]
|
|
{
|
|
top_topics: aggregate_top_items(source, 'top_topics', 'topic', 'count', limit: 10),
|
|
customer_opportunities: aggregate_top_items(source, 'customer_opportunities', 'opportunity', 'frequency', limit: 10),
|
|
faq_gaps: aggregate_top_items(source, 'faq_gaps', 'question', 'frequency', limit: 10),
|
|
complaints: aggregate_text_highlights(source, 'complaints', limit: 10),
|
|
praises: aggregate_text_highlights(source, 'praises', limit: 10),
|
|
most_requested_suites: aggregate_top_items(source, 'most_requested_suites', 'suite', 'count', limit: 5),
|
|
recommendations: aggregate_recommendations(source, limit: 10)
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
# Prioriza insights por-inbox (conceito de unidade do usuário).
|
|
# Se não houver insights por-inbox, cai pra insights por-captain_unit.
|
|
# Nunca mistura os dois — evita dupla contagem.
|
|
def pick_unit_scope(insights)
|
|
by_inbox = insights.select { |i| i.inbox_id.present? }
|
|
return by_inbox if by_inbox.any?
|
|
|
|
insights.select { |i| i.captain_unit_id.present? && i.inbox_id.blank? }
|
|
end
|
|
|
|
def global_insight(insights)
|
|
insights.find { |i| i.inbox_id.blank? && i.captain_unit_id.blank? }
|
|
end
|
|
|
|
def fetch_insights(period_start, period_end)
|
|
Captain::ConversationInsight
|
|
.where(account_id: @account.id)
|
|
.done
|
|
.for_period(period_start, period_end)
|
|
.includes(:captain_unit, :inbox)
|
|
.to_a
|
|
end
|
|
|
|
def build_totals(unit_insights, prev_unit_insights, global, prev_global)
|
|
current_conv = global&.conversations_count || unit_insights.sum(&:conversations_count)
|
|
current_msg = global&.messages_count || unit_insights.sum(&:messages_count)
|
|
previous_conv = prev_global&.conversations_count || prev_unit_insights.sum(&:conversations_count)
|
|
|
|
{
|
|
conversations: current_conv,
|
|
messages: current_msg,
|
|
conversations_delta_pct: pct_delta(previous_conv, current_conv),
|
|
insights_analyzed: unit_insights.count,
|
|
units_analyzed: unit_insights.map { |i| unit_key(i) }.uniq.count
|
|
}
|
|
end
|
|
|
|
def build_unit_ranking(unit_insights, prev_unit_insights)
|
|
entries = unit_insights.map { |i| unit_ranking_entry(i, prev_unit_insights) }
|
|
entries.sort_by { |u| -u[:conversations] }
|
|
end
|
|
|
|
def unit_ranking_entry(insight, prev_unit_insights)
|
|
prev = prev_unit_insights.find { |p| unit_key(p) == unit_key(insight) }
|
|
{
|
|
unit_id: unit_key(insight),
|
|
unit_name: unit_name(insight),
|
|
conversations: insight.conversations_count,
|
|
messages: insight.messages_count,
|
|
conversations_previous: prev&.conversations_count.to_i,
|
|
conversations_delta_pct: pct_delta(prev&.conversations_count.to_i, insight.conversations_count)
|
|
}
|
|
end
|
|
|
|
def build_ai_performance(unit_insights)
|
|
per_unit = unit_insights.map { |i| ai_performance_entry(i) }
|
|
per_unit.sort_by { |u| u[:success_rate_pct] || 100 } # piores primeiro
|
|
end
|
|
|
|
def ai_performance_entry(insight)
|
|
failures_list = insight.payload&.dig('ai_failures') || []
|
|
failures_count = failures_list.sum { |f| f['frequency'].to_i }
|
|
total = insight.conversations_count.to_i
|
|
success_rate = total.positive? ? ((total - failures_count).to_f / total * 100).round(1) : nil
|
|
|
|
{
|
|
unit_id: unit_key(insight),
|
|
unit_name: unit_name(insight),
|
|
conversations: total,
|
|
failures_count: failures_count,
|
|
success_rate_pct: success_rate,
|
|
top_failures: failures_list.first(3)
|
|
}
|
|
end
|
|
|
|
def build_satisfaction(unit_insights)
|
|
per_unit = unit_insights.map { |i| satisfaction_entry(i) }
|
|
{
|
|
most_dissatisfied: per_unit.sort_by { |u| -u[:complaints_count] }.first(5),
|
|
most_satisfied: per_unit.sort_by { |u| -u[:praises_count] }.first(5)
|
|
}
|
|
end
|
|
|
|
def satisfaction_entry(insight)
|
|
complaints = insight.payload&.dig('highlights', 'complaints') || []
|
|
praises = insight.payload&.dig('highlights', 'praises') || []
|
|
neg_pct, pos_pct = sentiment_percentages(insight.payload&.dig('sentiment') || {})
|
|
|
|
{
|
|
unit_id: unit_key(insight),
|
|
unit_name: unit_name(insight),
|
|
complaints_count: complaints.size,
|
|
praises_count: praises.size,
|
|
negative_pct: neg_pct,
|
|
positive_pct: pos_pct,
|
|
top_complaints: complaints.first(3),
|
|
top_praises: praises.first(3)
|
|
}
|
|
end
|
|
|
|
def sentiment_percentages(sentiment)
|
|
negative = sentiment['negative_count'].to_i
|
|
positive = sentiment['positive_count'].to_i
|
|
total = negative + positive + sentiment['neutral_count'].to_i
|
|
return [0, 0] unless total.positive?
|
|
|
|
[(negative.to_f / total * 100).round(1), (positive.to_f / total * 100).round(1)]
|
|
end
|
|
|
|
def aggregate_top_items(insights, payload_key, label_key, count_key, limit:)
|
|
all_items = insights.flat_map { |i| i.payload&.dig(payload_key) || [] }
|
|
grouped = all_items.group_by { |item| item[label_key].to_s.downcase.strip }
|
|
grouped.values
|
|
.map { |items| items.first.merge(count_key => items.sum { |i| i[count_key].to_i }) }
|
|
.sort_by { |item| -item[count_key].to_i }
|
|
.first(limit)
|
|
end
|
|
|
|
def aggregate_text_highlights(insights, kind, limit:)
|
|
all = insights.flat_map { |i| i.payload&.dig('highlights', kind) || [] }
|
|
grouped = all.group_by { |s| s.to_s.downcase.strip }
|
|
grouped.values
|
|
.sort_by { |v| -v.size }
|
|
.first(limit)
|
|
.map { |v| { text: v.first, frequency: v.size } }
|
|
end
|
|
|
|
def aggregate_recommendations(insights, limit:)
|
|
insights.flat_map { |i| i.payload&.dig('recommendations') || [] }
|
|
.uniq
|
|
.first(limit)
|
|
end
|
|
|
|
def build_period_summaries(unit_insights)
|
|
unit_insights.filter_map { |i| summary_entry(i) }
|
|
end
|
|
|
|
def summary_entry(insight)
|
|
summary = insight.payload&.dig('period_summary').to_s
|
|
return nil if summary.blank?
|
|
|
|
{ unit_name: unit_name(insight), summary: summary }
|
|
end
|
|
|
|
def unit_key(insight)
|
|
insight.inbox_id.present? ? "inbox:#{insight.inbox_id}" : "unit:#{insight.captain_unit_id}"
|
|
end
|
|
|
|
def unit_name(insight)
|
|
if insight.inbox_id.present?
|
|
insight.inbox&.name || "Canal ##{insight.inbox_id}"
|
|
else
|
|
insight.captain_unit&.name || "Unidade ##{insight.captain_unit_id}"
|
|
end
|
|
end
|
|
|
|
def pct_delta(previous, current)
|
|
return nil if previous.to_i.zero?
|
|
|
|
((current - previous).to_f / previous * 100).round(1)
|
|
end
|
|
|
|
def empty_digest
|
|
{
|
|
account_id: @account.id,
|
|
account_name: @account.name,
|
|
period_start: @period_start,
|
|
period_end: @period_end,
|
|
empty: true,
|
|
totals: { conversations: 0, messages: 0, insights_analyzed: 0, units_analyzed: 0 },
|
|
unit_ranking: [],
|
|
ai_performance: [],
|
|
satisfaction: { most_dissatisfied: [], most_satisfied: [] },
|
|
top_topics: [],
|
|
customer_opportunities: [],
|
|
faq_gaps: [],
|
|
complaints: [],
|
|
praises: [],
|
|
most_requested_suites: [],
|
|
recommendations: [],
|
|
period_summaries: []
|
|
}
|
|
end
|
|
end
|
|
# rubocop:enable Metrics/ClassLength
|