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>
This commit is contained in:
Rodribm10 2026-04-26 12:01:50 -03:00
parent e9b8b6e587
commit 617eadbfe4
4 changed files with 151 additions and 57 deletions

View File

@ -12,7 +12,10 @@ class V2::Reports::BotMetricsBuilder
conversation_count: bot_conversations.count,
message_count: bot_messages.count,
resolution_rate: bot_resolution_rate.to_i,
handoff_rate: bot_handoff_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
@ -44,11 +47,40 @@ class V2::Reports::BotMetricsBuilder
end
def bot_resolutions_count
base_reporting_events.joins(:conversation).select(:conversation_id).where(name: :conversation_bot_resolved).distinct.count
@bot_resolutions_count ||= base_reporting_events.joins(:conversation)
.select(:conversation_id)
.where(name: :conversation_bot_resolved)
.distinct.count
end
def bot_handoffs_count
base_reporting_events.joins(:conversation).select(:conversation_id).where(name: :conversation_bot_handoff).distinct.count
# 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
@ -57,9 +89,10 @@ class V2::Reports::BotMetricsBuilder
bot_resolutions_count.to_f / bot_conversations.count * 100
end
def bot_handoff_rate
# Total handoff = auto + manual (the gear that closes the math now)
def total_handoff_rate
return 0 if bot_conversations.count.zero?
bot_handoffs_count.to_f / bot_conversations.count * 100
(auto_handoffs_count + manual_takeovers_count).to_f / bot_conversations.count * 100
end
end

View File

@ -36,11 +36,11 @@
"DESC": "( Total )"
},
"BOT_RESOLUTION_COUNT": {
"NAME": "Resolution Count",
"NAME": "Resolved by bot",
"DESC": "( Total )"
},
"BOT_HANDOFF_COUNT": {
"NAME": "Handoff Count",
"NAME": "Transferred to human",
"DESC": "( Total )"
},
"REPLY_TIME": {
@ -534,20 +534,32 @@
"HEADER": "Bot Reports",
"METRIC": {
"TOTAL_CONVERSATIONS": {
"LABEL": "No. of Conversations",
"TOOLTIP": "Total number of conversations handled by the bot"
"LABEL": "Conversations",
"TOOLTIP": "Total number of conversations handled by the bot in the period"
},
"TOTAL_RESPONSES": {
"LABEL": "Total Responses",
"TOOLTIP": "Total number of responses sent by the bot"
"LABEL": "Outgoing messages",
"TOOLTIP": "Total number of outgoing messages — includes the bot AND humans (Chatwoot UI or WhatsApp echo)"
},
"RESOLUTION_RATE": {
"LABEL": "Resolution Rate",
"TOOLTIP": "Total number of conversations resolved by the bot / Total number of conversations handled by the bot * 100"
"LABEL": "Resolved by bot %",
"TOOLTIP": "Conversations the bot resolved alone (no human reply, via UI or WhatsApp) ÷ total conversations × 100"
},
"HANDOFF_RATE": {
"LABEL": "Handoff Rate",
"TOOLTIP": "Total number of conversations handed off to agents / Total number of conversations handled by the bot * 100"
"LABEL": "Transferred to human %",
"TOOLTIP": "Conversations transferred to human (auto by Jasmine + manual takeover) ÷ total conversations × 100. Together with the resolution rate, the gear closes the math (the rest are still open, snoozed, or abandoned)."
},
"BOT_RESOLUTIONS": {
"LABEL": "Resolved by bot",
"TOOLTIP": "Absolute count: conversations the bot closed alone, with no human reply (UI or WhatsApp)"
},
"AUTO_HANDOFFS": {
"LABEL": "Auto handoff (Jasmine)",
"TOOLTIP": "Conversations where Jasmine explicitly called bot_handoff! — typically tool loop, timeout, max turns reached or LLM intent classified as handoff"
},
"MANUAL_TAKEOVERS": {
"LABEL": "Manual takeover (agent)",
"TOOLTIP": "Conversations where a human replied (Chatwoot UI or WhatsApp echo) without Jasmine triggering bot_handoff! first — the agent took over silently"
}
}
},

View File

@ -36,11 +36,11 @@
"DESC": "( Total )"
},
"BOT_RESOLUTION_COUNT": {
"NAME": "Contagem de Resolução",
"NAME": "Resolvidas pelo bot",
"DESC": "( Total )"
},
"BOT_HANDOFF_COUNT": {
"NAME": "Contagem de transferências",
"NAME": "Transferidas para humano",
"DESC": "( Total )"
},
"REPLY_TIME": {
@ -466,20 +466,32 @@
"HEADER": "Relatórios do Bot",
"METRIC": {
"TOTAL_CONVERSATIONS": {
"LABEL": "Nº de Conversas",
"TOOLTIP": "Número total de conversas tratadas pelo bot"
"LABEL": "Conversas",
"TOOLTIP": "Total de conversas atendidas pelo bot no período"
},
"TOTAL_RESPONSES": {
"LABEL": "Total de respostas",
"TOOLTIP": "Número total de respostas enviadas pelo bot"
"LABEL": "Mensagens enviadas",
"TOOLTIP": "Total de mensagens enviadas — inclui o bot E humanos (via Chatwoot ou eco do WhatsApp)"
},
"RESOLUTION_RATE": {
"LABEL": "Tempo de resolução",
"TOOLTIP": "Número total de conversas resolvidas pelo bot / número total de conversas manipuladas pelo bot * 100"
"LABEL": "Resolvidas pelo bot %",
"TOOLTIP": "Conversas que o bot resolveu sozinho (sem humano respondendo, via Chatwoot ou WhatsApp) ÷ total de conversas × 100"
},
"HANDOFF_RATE": {
"LABEL": "Taxa de entrega",
"TOOLTIP": "Número total de conversas entregues a agentes / número total de conversas mantidas pelo bot * 100"
"LABEL": "Transferidas pra humano %",
"TOOLTIP": "Conversas transferidas pra humano (auto pela Jasmine + tomada manual) ÷ total de conversas × 100. Junto com a taxa de resolução fecha a engrenagem (o resto está aberto, em snooze ou abandonado)."
},
"BOT_RESOLUTIONS": {
"LABEL": "Resolvidas pelo bot",
"TOOLTIP": "Contagem absoluta: conversas que o bot fechou sozinho, sem humano respondendo (via Chatwoot ou WhatsApp)"
},
"AUTO_HANDOFFS": {
"LABEL": "Transferência automática (Jasmine)",
"TOOLTIP": "Conversas em que a Jasmine chamou bot_handoff! explicitamente — geralmente loop de ferramenta, timeout, limite de turnos ou intent do LLM classificado como handoff"
},
"MANUAL_TAKEOVERS": {
"LABEL": "Tomada manual (agente)",
"TOOLTIP": "Conversas em que um humano respondeu (Chatwoot ou eco do WhatsApp) SEM a Jasmine ter chamado bot_handoff! antes — o agente assumiu silenciosamente"
}
}
},

View File

@ -12,8 +12,11 @@ const props = defineProps({
const conversationCount = ref('0');
const messageCount = ref('0');
const resolutionRate = ref('0');
const handoffRate = ref('0');
const botResolutionRate = ref('0');
const humanTransferRate = ref('0');
const botResolutionsCount = ref('0');
const autoHandoffsCount = ref('0');
const manualTakeoversCount = ref('0');
const formatToPercent = value => {
return value ? `${value}%` : '--';
@ -26,8 +29,17 @@ const fetchMetrics = () => {
ReportsAPI.getBotMetrics(props.filters).then(response => {
conversationCount.value = response.data.conversation_count.toLocaleString();
messageCount.value = response.data.message_count.toLocaleString();
resolutionRate.value = response.data.resolution_rate.toString();
handoffRate.value = response.data.handoff_rate.toString();
botResolutionRate.value = response.data.resolution_rate.toString();
humanTransferRate.value = response.data.handoff_rate.toString();
botResolutionsCount.value = (
response.data.bot_resolutions_count || 0
).toLocaleString();
autoHandoffsCount.value = (
response.data.auto_handoffs_count || 0
).toLocaleString();
manualTakeoversCount.value = (
response.data.manual_takeovers_count || 0
).toLocaleString();
});
};
@ -37,32 +49,57 @@ onMounted(fetchMetrics);
</script>
<template>
<div
class="flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.TOOLTIP')"
:value="conversationCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="messageCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.TOOLTIP')"
:value="formatToPercent(resolutionRate)"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.TOOLTIP')"
:value="formatToPercent(handoffRate)"
class="flex-1"
/>
<div class="flex flex-col gap-4">
<div
class="flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.TOOLTIP')"
:value="conversationCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="messageCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.TOOLTIP')"
:value="formatToPercent(botResolutionRate)"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.TOOLTIP')"
:value="formatToPercent(humanTransferRate)"
class="flex-1"
/>
</div>
<div
class="flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.BOT_RESOLUTIONS.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.BOT_RESOLUTIONS.TOOLTIP')"
:value="botResolutionsCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.AUTO_HANDOFFS.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.AUTO_HANDOFFS.TOOLTIP')"
:value="autoHandoffsCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.MANUAL_TAKEOVERS.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.MANUAL_TAKEOVERS.TOOLTIP')"
:value="manualTakeoversCount"
class="flex-1"
/>
</div>
</div>
</template>