iachat/app/builders/v2/reports/bot_metrics_builder.rb
Rodribm10 617eadbfe4 feat(reports): breakdown auto/manual no BotReports + nomes corretos
Engrenagem fechada e nomes que casam com o que a métrica mede:

Cards superiores (taxas):
- "Tempo de resolução" -> "Resolvidas pelo bot %"
  Tooltip: bot resolveu sozinho (sem humano via Chatwoot ou WhatsApp) / total
- "Taxa de entrega" -> "Transferidas pra humano %"
  Tooltip: agora soma auto (Jasmine chamou) + manual (humano respondeu sem
  handoff explícito). Junto com a resolução, fecha ~100%.

Cards de detalhe (segunda linha, contagem absoluta):
- "Resolvidas pelo bot" — quantas o bot fechou sozinho
- "Transferência automática (Jasmine)" — bot_handoff explícito (loop, timeout,
  max turns, intent)
- "Tomada manual (agente)" — humano respondeu (UI ou WhatsApp echo) SEM a
  Jasmine ter chamado bot_handoff. Era o "bucket fantasma" antes.

Backend:
- BotMetricsBuilder.metrics inclui bot_resolutions_count, auto_handoffs_count,
  manual_takeovers_count
- handoff_rate agora é (auto + manual) / total — daí a engrenagem fechar
- manual_takeovers_count: conversas com mensagem outgoing humana
  (sender_type='User' OR NULL) MENOS as que tiveram conversation_bot_handoff

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:01:50 -03:00

99 lines
3.6 KiB
Ruby

class V2::Reports::BotMetricsBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def metrics
{
conversation_count: bot_conversations.count,
message_count: bot_messages.count,
resolution_rate: bot_resolution_rate.to_i,
handoff_rate: total_handoff_rate.to_i,
bot_resolutions_count: bot_resolutions_count,
auto_handoffs_count: auto_handoffs_count,
manual_takeovers_count: manual_takeovers_count
}
end
private
def filter_inbox_id
@filter_inbox_id ||= params[:inbox_id].presence&.to_i
end
def bot_activated_inbox_ids
@bot_activated_inbox_ids ||= begin
ids = account.inboxes.filter(&:active_bot?).map(&:id)
filter_inbox_id ? ids & [filter_inbox_id] : ids
end
end
def bot_conversations
@bot_conversations ||= account.conversations.where(inbox_id: bot_activated_inbox_ids).where(created_at: range)
end
def bot_messages
@bot_messages ||= account.messages.outgoing.where(conversation_id: bot_conversations.ids).where(created_at: range)
end
def base_reporting_events
scope = account.reporting_events.where(account_id: account.id, created_at: range)
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
scope
end
def bot_resolutions_count
@bot_resolutions_count ||= base_reporting_events.joins(:conversation)
.select(:conversation_id)
.where(name: :conversation_bot_resolved)
.distinct.count
end
# Auto handoff = Jasmine called bot_handoff! explicitly (loop, timeout, max_turns, intent)
def auto_handoffs_count
@auto_handoffs_count ||= base_reporting_events.joins(:conversation)
.select(:conversation_id)
.where(name: :conversation_bot_handoff)
.distinct.count
end
# Manual takeover = a human replied (via Chatwoot UI or WhatsApp echo) WITHOUT a bot_handoff
# event being emitted for the same conversation. The bot itself uses sender_type 'Captain::Assistant',
# so it's never counted here.
def manual_takeovers_count
@manual_takeovers_count ||= begin
conv_ids_with_human_reply = bot_conversations
.joins(:messages)
.where(messages: { message_type: :outgoing })
.where('messages.sender_type = ? OR messages.sender_type IS NULL', 'User')
.distinct
.pluck(:id)
conv_ids_with_auto_handoff = ReportingEvent.unscope(:order)
.where(name: 'conversation_bot_handoff',
conversation_id: conv_ids_with_human_reply)
.distinct
.pluck(:conversation_id)
(conv_ids_with_human_reply - conv_ids_with_auto_handoff).count
end
end
def bot_resolution_rate
return 0 if bot_conversations.count.zero?
bot_resolutions_count.to_f / bot_conversations.count * 100
end
# Total handoff = auto + manual (the gear that closes the math now)
def total_handoff_rate
return 0 if bot_conversations.count.zero?
(auto_handoffs_count + manual_takeovers_count).to_f / bot_conversations.count * 100
end
end