Compare commits

...
Author SHA1 Message Date
Shivam Mishra e06a15a432 fix: base scope 2026-01-21 20:52:20 +05:30
Shivam Mishra e5cbc174d5 feat: add sankey generator 2026-01-21 19:15:46 +05:30
Shivam Mishra fc9af7f315 feat: remove duplicate event 2026-01-21 18:53:10 +05:30
Shivam Mishra cba8e83b91 feat: update seeder to match new reporting event 2026-01-21 18:26:45 +05:30
Shivam Mishra 6325bd667a feat: include additional information 2026-01-21 17:55:38 +05:30
Shivam Mishra 92449e273c feat: update reporting_events to include new fields 2026-01-21 17:51:25 +05:30
Shivam Mishra fb81a4fc89 feat(reporting): add conversation_created event to ReportingEventListener
Add a new reporting event that fires when a conversation is created.
This event serves as a denominator for calculating rates like deflection
rate and escalation rate directly from the reporting_events table.
2026-01-21 17:40:04 +05:30
10 changed files with 668 additions and 110 deletions
+48 -43
View File
@@ -1,6 +1,18 @@
class ReportingEventListener < BaseListener class ReportingEventListener < BaseListener
include ReportingEventHelper include ReportingEventHelper
def conversation_created(event)
conversation = extract_conversation_and_account(event)[0]
ReportingEvent.create!(
name: 'conversation_created',
value: 0,
event_start_time: conversation.created_at,
event_end_time: conversation.created_at,
**base_event_attributes(conversation, from_state: nil)
)
end
def conversation_resolved(event) def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0] conversation = extract_conversation_and_account(event)[0]
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
@@ -8,14 +20,10 @@ class ReportingEventListener < BaseListener
reporting_event = ReportingEvent.new( reporting_event = ReportingEvent.new(
name: 'conversation_resolved', name: 'conversation_resolved',
value: time_to_resolve, value: time_to_resolve,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
conversation.updated_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at, event_start_time: conversation.created_at,
event_end_time: conversation.updated_at event_end_time: conversation.updated_at,
**base_event_attributes(conversation, from_state: 'handling')
) )
create_bot_resolved_event(conversation, reporting_event) create_bot_resolved_event(conversation, reporting_event)
@@ -27,20 +35,14 @@ class ReportingEventListener < BaseListener
conversation = message.conversation conversation = message.conversation
first_response_time = message.created_at.to_i - last_non_human_activity(conversation).to_i first_response_time = message.created_at.to_i - last_non_human_activity(conversation).to_i
reporting_event = ReportingEvent.new( ReportingEvent.create!(
name: 'first_response', name: 'first_response',
value: first_response_time, value: first_response_time,
value_in_business_hours: business_hours(conversation.inbox, last_non_human_activity(conversation), value_in_business_hours: business_hours(conversation.inbox, last_non_human_activity(conversation), message.created_at),
message.created_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: message.sender_id,
conversation_id: conversation.id,
event_start_time: last_non_human_activity(conversation), event_start_time: last_non_human_activity(conversation),
event_end_time: message.created_at event_end_time: message.created_at,
**base_event_attributes(conversation, from_state: 'waiting', user_id: message.sender_id)
) )
reporting_event.save!
end end
def reply_created(event) def reply_created(event)
@@ -50,21 +52,16 @@ class ReportingEventListener < BaseListener
return if waiting_since.blank? return if waiting_since.blank?
# When waiting_since is nil, set reply_time to 0
reply_time = message.created_at.to_i - waiting_since.to_i reply_time = message.created_at.to_i - waiting_since.to_i
reporting_event = ReportingEvent.new( ReportingEvent.create!(
name: 'reply_time', name: 'reply_time',
value: reply_time, value: reply_time,
value_in_business_hours: business_hours(conversation.inbox, waiting_since, message.created_at), value_in_business_hours: business_hours(conversation.inbox, waiting_since, message.created_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: waiting_since, event_start_time: waiting_since,
event_end_time: message.created_at event_end_time: message.created_at,
**base_event_attributes(conversation, from_state: 'waiting')
) )
reporting_event.save!
end end
def conversation_bot_handoff(event) def conversation_bot_handoff(event)
@@ -76,18 +73,14 @@ class ReportingEventListener < BaseListener
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
reporting_event = ReportingEvent.new( ReportingEvent.create!(
name: 'conversation_bot_handoff', name: 'conversation_bot_handoff',
value: time_to_handoff, value: time_to_handoff,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at), value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at, event_start_time: conversation.created_at,
event_end_time: conversation.updated_at event_end_time: conversation.updated_at,
**base_event_attributes(conversation, from_state: 'bot_handling')
) )
reporting_event.save!
end end
def conversation_opened(event) def conversation_opened(event)
@@ -99,36 +92,47 @@ class ReportingEventListener < BaseListener
name: 'conversation_resolved' name: 'conversation_resolved'
).order(event_end_time: :desc).first ).order(event_end_time: :desc).first
# For first-time openings, value is 0 # For first-time openings, value is 0, from_state is nil
# For reopenings, calculate time since resolution # For reopenings, calculate time since resolution, from_state is 'resolved'
if last_resolved_event if last_resolved_event
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at) business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
start_time = last_resolved_event.event_end_time start_time = last_resolved_event.event_end_time
from_state = 'resolved'
else else
time_since_resolved = 0 time_since_resolved = 0
business_hours_value = 0 business_hours_value = 0
start_time = conversation.created_at start_time = conversation.created_at
from_state = nil
end end
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time) create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, from_state)
end end
private private
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time) def base_event_attributes(conversation, from_state:, user_id: nil)
reporting_event = ReportingEvent.new( {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: user_id || conversation.assignee_id,
conversation_id: conversation.id,
conversation_created_at: conversation.created_at,
team_id: conversation.team_id,
channel_type: conversation.inbox.channel_type,
from_state: from_state
}
end
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, from_state)
ReportingEvent.create!(
name: 'conversation_opened', name: 'conversation_opened',
value: time_since_resolved, value: time_since_resolved,
value_in_business_hours: business_hours_value, value_in_business_hours: business_hours_value,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: start_time, event_start_time: start_time,
event_end_time: conversation.updated_at event_end_time: conversation.updated_at,
**base_event_attributes(conversation, from_state: from_state)
) )
reporting_event.save!
end end
def create_bot_resolved_event(conversation, reporting_event) def create_bot_resolved_event(conversation, reporting_event)
@@ -138,6 +142,7 @@ class ReportingEventListener < BaseListener
bot_resolved_event = reporting_event.dup bot_resolved_event = reporting_event.dup
bot_resolved_event.name = 'conversation_bot_resolved' bot_resolved_event.name = 'conversation_bot_resolved'
bot_resolved_event.from_state = 'bot_handling'
bot_resolved_event.save! bot_resolved_event.save!
end end
end end
+24 -7
View File
@@ -3,8 +3,11 @@
# Table name: reporting_events # Table name: reporting_events
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# channel_type :string
# conversation_created_at :datetime
# event_end_time :datetime # event_end_time :datetime
# event_start_time :datetime # event_start_time :datetime
# from_state :string
# name :string # name :string
# value :float # value :float
# value_in_business_hours :float # value_in_business_hours :float
@@ -13,17 +16,22 @@
# account_id :integer # account_id :integer
# conversation_id :integer # conversation_id :integer
# inbox_id :integer # inbox_id :integer
# team_id :bigint
# user_id :integer # user_id :integer
# #
# Indexes # Indexes
# #
# index_reporting_events_on_account_id (account_id) # idx_reporting_events_on_account_channel_name_created (account_id,channel_type,name,created_at)
# index_reporting_events_on_conversation_id (conversation_id) # idx_reporting_events_on_account_conv_created_at_name (account_id,conversation_created_at,name)
# index_reporting_events_on_created_at (created_at) # idx_reporting_events_on_account_from_state_name_created (account_id,from_state,name,created_at)
# index_reporting_events_on_inbox_id (inbox_id) # idx_reporting_events_on_account_team_name_created (account_id,team_id,name,created_at)
# index_reporting_events_on_name (name) # index_reporting_events_on_account_id (account_id)
# index_reporting_events_on_user_id (user_id) # index_reporting_events_on_conversation_id (conversation_id)
# reporting_events__account_id__name__created_at (account_id,name,created_at) # index_reporting_events_on_created_at (created_at)
# index_reporting_events_on_inbox_id (inbox_id)
# index_reporting_events_on_name (name)
# index_reporting_events_on_user_id (user_id)
# reporting_events__account_id__name__created_at (account_id,name,created_at)
# #
class ReportingEvent < ApplicationRecord class ReportingEvent < ApplicationRecord
@@ -35,6 +43,7 @@ class ReportingEvent < ApplicationRecord
belongs_to :user, optional: true belongs_to :user, optional: true
belongs_to :inbox, optional: true belongs_to :inbox, optional: true
belongs_to :conversation, optional: true belongs_to :conversation, optional: true
belongs_to :team, optional: true
# Scopes for filtering # Scopes for filtering
scope :filter_by_date_range, lambda { |range| scope :filter_by_date_range, lambda { |range|
@@ -52,4 +61,12 @@ class ReportingEvent < ApplicationRecord
scope :filter_by_name, lambda { |name| scope :filter_by_name, lambda { |name|
where(name: name) if name.present? where(name: name) if name.present?
} }
scope :filter_by_team_id, lambda { |team_id|
where(team_id: team_id) if team_id.present?
}
scope :filter_by_channel_type, lambda { |channel_type|
where(channel_type: channel_type) if channel_type.present?
}
end end
@@ -0,0 +1,19 @@
class AddFieldsToReportingEvents < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :reporting_events, :conversation_created_at, :datetime
add_column :reporting_events, :from_state, :string
add_column :reporting_events, :team_id, :bigint
add_column :reporting_events, :channel_type, :string
add_index :reporting_events, [:account_id, :conversation_created_at, :name],
algorithm: :concurrently, name: 'idx_reporting_events_on_account_conv_created_at_name'
add_index :reporting_events, [:account_id, :from_state, :name, :created_at],
algorithm: :concurrently, name: 'idx_reporting_events_on_account_from_state_name_created'
add_index :reporting_events, [:account_id, :team_id, :name, :created_at],
algorithm: :concurrently, name: 'idx_reporting_events_on_account_team_name_created'
add_index :reporting_events, [:account_id, :channel_type, :name, :created_at],
algorithm: :concurrently, name: 'idx_reporting_events_on_account_channel_name_created'
end
end
+9 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do ActiveRecord::Schema[7.1].define(version: 2026_01_21_121101) do
# These extensions should be enabled to support this database # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@@ -1114,7 +1114,15 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
t.float "value_in_business_hours" t.float "value_in_business_hours"
t.datetime "event_start_time", precision: nil t.datetime "event_start_time", precision: nil
t.datetime "event_end_time", precision: nil t.datetime "event_end_time", precision: nil
t.datetime "conversation_created_at"
t.string "from_state"
t.bigint "team_id"
t.string "channel_type"
t.index ["account_id", "channel_type", "name", "created_at"], name: "idx_reporting_events_on_account_channel_name_created"
t.index ["account_id", "conversation_created_at", "name"], name: "idx_reporting_events_on_account_conv_created_at_name"
t.index ["account_id", "from_state", "name", "created_at"], name: "idx_reporting_events_on_account_from_state_name_created"
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at" t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
t.index ["account_id", "team_id", "name", "created_at"], name: "idx_reporting_events_on_account_team_name_created"
t.index ["account_id"], name: "index_reporting_events_on_account_id" t.index ["account_id"], name: "index_reporting_events_on_account_id"
t.index ["conversation_id"], name: "index_reporting_events_on_conversation_id" t.index ["conversation_id"], name: "index_reporting_events_on_conversation_id"
t.index ["created_at"], name: "index_reporting_events_on_created_at" t.index ["created_at"], name: "index_reporting_events_on_created_at"
+234
View File
@@ -0,0 +1,234 @@
# Usage: bundle exec rails runner lib/scripts/sankey_data_generator.rb [account_id] [days]
# Example: bundle exec rails runner lib/scripts/sankey_data_generator.rb 2 30
#
# Generates Sankey diagram data from reporting_events for conversation flow visualization.
# The flow shows: Conversations → Bot/Direct → Resolution paths
class SankeyDataGenerator
def initialize(account_id:, days: 90)
@account_id = account_id
@days = days
end
def generate
{
nodes: build_nodes,
links: build_links
}
end
def to_json(*_args)
JSON.pretty_generate(generate)
end
private
def base_scope
ReportingEvent.where(account_id: @account_id).where('conversation_created_at >= ?', @days.days.ago)
end
def conversation_created
@conversation_created ||= base_scope.where(name: 'conversation_created').distinct.count(:conversation_id)
end
def bot_handoff
@bot_handoff ||= base_scope.where(name: 'conversation_bot_handoff').distinct.count(:conversation_id)
end
def bot_resolved
@bot_resolved ||= begin
# Exclude conversations that were later handed off (they go through handoff flow instead)
bot_resolved_ids = base_scope.where(name: 'conversation_bot_resolved').distinct.pluck(:conversation_id)
handoff_ids = base_scope.where(name: 'conversation_bot_handoff').distinct.pluck(:conversation_id)
(bot_resolved_ids - handoff_ids).count
end
end
def agent_resolved
@agent_resolved ||= base_scope.where(name: 'conversation_resolved').distinct.count(:conversation_id)
end
def reopened_conversation_ids
@reopened_conversation_ids ||= base_scope
.where(name: 'conversation_opened', from_state: 'resolved')
.distinct
.pluck(:conversation_id)
end
def reopened
@reopened ||= reopened_conversation_ids.count
end
# Reopened from bot_resolved (exclusive - not handed off after)
def reopened_from_bot
@reopened_from_bot ||= begin
bot_resolved_ids = base_scope.where(name: 'conversation_bot_resolved').pluck(:conversation_id)
handoff_ids = base_scope.where(name: 'conversation_bot_handoff').pluck(:conversation_id)
# Only count if bot_resolved but NOT handed off (stayed in bot flow)
bot_only_ids = bot_resolved_ids - handoff_ids
(reopened_conversation_ids & bot_only_ids).count
end
end
# Reopened from handoff_resolved (went through handoff path)
def reopened_from_handoff
@reopened_from_handoff ||= begin
handoff_ids = base_scope.where(name: 'conversation_bot_handoff').pluck(:conversation_id)
resolved_after_handoff_ids = base_scope
.where(name: 'conversation_resolved', conversation_id: handoff_ids)
.pluck(:conversation_id)
(reopened_conversation_ids & resolved_after_handoff_ids).count
end
end
# Reopened from direct agent resolution (no bot involvement)
def reopened_from_direct
@reopened_from_direct ||= [reopened - reopened_from_bot - reopened_from_handoff, 0].max
end
# Conversations assigned to bot (created in bot-enabled inboxes)
def bot_assigned
@bot_assigned ||= begin
return 0 if bot_inbox_ids.empty?
base_scope.where(name: 'conversation_created', inbox_id: bot_inbox_ids).distinct.count(:conversation_id)
end
end
# Conversations that went directly to agent (no bot involvement)
def direct_to_agent
@direct_to_agent ||= [conversation_created - bot_assigned, 0].max
end
# Count conversations that have both bot_handoff AND conversation_resolved events
def resolved_after_handoff
@resolved_after_handoff ||= begin
handoff_conversation_ids = base_scope
.where(name: 'conversation_bot_handoff')
.distinct
.pluck(:conversation_id)
return 0 if handoff_conversation_ids.empty?
base_scope
.where(name: 'conversation_resolved')
.where(conversation_id: handoff_conversation_ids)
.distinct
.count(:conversation_id)
end
end
# Agent resolutions from direct conversations (no bot involvement)
def resolved_direct_by_agent
@resolved_direct_by_agent ||= begin
# Get conversation IDs that went direct to agent (not in bot inboxes)
direct_conversation_ids = base_scope
.where(name: 'conversation_created')
.where.not(inbox_id: bot_inbox_ids)
.distinct
.pluck(:conversation_id)
return 0 if direct_conversation_ids.empty?
base_scope
.where(name: 'conversation_resolved')
.where(conversation_id: direct_conversation_ids)
.distinct
.count(:conversation_id)
end
end
def bot_inbox_ids
@bot_inbox_ids ||= Inbox.where(account_id: @account_id).select(&:active_bot?).map(&:id)
end
# Unresolved at bot level (assigned to bot but not resolved or handed off yet)
def unresolved_at_bot
@unresolved_at_bot ||= [bot_assigned - bot_resolved - bot_handoff, 0].max
end
# Unresolved after handoff (handed off but not yet resolved)
def unresolved_after_handoff
@unresolved_after_handoff ||= [bot_handoff - resolved_after_handoff, 0].max
end
# Unresolved direct (went to agent but not yet resolved)
def unresolved_direct
@unresolved_direct ||= [direct_to_agent - resolved_direct_by_agent, 0].max
end
def build_nodes
[
{ id: 'conversations', label: 'Conversations', value: conversation_created, subgroups: %w[bot_flow agent_direct] },
{ id: 'bot_flow', label: 'Assigned to Bot', value: bot_assigned,
subgroups: %w[bot_resolved bot_handoff bot_unresolved] },
{ id: 'agent_direct', label: 'Direct to Agent', value: direct_to_agent,
subgroups: %w[agent_direct_resolved agent_direct_unresolved] },
{ id: 'bot_resolved', label: 'Resolved by Bot', value: bot_resolved,
subgroups: %w[bot_resolved_final bot_resolved_reopened] },
{ id: 'bot_resolved_final', label: 'Stayed Resolved', value: bot_resolved - reopened_from_bot, subgroups: [] },
{ id: 'bot_resolved_reopened', label: 'Reopened', value: reopened_from_bot, subgroups: [] },
{ id: 'bot_handoff', label: 'Handed off to Agent', value: bot_handoff,
subgroups: %w[handoff_resolved handoff_unresolved] },
{ id: 'bot_unresolved', label: 'Unresolved (Bot)', value: unresolved_at_bot, subgroups: [] },
{ id: 'handoff_resolved', label: 'Resolved by Agent', value: resolved_after_handoff,
subgroups: %w[handoff_resolved_final handoff_resolved_reopened] },
{ id: 'handoff_resolved_final', label: 'Stayed Resolved', value: resolved_after_handoff - reopened_from_handoff, subgroups: [] },
{ id: 'handoff_resolved_reopened', label: 'Reopened', value: reopened_from_handoff, subgroups: [] },
{ id: 'handoff_unresolved', label: 'Unresolved', value: unresolved_after_handoff, subgroups: [] },
{ id: 'agent_direct_resolved', label: 'Resolved by Agent', value: resolved_direct_by_agent,
subgroups: %w[agent_direct_resolved_final agent_direct_resolved_reopened] },
{ id: 'agent_direct_resolved_final', label: 'Stayed Resolved', value: resolved_direct_by_agent - reopened_from_direct, subgroups: [] },
{ id: 'agent_direct_resolved_reopened', label: 'Reopened', value: reopened_from_direct, subgroups: [] },
{ id: 'agent_direct_unresolved', label: 'Unresolved', value: unresolved_direct, subgroups: [] }
]
end
def build_links
[
{ source: 'root', target: 'conversations', value: conversation_created },
{ source: 'conversations', target: 'bot_flow', value: bot_assigned },
{ source: 'conversations', target: 'agent_direct', value: direct_to_agent },
{ source: 'bot_flow', target: 'bot_resolved', value: bot_resolved },
{ source: 'bot_flow', target: 'bot_handoff', value: bot_handoff },
{ source: 'bot_flow', target: 'bot_unresolved', value: unresolved_at_bot },
{ source: 'bot_resolved', target: 'bot_resolved_final', value: bot_resolved - reopened_from_bot },
{ source: 'bot_resolved', target: 'bot_resolved_reopened', value: reopened_from_bot },
{ source: 'bot_handoff', target: 'handoff_resolved', value: resolved_after_handoff },
{ source: 'bot_handoff', target: 'handoff_unresolved', value: unresolved_after_handoff },
{ source: 'handoff_resolved', target: 'handoff_resolved_final', value: resolved_after_handoff - reopened_from_handoff },
{ source: 'handoff_resolved', target: 'handoff_resolved_reopened', value: reopened_from_handoff },
{ source: 'agent_direct', target: 'agent_direct_resolved', value: resolved_direct_by_agent },
{ source: 'agent_direct', target: 'agent_direct_unresolved', value: unresolved_direct },
{ source: 'agent_direct_resolved', target: 'agent_direct_resolved_final', value: resolved_direct_by_agent - reopened_from_direct },
{ source: 'agent_direct_resolved', target: 'agent_direct_resolved_reopened', value: reopened_from_direct }
]
end
end
if __FILE__ == $PROGRAM_NAME
account_id = ARGV[0]&.to_i || 2
days = ARGV[1]&.to_i || 90
generator = SankeyDataGenerator.new(account_id: account_id, days: days)
puts generator.to_json
end
+75 -3
View File
@@ -13,6 +13,7 @@ class Seeders::Reports::ConversationCreator
@teams = resources[:teams] @teams = resources[:teams]
@labels = resources[:labels] @labels = resources[:labels]
@agents = resources[:agents] @agents = resources[:agents]
@captain_assistant = resources[:captain_assistant]
@priorities = [nil, 'urgent', 'high', 'medium', 'low'] @priorities = [nil, 'urgent', 'high', 'medium', 'low']
end end
@@ -28,7 +29,13 @@ class Seeders::Reports::ConversationCreator
conversation.save! conversation.save!
add_labels_to_conversation(conversation) add_labels_to_conversation(conversation)
create_messages_for_conversation(conversation)
# Check if this is a bot inbox or human-only inbox
if inbox_has_captain?(conversation.inbox)
create_bot_conversation_flow(conversation, created_at)
else
create_human_conversation_flow(conversation)
end
# Determine if should resolve but don't update yet # Determine if should resolve but don't update yet
should_resolve = rand > 0.3 should_resolve = rand > 0.3
@@ -100,15 +107,80 @@ class Seeders::Reports::ConversationCreator
conversation.update_labels(labels_to_add.map(&:title)) conversation.update_labels(labels_to_add.map(&:title))
end end
def create_messages_for_conversation(conversation) def create_messages_for_conversation(conversation, include_bot: false)
message_creator = Seeders::Reports::MessageCreator.new( message_creator = Seeders::Reports::MessageCreator.new(
account: @account, account: @account,
agents: @agents, agents: @agents,
conversation: conversation conversation: conversation,
captain_assistant: include_bot ? @captain_assistant : nil
) )
message_creator.create_messages message_creator.create_messages
end end
def inbox_has_captain?(inbox)
return false unless @captain_assistant && inbox.respond_to?(:captain_assistant)
inbox.captain_assistant.present?
end
def create_bot_conversation_flow(conversation, _created_at)
message_creator = Seeders::Reports::MessageCreator.new(
account: @account,
agents: @agents,
conversation: conversation,
captain_assistant: @captain_assistant
)
# Create initial customer message
message_creator.create_incoming_message
# Add delay for bot response
travel(rand((5.seconds)..(30.seconds)))
# Create bot response
message_creator.create_bot_message
# Randomly decide outcome: 30% bot resolves, 50% handoff to human, 20% abandoned
outcome = rand(100)
if outcome < 30
# Bot resolves - no more messages needed, will be resolved later
nil
elsif outcome < 80
# Bot hands off to human - use travel to move forward from current time
travel(rand((1.minute)..(10.minutes)))
trigger_bot_handoff_event(conversation)
# Continue with human agent messages
create_human_messages_after_handoff(message_creator)
end
# else: 20% abandoned - no further action
end
def create_human_conversation_flow(conversation)
create_messages_for_conversation(conversation)
end
def create_human_messages_after_handoff(message_creator)
# Create a few more human agent messages after handoff
message_count = rand(2..4)
message_count.times do |i|
travel(rand((1.minute)..(30.minutes)))
if i.even?
message_creator.create_incoming_message
else
message_creator.create_human_outgoing_message
end
end
end
def trigger_bot_handoff_event(conversation)
event_data = { conversation: conversation }
ReportingEventListener.instance.conversation_bot_handoff(
Events::Base.new('conversation_bot_handoff', Time.current, event_data)
)
end
def trigger_conversation_resolved_event(conversation) def trigger_conversation_resolved_event(conversation)
event_data = { conversation: conversation } event_data = { conversation: conversation }
+73 -11
View File
@@ -8,10 +8,11 @@ class Seeders::Reports::MessageCreator
MESSAGES_PER_CONVERSATION = 5 MESSAGES_PER_CONVERSATION = 5
def initialize(account:, agents:, conversation:) def initialize(account:, agents:, conversation:, captain_assistant: nil)
@account = account @account = account
@agents = agents @agents = agents
@conversation = conversation @conversation = conversation
@captain_assistant = captain_assistant
end end
def create_messages def create_messages
@@ -37,6 +38,44 @@ class Seeders::Reports::MessageCreator
false # No longer first reply after any agent message false # No longer first reply after any agent message
end end
def create_incoming_message
@conversation.messages.create!(
account: @account,
inbox: @conversation.inbox,
message_type: :incoming,
content: generate_customer_message_content,
sender: @conversation.contact
)
end
def create_bot_message
return unless @captain_assistant
@conversation.messages.create!(
account: @account,
inbox: @conversation.inbox,
message_type: :outgoing,
content: generate_bot_response_content,
sender: @captain_assistant
)
end
def create_human_outgoing_message
sender = @conversation.assignee || @agents.sample
message = @conversation.messages.create!(
account: @account,
inbox: @conversation.inbox,
message_type: :outgoing,
content: generate_message_content,
sender: sender
)
# Trigger reply events for human messages after handoff
trigger_reply_event(message)
message
end
private private
def add_realistic_delay(_message_index, is_incoming) def add_realistic_delay(_message_index, is_incoming)
@@ -64,16 +103,6 @@ class Seeders::Reports::MessageCreator
end end
end end
def create_incoming_message
@conversation.messages.create!(
account: @account,
inbox: @conversation.inbox,
message_type: :incoming,
content: generate_message_content,
sender: @conversation.contact
)
end
def create_outgoing_message def create_outgoing_message
sender = @conversation.assignee || @agents.sample sender = @conversation.assignee || @agents.sample
@@ -90,6 +119,39 @@ class Seeders::Reports::MessageCreator
Faker::Lorem.paragraph(sentence_count: rand(1..5)) Faker::Lorem.paragraph(sentence_count: rand(1..5))
end end
def generate_customer_message_content
customer_messages = [
'Hi, I need help with my order.',
'Can you assist me with a billing issue?',
'I have a question about my account.',
"I'm having trouble logging in.",
'How do I change my password?',
'When will my order arrive?',
"I'd like to request a refund.",
'Can you help me update my shipping address?',
'I received the wrong item.',
'Is there a way to track my package?',
Faker::Lorem.paragraph(sentence_count: rand(1..3))
]
customer_messages.sample
end
def generate_bot_response_content
bot_responses = [
"Hello! I'm here to help. Let me look into that for you.",
'Thank you for reaching out. I can assist you with this.',
'I understand your concern. Let me check our records.',
'Sure, I can help with that! Could you provide more details?',
"I've found some information that might help.",
'Let me connect you with a specialist who can better assist you.',
'Based on your question, here are some options available to you.',
'I can see your account details. Let me review this for you.',
"Thank you for your patience. I'm processing your request now.",
'Is there anything else I can help you with today?'
]
bot_responses.sample
end
def handle_agent_reply_events(message, is_first_reply) def handle_agent_reply_events(message, is_first_reply)
if is_first_reply if is_first_reply
trigger_first_reply_event(message) trigger_first_reply_event(message)
+78 -32
View File
@@ -8,7 +8,7 @@
# to generate historical data with realistic timestamps. # to generate historical data with realistic timestamps.
# #
# Usage: # Usage:
# ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data # ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data
# #
# This will create: # This will create:
# - 1000 conversations with realistic message exchanges # - 1000 conversations with realistic message exchanges
@@ -25,29 +25,36 @@ require 'faker'
require_relative 'conversation_creator' require_relative 'conversation_creator'
require_relative 'message_creator' require_relative 'message_creator'
# rubocop:disable Rails/Output # rubocop:disable Rails/Output, Metrics/ClassLength
class Seeders::Reports::ReportDataSeeder class Seeders::Reports::ReportDataSeeder
include ActiveSupport::Testing::TimeHelpers include ActiveSupport::Testing::TimeHelpers
TOTAL_CONVERSATIONS = 1000
TOTAL_CONTACTS = 100
TOTAL_AGENTS = 20
TOTAL_TEAMS = 5
TOTAL_LABELS = 30
TOTAL_INBOXES = 3 TOTAL_INBOXES = 3
MESSAGES_PER_CONVERSATION = 5
START_DATE = 3.months.ago # rubocop:disable Rails/RelativeDateConstant
END_DATE = Time.current
def initialize(account:) def initialize(account:, config: {})
raise 'Account Seeding is not allowed.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?) raise 'Account Seeding is not allowed.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
@account = account @account = account
initialize_config(config)
@teams = [] @teams = []
@agents = [] @agents = []
@labels = [] @labels = []
@inboxes = [] @inboxes = []
@contacts = [] @contacts = []
@captain_assistant = nil
end
def initialize_config(config)
defaults = { total_conversations: 1000, total_contacts: 100, total_agents: 20,
total_teams: 5, total_labels: 30, days_range: 90 }
config = defaults.merge(config)
@total_conversations = config[:total_conversations]
@total_contacts = config[:total_contacts]
@total_agents = config[:total_agents]
@total_teams = config[:total_teams]
@total_labels = config[:total_labels]
@days_range = config[:days_range]
end end
def perform! def perform!
@@ -56,6 +63,7 @@ class Seeders::Reports::ReportDataSeeder
# Clear existing data # Clear existing data
clear_existing_data clear_existing_data
create_captain_assistant
create_teams create_teams
create_agents create_agents
create_labels create_labels
@@ -78,26 +86,38 @@ class Seeders::Reports::ReportDataSeeder
@account.contacts.destroy_all @account.contacts.destroy_all
@account.agents.destroy_all @account.agents.destroy_all
@account.reporting_events.destroy_all @account.reporting_events.destroy_all
Captain::Assistant.where(account: @account).destroy_all if defined?(Captain::Assistant)
end
def create_captain_assistant
return unless defined?(Captain::Assistant)
@captain_assistant = Captain::Assistant.create!(
account: @account,
name: 'Support Bot',
description: 'AI assistant for customer support'
)
puts 'Created Captain assistant: Support Bot'
end end
def create_teams def create_teams
TOTAL_TEAMS.times do |i| @total_teams.times do |i|
team = @account.teams.create!( team = @account.teams.create!(
name: "#{Faker::Company.industry} Team #{i + 1}" name: "#{Faker::Company.industry} Team #{i + 1}"
) )
@teams << team @teams << team
print "\rCreating teams: #{i + 1}/#{TOTAL_TEAMS}" print "\rCreating teams: #{i + 1}/#{@total_teams}"
end end
print "\n" print "\n"
end end
def create_agents def create_agents
TOTAL_AGENTS.times do |i| @total_agents.times do |i|
user = create_single_agent(i) user = create_single_agent(i)
assign_agent_to_teams(user) assign_agent_to_teams(user)
@agents << user @agents << user
print "\rCreating agents: #{i + 1}/#{TOTAL_AGENTS}" print "\rCreating agents: #{i + 1}/#{@total_agents}"
end end
print "\n" print "\n"
@@ -134,22 +154,33 @@ class Seeders::Reports::ReportDataSeeder
end end
def create_labels def create_labels
TOTAL_LABELS.times do |i| @total_labels.times do |i|
label = @account.labels.create!( label = @account.labels.create!(
title: "Label-#{i + 1}-#{Faker::Lorem.word}", title: "Label-#{i + 1}-#{Faker::Lorem.word}",
description: Faker::Company.catch_phrase, description: Faker::Company.catch_phrase,
color: Faker::Color.hex_color color: Faker::Color.hex_color
) )
@labels << label @labels << label
print "\rCreating labels: #{i + 1}/#{TOTAL_LABELS}" print "\rCreating labels: #{i + 1}/#{@total_labels}"
end end
print "\n" print "\n"
end end
def create_inboxes def create_inboxes
TOTAL_INBOXES.times do |_i| # Create inboxes: 2/3 with Captain (bot inboxes) and 1/3 without (human-only)
inbox = create_single_inbox bot_inbox_count = (TOTAL_INBOXES * 2 / 3.0).ceil
human_inbox_count = TOTAL_INBOXES - bot_inbox_count
bot_inbox_count.times do |i|
inbox = create_web_inbox_with_captain("Support Bot Inbox #{i + 1}")
assign_agents_to_inbox(inbox)
@inboxes << inbox
print "\rCreating inboxes: #{@inboxes.size}/#{TOTAL_INBOXES}"
end
human_inbox_count.times do |i|
inbox = create_web_inbox("Human Support Inbox #{i + 1}")
assign_agents_to_inbox(inbox) assign_agents_to_inbox(inbox)
@inboxes << inbox @inboxes << inbox
print "\rCreating inboxes: #{@inboxes.size}/#{TOTAL_INBOXES}" print "\rCreating inboxes: #{@inboxes.size}/#{TOTAL_INBOXES}"
@@ -158,14 +189,27 @@ class Seeders::Reports::ReportDataSeeder
print "\n" print "\n"
end end
def create_single_inbox def create_web_inbox_with_captain(name)
inbox = create_web_inbox(name)
if @captain_assistant && defined?(CaptainInbox)
CaptainInbox.create!(
captain_assistant: @captain_assistant,
inbox: inbox
)
end
inbox
end
def create_web_inbox(name)
channel = Channel::WebWidget.create!( channel = Channel::WebWidget.create!(
website_url: "https://#{Faker::Internet.domain_name}", website_url: "https://#{Faker::Internet.domain_name}",
account_id: @account.id account_id: @account.id
) )
@account.inboxes.create!( @account.inboxes.create!(
name: "#{Faker::Company.name} Website", name: name,
channel: channel channel: channel
) )
end end
@@ -176,9 +220,10 @@ class Seeders::Reports::ReportDataSeeder
@agents @agents
else else
# Subsequent inboxes get random selection with some overlap # Subsequent inboxes get random selection with some overlap
min_agents = [@agents.size / TOTAL_INBOXES, 10].max min_agents = [(@agents.size / TOTAL_INBOXES), 1].max
max_agents = [(@agents.size * 0.8).to_i, 50].min max_agents = [(@agents.size * 0.8).to_i, @agents.size].min
@agents.sample(rand(min_agents..max_agents)) sample_count = rand([min_agents, max_agents].min..[min_agents, max_agents].max)
@agents.sample(sample_count)
end end
agents_to_assign.each do |agent| agents_to_assign.each do |agent|
@@ -187,7 +232,7 @@ class Seeders::Reports::ReportDataSeeder
end end
def create_contacts def create_contacts
TOTAL_CONTACTS.times do |i| @total_contacts.times do |i|
contact = @account.contacts.create!( contact = @account.contacts.create!(
name: Faker::Name.name, name: Faker::Name.name,
email: Faker::Internet.email, email: Faker::Internet.email,
@@ -202,7 +247,7 @@ class Seeders::Reports::ReportDataSeeder
) )
@contacts << contact @contacts << contact
print "\rCreating contacts: #{i + 1}/#{TOTAL_CONTACTS}" print "\rCreating contacts: #{i + 1}/#{@total_contacts}"
end end
print "\n" print "\n"
@@ -216,19 +261,20 @@ class Seeders::Reports::ReportDataSeeder
inboxes: @inboxes, inboxes: @inboxes,
teams: @teams, teams: @teams,
labels: @labels, labels: @labels,
agents: @agents agents: @agents,
captain_assistant: @captain_assistant
} }
) )
TOTAL_CONVERSATIONS.times do |i| @total_conversations.times do |i|
created_at = Faker::Time.between(from: START_DATE, to: END_DATE) created_at = Faker::Time.between(from: @days_range.days.ago, to: Time.current)
conversation_creator.create_conversation(created_at: created_at) conversation_creator.create_conversation(created_at: created_at)
completion_percentage = ((i + 1).to_f / TOTAL_CONVERSATIONS * 100).round completion_percentage = ((i + 1).to_f / @total_conversations * 100).round
print "\rCreating conversations: #{i + 1}/#{TOTAL_CONVERSATIONS} (#{completion_percentage}%)" print "\rCreating conversations: #{i + 1}/#{@total_conversations} (#{completion_percentage}%)"
end end
print "\n" print "\n"
end end
end end
# rubocop:enable Rails/Output # rubocop:enable Rails/Output, Metrics/ClassLength
+41 -12
View File
@@ -1,24 +1,53 @@
# rubocop:disable Rails/Output, Metrics/BlockLength
namespace :db do namespace :db do
namespace :seed do namespace :seed do
desc 'Seed test data for reports with conversations, contacts, agents, teams, and realistic reporting events' desc 'Seed test data for reports with conversations, contacts, agents, teams, and realistic reporting events'
task reports_data: :environment do task reports_data: :environment do
if ENV['ACCOUNT_ID'].blank? abort 'This task can only be run in development environment.' unless Rails.env.development?
puts 'Please provide an ACCOUNT_ID environment variable' abort 'ENABLE_ACCOUNT_SEEDING must be set to true.' unless ENV['ENABLE_ACCOUNT_SEEDING'] == 'true'
puts 'Usage: ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data'
exit 1
end
ENV['ENABLE_ACCOUNT_SEEDING'] = 'true' if ENV['ENABLE_ACCOUNT_SEEDING'].blank? account = prompt_for_account
config = prompt_for_config
abort 'Seeding cancelled.' unless confirm_seeding(account)
account_id = ENV.fetch('ACCOUNT_ID', nil) seeder = Seeders::Reports::ReportDataSeeder.new(account: account, config: config)
account = Account.find(account_id)
puts "Starting reports data seeding for account: #{account.name} (ID: #{account.id})"
seeder = Seeders::Reports::ReportDataSeeder.new(account: account)
seeder.perform! seeder.perform!
puts "Finished seeding reports data for account: #{account.name}" puts "Finished seeding reports data for account: #{account.name}"
end end
def prompt(message)
print "#{message}: "
$stdin.gets.chomp
end
def prompt_with_default(message, default)
input = prompt("#{message} (default: #{default})")
input.blank? ? default : input.to_i
end
def prompt_for_account
input = prompt('Enter Account ID')
account = Account.find_by(id: input)
abort "Account with ID #{input} not found." if account.nil?
account
end
def prompt_for_config
{
total_conversations: prompt_with_default('Total conversations', 1000),
total_contacts: prompt_with_default('Total contacts', 100),
total_agents: prompt_with_default('Total agents', 20),
total_teams: prompt_with_default('Total teams', 5),
total_labels: prompt_with_default('Total labels', 30),
days_range: prompt_with_default('Days of data to generate', 90)
}
end
def confirm_seeding(account)
puts "\nWARNING: This will DELETE all existing data for account '#{account.name}' (ID: #{account.id})"
prompt('Are you sure you want to proceed? (yes/no)').downcase == 'yes'
end
end end
end end
# rubocop:enable Rails/Output, Metrics/BlockLength
@@ -10,6 +10,57 @@ describe ReportingEventListener do
account: account, inbox: inbox, conversation: conversation) account: account, inbox: inbox, conversation: conversation)
end end
describe '#conversation_created' do
it 'creates conversation_created event' do
expect(account.reporting_events.where(name: 'conversation_created').count).to be 0
event = Events::Base.new('conversation.created', Time.zone.now, conversation: conversation)
listener.conversation_created(event)
expect(account.reporting_events.where(name: 'conversation_created').count).to be 1
end
it 'sets correct attributes for conversation_created event' do
event = Events::Base.new('conversation.created', Time.zone.now, conversation: conversation)
listener.conversation_created(event)
created_event = account.reporting_events.find_by(name: 'conversation_created')
expect(created_event.value).to eq 0
expect(created_event.account_id).to eq(account.id)
expect(created_event.inbox_id).to eq(inbox.id)
expect(created_event.conversation_id).to eq(conversation.id)
expect(created_event.user_id).to eq(user.id)
expect(created_event.event_start_time).to be_within(1.second).of(conversation.created_at)
expect(created_event.event_end_time).to be_within(1.second).of(conversation.created_at)
expect(created_event.from_state).to be_nil
expect(created_event.conversation_created_at).to be_within(1.second).of(conversation.created_at)
expect(created_event.channel_type).to eq(inbox.channel_type)
end
context 'when conversation has no assignee' do
let(:unassigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil) }
it 'creates conversation_created event with nil user_id' do
event = Events::Base.new('conversation.created', Time.zone.now, conversation: unassigned_conversation)
listener.conversation_created(event)
created_event = account.reporting_events.find_by(name: 'conversation_created', conversation_id: unassigned_conversation.id)
expect(created_event.user_id).to be_nil
end
end
context 'when conversation has team assigned' do
let(:team) { create(:team, account: account) }
let(:team_conversation) { create(:conversation, account: account, inbox: inbox, assignee: user, team: team) }
it 'sets team_id on the event' do
event = Events::Base.new('conversation.created', Time.zone.now, conversation: team_conversation)
listener.conversation_created(event)
created_event = account.reporting_events.find_by(name: 'conversation_created', conversation_id: team_conversation.id)
expect(created_event.team_id).to eq(team.id)
end
end
end
describe '#conversation_resolved' do describe '#conversation_resolved' do
it 'creates conversation_resolved event' do it 'creates conversation_resolved event' do
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 0 expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 0
@@ -18,6 +69,16 @@ describe ReportingEventListener do
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1 expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
end end
it 'sets from_state to handling' do
event = Events::Base.new('conversation.resolved', Time.zone.now, conversation: conversation)
listener.conversation_resolved(event)
resolved_event = account.reporting_events.find_by(name: 'conversation_resolved')
expect(resolved_event.from_state).to eq('handling')
expect(resolved_event.conversation_created_at).to be_within(1.second).of(conversation.created_at)
expect(resolved_event.channel_type).to eq(inbox.channel_type)
end
context 'when business hours enabled for inbox' do context 'when business hours enabled for inbox' do
let(:created_at) { Time.zone.parse('March 20, 2022 00:00') } let(:created_at) { Time.zone.parse('March 20, 2022 00:00') }
let(:updated_at) { Time.zone.parse('March 26, 2022 23:59') } let(:updated_at) { Time.zone.parse('March 26, 2022 23:59') }
@@ -246,6 +307,9 @@ describe ReportingEventListener do
listener.conversation_bot_handoff(event) listener.conversation_bot_handoff(event)
expect(account.reporting_events.where(name: 'conversation_bot_handoff').count).to be 1 expect(account.reporting_events.where(name: 'conversation_bot_handoff').count).to be 1
handoff_event = account.reporting_events.find_by(name: 'conversation_bot_handoff')
expect(handoff_event.from_state).to eq('bot_handling')
# add extra handoff event for the same and ensure it's not created # add extra handoff event for the same and ensure it's not created
event = Events::Base.new('conversation.bot_handoff', Time.zone.now, conversation: conversation) event = Events::Base.new('conversation.bot_handoff', Time.zone.now, conversation: conversation)
listener.conversation_bot_handoff(event) listener.conversation_bot_handoff(event)
@@ -272,7 +336,7 @@ describe ReportingEventListener do
context 'when conversation is opened for the first time' do context 'when conversation is opened for the first time' do
let(:new_conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) } let(:new_conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
it 'creates conversation_opened event with value 0' do it 'creates conversation_opened event with value 0 and nil from_state' do
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0 expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation) event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation)
listener.conversation_opened(event) listener.conversation_opened(event)
@@ -283,6 +347,7 @@ describe ReportingEventListener do
expect(opened_event.value_in_business_hours).to eq 0 expect(opened_event.value_in_business_hours).to eq 0
expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at) expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at)
expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at) expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at)
expect(opened_event.from_state).to be_nil
end end
end end
@@ -332,6 +397,7 @@ describe ReportingEventListener do
expect(reopened_event.inbox_id).to eq(inbox.id) expect(reopened_event.inbox_id).to eq(inbox.id)
expect(reopened_event.conversation_id).to eq(reopened_conversation.id) expect(reopened_event.conversation_id).to eq(reopened_conversation.id)
expect(reopened_event.user_id).to eq(user.id) expect(reopened_event.user_id).to eq(user.id)
expect(reopened_event.from_state).to eq('resolved')
end end
context 'when business hours enabled for inbox' do context 'when business hours enabled for inbox' do