Files
chatwoot/lib/seeders/reports/assistant_conversation_creator.rb
Shivam MishraandGitHub 29d0b92f1c fix: captain hours saved metric (#14948)
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.
2026-07-07 19:16:24 +05:30

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