mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 10:42:38 +00:00
feat: Add backend APIs for the bot metrics (#9031)
Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
@@ -54,6 +54,13 @@ class V2::ReportBuilder
|
||||
}
|
||||
end
|
||||
|
||||
def bot_summary
|
||||
{
|
||||
bot_resolutions_count: bot_resolutions.count,
|
||||
bot_handoffs_count: bot_handoffs.count
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_metrics
|
||||
if params[:type].equal?(:account)
|
||||
live_conversations
|
||||
@@ -71,6 +78,8 @@ class V2::ReportBuilder
|
||||
avg_first_response_time
|
||||
avg_resolution_time reply_time
|
||||
resolutions_count
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
reply_time].include?(params[:metric])
|
||||
end
|
||||
|
||||
@@ -123,6 +132,7 @@ class V2::ReportBuilder
|
||||
unattended: @open_conversations.unattended.count
|
||||
}
|
||||
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
|
||||
metric[:pending] = @open_conversations.pending.count if params[:type].equal?(:account)
|
||||
metric
|
||||
end
|
||||
end
|
||||
|
||||
54
app/builders/v2/reports/bot_metrics_builder.rb
Normal file
54
app/builders/v2/reports/bot_metrics_builder.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
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: bot_handoff_rate.to_i
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bot_activated_inbox_ids
|
||||
@bot_activated_inbox_ids ||= account.inboxes.filter(&:active_bot?).map(&:id)
|
||||
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 bot_resolutions_count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range).distinct.count
|
||||
end
|
||||
|
||||
def bot_handoffs_count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
|
||||
created_at: range).distinct.count
|
||||
end
|
||||
|
||||
def bot_resolution_rate
|
||||
return 0 if bot_conversations.count.zero?
|
||||
|
||||
bot_resolutions_count.to_f / bot_conversations.count * 100
|
||||
end
|
||||
|
||||
def bot_handoff_rate
|
||||
return 0 if bot_conversations.count.zero?
|
||||
|
||||
bot_handoffs_count.to_f / bot_conversations.count * 100
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
render json: summary_metrics
|
||||
end
|
||||
|
||||
def bot_summary
|
||||
summary = V2::ReportBuilder.new(Current.account, current_summary_params).bot_summary
|
||||
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).bot_summary
|
||||
render json: summary
|
||||
end
|
||||
|
||||
def agents
|
||||
@report_data = generate_agents_report
|
||||
generate_csv('agents_report', 'api/v2/accounts/reports/agents')
|
||||
@@ -48,6 +54,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
render json: conversation_metrics
|
||||
end
|
||||
|
||||
def bot_metrics
|
||||
bot_metrics = V2::Reports::BotMetricsBuilder.new(Current.account, params).metrics
|
||||
render json: bot_metrics
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_csv(filename, template)
|
||||
|
||||
@@ -32,6 +32,14 @@ module ReportHelper
|
||||
(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
|
||||
@@ -49,6 +57,16 @@ module ReportHelper
|
||||
conversations: { status: :resolved }, created_at: range).distinct
|
||||
end
|
||||
|
||||
def bot_resolutions
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
conversations: { status: :resolved }, created_at: range).distinct
|
||||
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 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]
|
||||
|
||||
@@ -412,7 +412,8 @@
|
||||
"LOADING_MESSAGE": "Loading conversation metrics...",
|
||||
"OPEN": "Open",
|
||||
"UNATTENDED": "Unattended",
|
||||
"UNASSIGNED": "Unassigned"
|
||||
"UNASSIGNED": "Unassigned",
|
||||
"PENDING": "Pending"
|
||||
},
|
||||
"CONVERSATION_HEATMAP": {
|
||||
"HEADER": "Conversation Traffic",
|
||||
|
||||
@@ -200,6 +200,7 @@ export const OVERVIEW_METRICS = {
|
||||
open: 'OPEN',
|
||||
unattended: 'UNATTENDED',
|
||||
unassigned: 'UNASSIGNED',
|
||||
pending: 'PENDING',
|
||||
online: 'ONLINE',
|
||||
busy: 'BUSY',
|
||||
offline: 'OFFLINE',
|
||||
|
||||
@@ -303,12 +303,14 @@ Rails.application.routes.draw do
|
||||
resources :reports, only: [:index] do
|
||||
collection do
|
||||
get :summary
|
||||
get :bot_summary
|
||||
get :agents
|
||||
get :inboxes
|
||||
get :labels
|
||||
get :teams
|
||||
get :conversations
|
||||
get :conversation_traffic
|
||||
get :bot_metrics
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,8 +6,6 @@ describe V2::ReportBuilder do
|
||||
let_it_be(:label_1) { create(:label, title: 'Label_1', account: account) }
|
||||
let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) }
|
||||
|
||||
# Update this spec to use travel_to
|
||||
# This spec breaks in certain timezone
|
||||
describe '#timeseries' do
|
||||
before do
|
||||
travel_to(Time.zone.today) do
|
||||
@@ -128,6 +126,75 @@ describe V2::ReportBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns bot_resolutions count' do
|
||||
travel_to(Time.zone.today) do
|
||||
params = {
|
||||
metric: 'bot_resolutions_count',
|
||||
type: :account,
|
||||
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
|
||||
until: Time.zone.today.end_of_day.to_time.to_i.to_s
|
||||
}
|
||||
|
||||
create(:agent_bot_inbox, inbox: account.inboxes.first)
|
||||
conversations = account.conversations.where('created_at < ?', 1.day.ago)
|
||||
conversations.each do |conversation|
|
||||
conversation.messages.outgoing.all.update(sender: nil)
|
||||
end
|
||||
|
||||
perform_enqueued_jobs do
|
||||
# Resolve all 5 conversations
|
||||
conversations.each(&:resolved!)
|
||||
|
||||
# Reopen 1 conversation
|
||||
conversations.first.open!
|
||||
end
|
||||
|
||||
builder = described_class.new(account, params)
|
||||
metrics = builder.timeseries
|
||||
summary = builder.bot_summary
|
||||
|
||||
# 4 conversations are resolved
|
||||
expect(metrics[Time.zone.today]).to be 4
|
||||
expect(metrics[Time.zone.today - 2.days]).to be 0
|
||||
expect(summary[:bot_resolutions_count]).to be 4
|
||||
end
|
||||
end
|
||||
|
||||
it 'return bot_handoff count' do
|
||||
travel_to(Time.zone.today) do
|
||||
params = {
|
||||
metric: 'bot_handoffs_count',
|
||||
type: :account,
|
||||
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
|
||||
until: Time.zone.today.end_of_day.to_time.to_i.to_s
|
||||
}
|
||||
|
||||
create(:agent_bot_inbox, inbox: account.inboxes.first)
|
||||
conversations = account.conversations.where('created_at < ?', 1.day.ago)
|
||||
conversations.each do |conversation|
|
||||
conversation.pending!
|
||||
conversation.messages.outgoing.all.update(sender: nil)
|
||||
end
|
||||
|
||||
perform_enqueued_jobs do
|
||||
# Resolve all 5 conversations
|
||||
conversations.each(&:bot_handoff!)
|
||||
|
||||
# Reopen 1 conversation
|
||||
conversations.first.open!
|
||||
end
|
||||
|
||||
builder = described_class.new(account, params)
|
||||
metrics = builder.timeseries
|
||||
summary = builder.bot_summary
|
||||
|
||||
# 4 conversations are resolved
|
||||
expect(metrics[Time.zone.today]).to be 5
|
||||
expect(metrics[Time.zone.today - 2.days]).to be 0
|
||||
expect(summary[:bot_handoffs_count]).to be 5
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns average first response time' do
|
||||
params = {
|
||||
metric: 'avg_first_response_time',
|
||||
|
||||
44
spec/builders/v2/reports/bot_metrics_builder_spec.rb
Normal file
44
spec/builders/v2/reports/bot_metrics_builder_spec.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe V2::Reports::BotMetricsBuilder do
|
||||
subject(:bot_metrics_builder) { described_class.new(inbox.account, params) }
|
||||
|
||||
let(:inbox) { create(:inbox) }
|
||||
let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
let!(:unresolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
|
||||
let(:since) { 1.week.ago.to_i.to_s }
|
||||
let(:until_time) { Time.now.to_i.to_s }
|
||||
let(:params) { { since: since, until: until_time } }
|
||||
|
||||
before do
|
||||
create(:agent_bot_inbox, inbox: inbox)
|
||||
create(:message, account: inbox.account, conversation: resolved_conversation, created_at: 2.days.ago, message_type: 'outgoing')
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved', conversation_id: resolved_conversation.id,
|
||||
created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: resolved_conversation.id, created_at: 2.days.ago)
|
||||
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
|
||||
conversation_id: unresolved_conversation.id, created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
describe '#metrics' do
|
||||
context 'with valid params' do
|
||||
it 'returns correct metrics' do
|
||||
metrics = bot_metrics_builder.metrics
|
||||
|
||||
expect(metrics[:conversation_count]).to eq(2)
|
||||
expect(metrics[:message_count]).to eq(1)
|
||||
expect(metrics[:resolution_rate]).to eq(50)
|
||||
expect(metrics[:handoff_rate]).to eq(100)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing params' do
|
||||
let(:params) { {} }
|
||||
|
||||
it 'handles missing since and until params gracefully' do
|
||||
expect { bot_metrics_builder.metrics }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -191,6 +191,48 @@ RSpec.describe 'Reports API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v2/accounts/:account_id/reports/bot_summary' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/bot_summary"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:params) do
|
||||
super().merge(
|
||||
type: :account,
|
||||
since: start_of_today.to_s,
|
||||
until: end_of_today.to_s
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for agents' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/bot_summary",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns bot summary metrics' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/bot_summary",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['bot_resolutions_count']).to eq(0)
|
||||
expect(json_response['bot_handoffs_count']).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v2/accounts/:account_id/reports/agents' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
@@ -399,4 +441,41 @@ RSpec.describe 'Reports API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v2/accounts/:account_id/reports/bot_metrics' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/bot_metrics"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:params) do
|
||||
super().merge(
|
||||
since: 7.days.ago.to_i.to_s,
|
||||
until: end_of_today.to_s
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns unauthorized if the user is an agent' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/bot_metrics",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns values' do
|
||||
expect(V2::Reports::BotMetricsBuilder).to receive(:new).and_call_original
|
||||
get "/api/v2/accounts/#{account.id}/reports/bot_metrics",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body.keys).to match_array(%w[conversation_count message_count resolution_rate handoff_rate])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ FactoryBot.define do
|
||||
factory :agent_bot do
|
||||
name { 'MyString' }
|
||||
description { 'MyString' }
|
||||
outgoing_url { 'MyString' }
|
||||
outgoing_url { 'localhost' }
|
||||
bot_config { {} }
|
||||
bot_type { 'webhook' }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user