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>
99 lines
3.6 KiB
Ruby
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
|