mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-04 21:02:35 +08:00
The bot metrics dashboard can show `handoff_rate + resolution_rate >
100%`. A single conversation can accumulate both
`conversation_bot_handoff` and `conversation_bot_resolved` events, and
the rate queries count them independently against a shared denominator.
## How it happens
```
Customer messages bot inbox
│
▼
┌──────────┐
│ pending │ (bot handling)
└────┬─────┘
│ bot can't help
▼
┌──────────┐
│ open │ (handed off → conversation_bot_handoff event created)
└────┬─────┘
│ agent clicks "Resolve" WITHOUT sending a message
▼
┌──────────┐
│ resolved │ conversation_resolved fires
└──────────┘
│
▼
create_bot_resolved_event guard checks:
✅ inbox.active_bot?
✅ no outgoing messages with sender_type: 'User' ← agent never messaged!
│
▼
conversation_bot_resolved event ALSO created ← BUG
│
▼
Same conversation counted in BOTH rates → sum exceeds 100%
```
## Why fix at the read path, not the write path
An earlier attempt added guards in the listener to make the two events
mutually exclusive per conversation — deleting `bot_resolved` when a
handoff fires, suppressing resolutions when a handoff exists. This was
rejected because conversations can be reopened across multiple cycles
(bot resolves on day 1, customer returns on day 5, bot hands off).
Deleting the day-1 resolution corrupts historical reports, and the async
event dispatcher makes listener-level guards vulnerable to race
conditions.
## What this PR does
Within a reporting window, if a conversation has both events, **handoff
wins** — the conversation is excluded from the resolution count. This is
applied via SQL subquery across all three read paths:
```
┌─────────────────────────┐
│ Reporting Events DB │
│ │
│ conv_bot_handoff: [A,B] │
│ conv_bot_resolved: [A,C]│
└────────┬────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
BotMetricsBuilder ReportHelper CountReportBuilder
(rate cards) (bot_summary) (timeseries charts)
│ │ │
▼ ▼ ▼
resolutions: resolutions: resolutions:
[A,C] minus [A,B] same logic same logic
= [C] only = [C] only = [C] only
Result: Conversation A → handoff only
Conversation B → handoff only
Conversation C → resolution only
```
For wide date ranges spanning multiple lifecycles, a conversation
bot-resolved in one cycle and handed off in a later cycle will only show
as a handoff. This is an acceptable tradeoff — the alternative (>100%
rates) is clearly worse, and narrow ranges handle this correctly since
the events fall into different windows. No reporting events are
modified, so historical data stays intact.
## Diagnostic tool
`rake bot_metrics:diagnose` — read-only task that prompts for account ID
and date range, shows a before/after rate comparison without modifying
data.
---------
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
132 lines
3.8 KiB
Ruby
132 lines
3.8 KiB
Ruby
module ReportHelper
|
|
private
|
|
|
|
def scope
|
|
case params[:type]
|
|
when :account
|
|
account
|
|
when :inbox
|
|
inbox
|
|
when :agent
|
|
user
|
|
when :label
|
|
label
|
|
when :team
|
|
team
|
|
end
|
|
end
|
|
|
|
def conversations_count
|
|
(get_grouped_values conversations).count
|
|
end
|
|
|
|
def incoming_messages_count
|
|
(get_grouped_values incoming_messages).count
|
|
end
|
|
|
|
def outgoing_messages_count
|
|
(get_grouped_values outgoing_messages).count
|
|
end
|
|
|
|
def resolutions_count
|
|
(get_grouped_values resolutions).count
|
|
end
|
|
|
|
def bot_resolutions_count
|
|
(get_grouped_values bot_resolutions).count
|
|
end
|
|
|
|
def bot_handoffs_count
|
|
(get_grouped_values bot_handoffs).count
|
|
end
|
|
|
|
def conversations
|
|
scope.conversations.where(account_id: account.id, created_at: range)
|
|
end
|
|
|
|
def incoming_messages
|
|
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
|
end
|
|
|
|
def outgoing_messages
|
|
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
|
end
|
|
|
|
def resolutions
|
|
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range)
|
|
end
|
|
|
|
def bot_resolutions
|
|
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
|
|
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
|
end
|
|
|
|
def bot_handoffs
|
|
scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
|
|
created_at: range).distinct
|
|
end
|
|
|
|
def bot_handoff_conversation_ids_subquery
|
|
bot_handoffs
|
|
end
|
|
|
|
def avg_first_response_time
|
|
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
|
|
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
|
|
|
|
grouped_reporting_events.average(:value)
|
|
end
|
|
|
|
def reply_time
|
|
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'reply_time', account_id: account.id))
|
|
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
|
|
|
|
grouped_reporting_events.average(:value)
|
|
end
|
|
|
|
def avg_resolution_time
|
|
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved', account_id: account.id))
|
|
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
|
|
|
|
grouped_reporting_events.average(:value)
|
|
end
|
|
|
|
def avg_resolution_time_summary
|
|
reporting_events = scope.reporting_events
|
|
.where(name: 'conversation_resolved', account_id: account.id, created_at: range)
|
|
avg_rt = if params[:business_hours].present?
|
|
reporting_events.average(:value_in_business_hours)
|
|
else
|
|
reporting_events.average(:value)
|
|
end
|
|
|
|
return 0 if avg_rt.blank?
|
|
|
|
avg_rt
|
|
end
|
|
|
|
def reply_time_summary
|
|
reporting_events = scope.reporting_events
|
|
.where(name: 'reply_time', account_id: account.id, created_at: range)
|
|
reply_time = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
|
|
|
|
return 0 if reply_time.blank?
|
|
|
|
reply_time
|
|
end
|
|
|
|
def avg_first_response_time_summary
|
|
reporting_events = scope.reporting_events
|
|
.where(name: 'first_response', account_id: account.id, created_at: range)
|
|
avg_frt = if params[:business_hours].present?
|
|
reporting_events.average(:value_in_business_hours)
|
|
else
|
|
reporting_events.average(:value)
|
|
end
|
|
|
|
return 0 if avg_frt.blank?
|
|
|
|
avg_frt
|
|
end
|
|
end
|