Reworks the Captain assistant "hours saved" metric so it produces a believable number. It previously multiplied assistant reply count by customer reply-wait time, which inflated the figure into the millions of hours. It now estimates saved time as replies × a fixed ~2 min per-reply effort assumption. On the overview card the value renders in days once it passes 100 hours, and the label/hint copy was updated to match. ## How to test 1. Open the Captain assistant overview page. 2. Check the "Time saved" card shows a sensible value (hours, or days past 100h). 3. Hover the hint and confirm it reflects the per-reply estimate.
204 lines
6.5 KiB
Ruby
204 lines
6.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'faker'
|
|
require 'active_support/testing/time_helpers'
|
|
|
|
# Seeds Captain assistant activity for the reports/overview test data.
|
|
#
|
|
# Produces a variety of assistant-handled conversations in a single web inbox so
|
|
# every Captain assistant overview metric (handled, auto-resolution, handoff,
|
|
# hours saved, reopen rate, conversation depth) has realistic data:
|
|
# - :resolved_by_assistant assistant answers and Captain auto-resolves
|
|
# - :handled_by_both assistant answers, a human also replies and resolves
|
|
# - :handed_off assistant answers, then hands off to a human
|
|
# - :resolved_and_reopened Captain resolves, then the conversation reopens
|
|
#
|
|
# Reporting events are fired through ReportingEventListener directly (mirroring
|
|
# ConversationCreator) so the same rows the builder reads from get populated.
|
|
class Seeders::Reports::AssistantConversationCreator
|
|
include ActiveSupport::Testing::TimeHelpers
|
|
|
|
OUTCOMES = %i[resolved_by_assistant handled_by_both handed_off resolved_and_reopened].freeze
|
|
|
|
def initialize(account:, assistant:, inbox:, resources:)
|
|
@account = account
|
|
@assistant = assistant
|
|
@inbox = inbox
|
|
@contacts = resources[:contacts]
|
|
@agents = inbox.members.to_a.presence || resources[:agents]
|
|
end
|
|
|
|
def create_conversation(created_at:, outcome:)
|
|
conversation = nil
|
|
|
|
travel_to(created_at) do
|
|
conversation = build_conversation
|
|
conversation.save!
|
|
seed_dialogue(conversation, outcome)
|
|
end
|
|
travel_back
|
|
|
|
apply_outcome(conversation, created_at, outcome)
|
|
conversation
|
|
end
|
|
|
|
private
|
|
|
|
def build_conversation
|
|
contact = @contacts.sample
|
|
contact_inbox = @inbox.contact_inboxes.find_or_create_by!(contact: contact, source_id: SecureRandom.hex)
|
|
|
|
contact_inbox.conversations.create!(
|
|
account: @account,
|
|
inbox: @inbox,
|
|
contact: contact,
|
|
priority: [nil, 'high', 'medium', 'low'].sample
|
|
)
|
|
end
|
|
|
|
# Builds the message exchange for the conversation while time is frozen at its
|
|
# creation moment. Every outcome starts with a customer question and at least
|
|
# one public assistant reply so the conversation lands in the assistant's
|
|
# handled set; some outcomes add a human reply or a handoff.
|
|
def seed_dialogue(conversation, outcome)
|
|
customer_message = incoming_message(conversation)
|
|
|
|
travel(rand((20.seconds)..(5.minutes)))
|
|
assistant_reply(conversation, waiting_since: customer_message.created_at)
|
|
|
|
case outcome
|
|
when :handed_off then seed_handoff(conversation)
|
|
when :handled_by_both then seed_human_turn(conversation)
|
|
else seed_assistant_follow_up(conversation)
|
|
end
|
|
end
|
|
|
|
def seed_handoff(conversation)
|
|
travel(rand((1.minute)..(10.minutes)))
|
|
handoff_to_human(conversation)
|
|
travel(rand((1.minute)..(15.minutes)))
|
|
human_reply(conversation)
|
|
end
|
|
|
|
def seed_human_turn(conversation)
|
|
travel(rand((1.minute)..(15.minutes)))
|
|
human_reply(conversation)
|
|
end
|
|
|
|
# Pure assistant threads occasionally take a second turn, giving depth > 1.
|
|
def seed_assistant_follow_up(conversation)
|
|
return unless rand < 0.6
|
|
|
|
travel(rand((1.minute)..(10.minutes)))
|
|
follow_up = incoming_message(conversation)
|
|
travel(rand((20.seconds)..(5.minutes)))
|
|
assistant_reply(conversation, waiting_since: follow_up.created_at)
|
|
end
|
|
|
|
def apply_outcome(conversation, created_at, outcome)
|
|
resolved_at = created_at + rand((30.minutes)..(8.hours))
|
|
|
|
case outcome
|
|
when :resolved_by_assistant
|
|
resolve_by_captain(conversation, resolved_at)
|
|
when :handled_by_both
|
|
resolve_by_human(conversation, resolved_at)
|
|
when :handed_off
|
|
resolve_by_human(conversation, resolved_at) if rand < 0.6
|
|
when :resolved_and_reopened
|
|
resolve_by_captain(conversation, resolved_at)
|
|
reopen(conversation, resolved_at + rand((1.hour)..(24.hours)))
|
|
end
|
|
end
|
|
|
|
def incoming_message(conversation)
|
|
conversation.messages.create!(
|
|
account: @account,
|
|
inbox: @inbox,
|
|
message_type: :incoming,
|
|
content: Faker::Lorem.paragraph(sentence_count: rand(1..3)),
|
|
sender: conversation.contact
|
|
)
|
|
end
|
|
|
|
def assistant_reply(conversation, waiting_since:)
|
|
message = conversation.messages.create!(
|
|
account: @account,
|
|
inbox: @inbox,
|
|
message_type: :outgoing,
|
|
private: false,
|
|
content: Faker::Lorem.paragraph(sentence_count: rand(1..4)),
|
|
sender: @assistant
|
|
)
|
|
trigger_reply_time(message, waiting_since)
|
|
message
|
|
end
|
|
|
|
def human_reply(conversation)
|
|
agent = @agents.sample
|
|
conversation.update_column(:assignee_id, agent.id) if conversation.assignee_id.nil? # rubocop:disable Rails/SkipsModelValidations
|
|
|
|
conversation.messages.create!(
|
|
account: @account,
|
|
inbox: @inbox,
|
|
message_type: :outgoing,
|
|
private: false,
|
|
content: Faker::Lorem.paragraph(sentence_count: rand(1..4)),
|
|
sender: agent
|
|
)
|
|
end
|
|
|
|
def resolve_by_captain(conversation, resolved_at)
|
|
mark_resolved(conversation, resolved_at)
|
|
travel_to(resolved_at) do
|
|
trigger_event('conversation_resolved', conversation)
|
|
trigger_event('conversation_captain_inference_resolved', conversation)
|
|
end
|
|
travel_back
|
|
end
|
|
|
|
def resolve_by_human(conversation, resolved_at)
|
|
mark_resolved(conversation, resolved_at)
|
|
travel_to(resolved_at) do
|
|
trigger_event('conversation_resolved', conversation)
|
|
end
|
|
travel_back
|
|
end
|
|
|
|
def reopen(conversation, reopened_at)
|
|
# rubocop:disable Rails/SkipsModelValidations
|
|
conversation.update_column(:status, :open)
|
|
conversation.update_column(:updated_at, reopened_at)
|
|
# rubocop:enable Rails/SkipsModelValidations
|
|
|
|
travel_to(reopened_at) do
|
|
trigger_event('conversation_opened', conversation)
|
|
end
|
|
travel_back
|
|
end
|
|
|
|
def handoff_to_human(conversation)
|
|
trigger_event('conversation_captain_inference_handoff', conversation)
|
|
end
|
|
|
|
def mark_resolved(conversation, resolved_at)
|
|
# rubocop:disable Rails/SkipsModelValidations
|
|
conversation.update_column(:status, :resolved)
|
|
conversation.update_column(:updated_at, resolved_at)
|
|
# rubocop:enable Rails/SkipsModelValidations
|
|
end
|
|
|
|
def trigger_event(name, conversation)
|
|
ReportingEventListener.instance.public_send(
|
|
name, Events::Base.new(name, Time.current, { conversation: conversation })
|
|
)
|
|
end
|
|
|
|
def trigger_reply_time(message, waiting_since)
|
|
ReportingEventListener.instance.reply_created(
|
|
Events::Base.new('reply_created', Time.current,
|
|
{ message: message, conversation: message.conversation, waiting_since: waiting_since })
|
|
)
|
|
end
|
|
end
|