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>
298 lines
8.4 KiB
Ruby
298 lines
8.4 KiB
Ruby
# Entrega o CEO Digest em um canal Mattermost via Incoming Webhook.
|
|
# Formata o digest como um attachment rico (cards coloridos, campos em tabela).
|
|
#
|
|
# Doc do formato: https://docs.mattermost.com/developer/message-attachments.html
|
|
# rubocop:disable Metrics/ClassLength
|
|
class Captain::Reports::MattermostDeliveryService
|
|
TIMEOUT_SECONDS = 10
|
|
MAX_TEXT_LENGTH = 4_000 # Mattermost truncates silently above this
|
|
|
|
COLOR_GREEN = '#2eb886'.freeze
|
|
COLOR_YELLOW = '#ecb22e'.freeze
|
|
COLOR_RED = '#e01e5a'.freeze
|
|
|
|
def initialize(digest:, webhook_url:, channel: nil, username: 'Captain CEO Digest')
|
|
@digest = digest
|
|
@webhook_url = webhook_url
|
|
@channel = channel
|
|
@username = username
|
|
end
|
|
|
|
def call
|
|
raise ArgumentError, 'webhook_url is required' if @webhook_url.blank?
|
|
|
|
post_and_handle
|
|
end
|
|
|
|
private
|
|
|
|
def post_and_handle
|
|
response = HTTParty.post(
|
|
@webhook_url,
|
|
body: build_payload.to_json,
|
|
headers: { 'Content-Type' => 'application/json' },
|
|
timeout: TIMEOUT_SECONDS
|
|
)
|
|
|
|
unless response.success?
|
|
Rails.logger.error "[CeoDigest::Mattermost] delivery failed #{response.code}: #{response.body.to_s.force_encoding('UTF-8')}"
|
|
return { success: false, status: response.code, body: response.body }
|
|
end
|
|
|
|
{ success: true, status: response.code }
|
|
rescue StandardError => e
|
|
Rails.logger.error "[CeoDigest::Mattermost] delivery error: #{e.class} #{e.message}"
|
|
{ success: false, error: e.message }
|
|
end
|
|
|
|
def build_payload
|
|
payload = {
|
|
username: @username,
|
|
icon_emoji: ':bar_chart:',
|
|
text: header_text,
|
|
attachments: build_attachments
|
|
}
|
|
payload[:channel] = @channel if @channel.present?
|
|
payload
|
|
end
|
|
|
|
def header_text
|
|
period = "#{format_date(@digest[:period_start])} a #{format_date(@digest[:period_end])}"
|
|
"## :bar_chart: CEO Digest Semanal — **#{@digest[:account_name]}**\n_Período: #{period}_"
|
|
end
|
|
|
|
def build_attachments
|
|
return [empty_attachment] if @digest[:empty]
|
|
|
|
[
|
|
totals_attachment,
|
|
unit_ranking_attachment,
|
|
ai_performance_attachment,
|
|
satisfaction_attachment,
|
|
opportunities_attachment,
|
|
topics_attachment,
|
|
recommendations_attachment
|
|
].compact
|
|
end
|
|
|
|
def totals_attachment
|
|
t = @digest[:totals]
|
|
delta = format_delta(t[:conversations_delta_pct])
|
|
|
|
{
|
|
color: COLOR_GREEN,
|
|
title: ':1234: Números da semana',
|
|
fields: [
|
|
{ title: 'Conversas', value: "**#{t[:conversations]}** #{delta}", short: true },
|
|
{ title: 'Mensagens', value: "**#{t[:messages]}**", short: true },
|
|
{ title: 'Unidades analisadas', value: t[:units_analyzed].to_s, short: true },
|
|
{ title: 'Insights gerados', value: t[:insights_analyzed].to_s, short: true }
|
|
]
|
|
}
|
|
end
|
|
|
|
def unit_ranking_attachment
|
|
ranking = @digest[:unit_ranking]
|
|
return nil if ranking.empty?
|
|
|
|
lines = ranking.each_with_index.map do |u, idx|
|
|
delta = format_delta(u[:conversations_delta_pct])
|
|
"**#{idx + 1}. #{u[:unit_name]}** — #{u[:conversations]} conversas #{delta}"
|
|
end
|
|
|
|
{
|
|
color: COLOR_GREEN,
|
|
title: ':trophy: Ranking por unidade (volume)',
|
|
text: lines.join("\n")
|
|
}
|
|
end
|
|
|
|
def ai_performance_attachment
|
|
perf = @digest[:ai_performance]
|
|
return nil if perf.empty?
|
|
|
|
text_parts = [ai_performance_lines(perf).join("\n")]
|
|
failures_block = ai_failures_block(perf)
|
|
text_parts << "\n**Principais erros:**\n#{failures_block.join("\n")}" if failures_block.any?
|
|
|
|
{
|
|
color: ai_performance_color(perf),
|
|
title: ':robot_face: Performance da IA (Angelina)',
|
|
text: truncate(text_parts.join("\n"))
|
|
}
|
|
end
|
|
|
|
def ai_performance_lines(perf)
|
|
perf.map do |u|
|
|
rate = u[:success_rate_pct]
|
|
rate_str = rate ? "#{rate}%" : 'sem dados'
|
|
"#{ai_rate_icon(rate)} **#{u[:unit_name]}** — acerto: #{rate_str} (#{u[:failures_count]} falhas em #{u[:conversations]} conversas)"
|
|
end
|
|
end
|
|
|
|
def ai_rate_icon(rate)
|
|
return ':grey_question:' if rate.nil?
|
|
return ':white_check_mark:' if rate >= 85
|
|
return ':warning:' if rate >= 70
|
|
|
|
':x:'
|
|
end
|
|
|
|
def ai_failures_block(perf)
|
|
failing = perf.reject { |u| u[:success_rate_pct].nil? || u[:success_rate_pct] >= 85 }
|
|
failing.first(3).flat_map do |u|
|
|
next [] if u[:top_failures].empty?
|
|
|
|
["_#{u[:unit_name]}:_"] + u[:top_failures].map { |f| "• #{f['description']} (#{f['frequency']}x)" }
|
|
end
|
|
end
|
|
|
|
def ai_performance_color(perf)
|
|
perf.any? { |u| u[:success_rate_pct].to_f < 70 } ? COLOR_RED : COLOR_YELLOW
|
|
end
|
|
|
|
def satisfaction_attachment
|
|
sat = @digest[:satisfaction]
|
|
return nil if sat[:most_dissatisfied].empty? && sat[:most_satisfied].empty?
|
|
|
|
dissatisfied_lines = dissatisfied_unit_lines(sat[:most_dissatisfied])
|
|
satisfied_lines = satisfied_unit_lines(sat[:most_satisfied])
|
|
|
|
text_parts = []
|
|
text_parts << ":rage: **Onde teve mais reclamação:**\n#{dissatisfied_lines.join("\n")}" if dissatisfied_lines.any?
|
|
text_parts << "\n:heart: **Onde teve mais elogio:**\n#{satisfied_lines.join("\n")}" if satisfied_lines.any?
|
|
return nil if text_parts.empty?
|
|
|
|
{
|
|
color: dissatisfied_lines.any? ? COLOR_RED : COLOR_GREEN,
|
|
title: ':thermometer: Satisfação dos clientes',
|
|
text: truncate(text_parts.join("\n"))
|
|
}
|
|
end
|
|
|
|
def dissatisfied_unit_lines(units)
|
|
units.first(3).flat_map do |u|
|
|
next [] if u[:complaints_count].zero?
|
|
|
|
header = "**#{u[:unit_name]}** — #{u[:complaints_count]} reclamações (#{u[:negative_pct]}% negativo)"
|
|
[header] + u[:top_complaints].map { |c| "• _#{truncate(c, limit: 150)}_" }
|
|
end
|
|
end
|
|
|
|
def satisfied_unit_lines(units)
|
|
units.first(3).flat_map do |u|
|
|
next [] if u[:praises_count].zero?
|
|
|
|
header = "**#{u[:unit_name]}** — #{u[:praises_count]} elogios (#{u[:positive_pct]}% positivo)"
|
|
[header] + u[:top_praises].map { |p| "• _#{truncate(p, limit: 150)}_" }
|
|
end
|
|
end
|
|
|
|
def opportunities_attachment
|
|
opps = @digest[:customer_opportunities]
|
|
return nil if opps.empty?
|
|
|
|
lines = opps.first(7).map do |o|
|
|
freq = o['frequency'].to_i
|
|
"• **#{o['opportunity']}** — pedido #{freq}x"
|
|
end
|
|
|
|
{
|
|
color: COLOR_YELLOW,
|
|
title: ':bulb: Oportunidades (o que os clientes pediram)',
|
|
text: lines.join("\n")
|
|
}
|
|
end
|
|
|
|
def topics_attachment
|
|
fields = [
|
|
topics_field(@digest[:top_topics]),
|
|
faq_gaps_field(@digest[:faq_gaps]),
|
|
complaints_field(@digest[:complaints])
|
|
].compact
|
|
return nil if fields.empty?
|
|
|
|
{
|
|
color: COLOR_YELLOW,
|
|
title: ':speech_balloon: Temas e lacunas',
|
|
fields: fields
|
|
}
|
|
end
|
|
|
|
def topics_field(topics)
|
|
return nil if topics.blank?
|
|
|
|
{
|
|
title: 'Mais falados',
|
|
value: topics.first(5).map { |t| "• #{t['topic']} (#{t['count']})" }.join("\n"),
|
|
short: true
|
|
}
|
|
end
|
|
|
|
def faq_gaps_field(gaps)
|
|
return nil if gaps.blank?
|
|
|
|
{
|
|
title: 'Lacunas de FAQ',
|
|
value: gaps.first(5).map { |g| "• #{truncate(g['question'], limit: 80)} (#{g['frequency']}x)" }.join("\n"),
|
|
short: true
|
|
}
|
|
end
|
|
|
|
def complaints_field(complaints)
|
|
return nil if complaints.blank?
|
|
|
|
{
|
|
title: 'Reclamações recorrentes',
|
|
value: complaints.first(5).map { |c| "• #{truncate(c[:text], limit: 120)}" }.join("\n"),
|
|
short: false
|
|
}
|
|
end
|
|
|
|
def recommendations_attachment
|
|
recs = @digest[:recommendations]
|
|
return nil if recs.empty?
|
|
|
|
{
|
|
color: COLOR_GREEN,
|
|
title: ':dart: Recomendações da IA',
|
|
text: recs.first(8).map { |r| "• #{r}" }.join("\n")
|
|
}
|
|
end
|
|
|
|
def empty_attachment
|
|
{
|
|
color: COLOR_YELLOW,
|
|
title: ':grey_question: Sem dados no período',
|
|
text: 'Não há insights gerados para a semana selecionada. Verifique se o job semanal está agendado e se há conversas nas unidades.'
|
|
}
|
|
end
|
|
|
|
def format_date(date)
|
|
return '—' if date.blank?
|
|
|
|
begin
|
|
I18n.l(date.to_date, format: :default)
|
|
rescue StandardError
|
|
date.to_s
|
|
end
|
|
end
|
|
|
|
def format_delta(pct)
|
|
return '' if pct.nil?
|
|
|
|
arrow = if pct.positive?
|
|
':arrow_up:'
|
|
else
|
|
pct.negative? ? ':arrow_down:' : ':arrow_right:'
|
|
end
|
|
sign = pct.positive? ? '+' : ''
|
|
"(#{arrow} #{sign}#{pct}% vs. semana anterior)"
|
|
end
|
|
|
|
def truncate(text, limit: MAX_TEXT_LENGTH)
|
|
text.to_s.length > limit ? "#{text.to_s[0, limit - 3]}..." : text.to_s
|
|
end
|
|
end
|
|
# rubocop:enable Metrics/ClassLength
|