feat: add Captain outcome reporting builders (#15425)

This PR adds outcome-based reporting builders for the redesigned Captain
overview, resolution flow, and resolution trend. These builders are not
wired to controllers or the frontend yet, so the existing Captain
metrics remain unchanged.

## AssistantOverviewStatsBuilder

Builds current and previous reporting-window metrics, including the
comparison trend, for the overview and CSAT cards.

| Statistic | Description |
| --- | --- |
| Conversations handled | Counts outcome episodes in which Captain
replied or performed a non-usage-limit handoff. |
| Auto-resolution rate | Shows autonomous resolutions as a percentage of
conversations handled. |
| Autonomous resolutions | Counts episodes resolved by Captain without a
handoff or an earlier human reply. |
| Handoff rate | Shows involved handoffs as a percentage of
conversations handled. |
| Handoff count | Counts involved handoffs while excluding demand
blocked by usage limits. |
| Hours saved | Estimates displaced agent effort from Captain's public
replies at two minutes per reply. |
| Reopen rate | Shows the share of autonomous resolutions followed by
another episode. |
| Conversation depth | Shows the average number of public Captain
replies per replied-to conversation. |
| Durable resolution rate | Shows autonomous resolutions that remained
closed for at least seven days among resolutions old enough to assess. |
| Autonomous CSAT score | Averages CSAT ratings from conversations
resolved autonomously by Captain. |
| Assisted CSAT score | Averages CSAT ratings from resolved
conversations where Captain participated alongside a human. |
| Human-only CSAT score | Averages account CSAT from conversations where
Captain never participated. |
| Median resolution time | Reports the median elapsed seconds from
demand start to resolution for handled episodes. |

## AssistantResolutionFlowBuilder

Builds the current-window Sankey data and a matching handoff-reason
distribution from the same outcome cohort.

| Statistic | Description |
| --- | --- |
| Conversations handled | Provides the Sankey entry count for episodes
where Captain participated. |
| Resolved by Captain | Counts handled episodes resolved autonomously by
Captain. |
| Handed off | Counts handled episodes transferred to a human for a
non-usage-limit reason. |
| Closed with team | Counts handled episodes outside the
autonomous-resolution and handoff branches. |
| Reopened within seven days | Counts Captain resolutions followed by a
new episode before the seven-day durability boundary. |
| Stayed closed | Counts Captain resolutions with no reopen inside seven
days. |
| Handoff reason nodes | Shows the two largest handoff categories and
combines the remainder as other reasons. |
| Handoff distribution | Returns every involved handoff category with
its count and percentage, including unclassified handoffs. |

## AssistantResolutionTrendStatsBuilder

Builds a zero-filled, timezone-aware resolution series in one outcome
query, using daily buckets for windows of 15 days or less and weekly
buckets for longer windows.

| Statistic | Description |
| --- | --- |
| Granularity | Identifies whether the response contains daily or weekly
buckets. |
| Bucket range | Returns the start and end date represented by each
bucket. |
| Conversations handled | Counts Captain-involved outcome episodes whose
demand started in each bucket. |
| Resolved by Captain | Counts autonomously resolved outcome episodes
whose demand started in each bucket. |
This commit is contained in:
Shivam Mishra
2026-08-13 18:29:22 +05:30
committed by GitHub
parent a929955cc3
commit 40f5381da6
7 changed files with 1152 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
# Shared query-time classifications for Captain conversation outcome reporting.
module Captain::AssistantOutcomeClassification
DURABLE_RESOLUTION_WINDOW = 7.days
USAGE_LIMIT_REASON = 'usage_limit'.freeze
private
# A usage-limit handoff is blocked demand only when Captain never replied, so
# it does not make the episode involved. If Captain replied before the quota
# ran out, the episode remains involved and the later transfer is a real
# handoff. Every non-usage-limit handoff, including an unclassified one, also
# means Captain participated.
def involved(table)
table[:first_captain_reply_at].not_eq(nil).or(
table[:handoff_at].not_eq(nil).and(
table[:handoff_reason_category].is_distinct_from(USAGE_LIMIT_REASON)
)
)
end
def autonomous(table)
table[:resolved_at].not_eq(nil)
.and(table[:first_captain_reply_at].not_eq(nil))
.and(table[:handoff_at].eq(nil))
.and(
table[:first_human_reply_at].eq(nil).or(
table[:first_human_reply_at].gt(table[:resolved_at])
)
)
end
def assisted(table)
table[:resolved_at].not_eq(nil)
.and(involved(table))
.and(autonomous(table).not)
end
def handoff(table)
# Intentionally includes usage-limit transfers when a prior Captain reply
# made the episode involved; only pre-participation quota blocks are excluded.
involved(table).and(table[:handoff_at].not_eq(nil))
end
def reopened_autonomous(table)
autonomous(table)
.and(table[:ended_at].not_eq(nil))
.and(table[:ended_at].gt(table[:resolved_at]))
end
def reopened_within_7_days(table)
reopened_autonomous(table).and(resolution_duration(table).lt(DURABLE_RESOLUTION_WINDOW.to_i))
end
def durable(table)
table[:ended_at].eq(nil).or(
resolution_duration(table).gteq(DURABLE_RESOLUTION_WINDOW.to_i)
)
end
def resolution_duration(table)
(table[:ended_at] - table[:resolved_at]).extract(:epoch)
end
end

View File

@@ -0,0 +1,229 @@
# Computes the complete per-assistant metric set for the Captain overview.
# Funnel and outcome metrics use episodes grouped by when demand started,
# keeping their cohort stable as later facts arrive. Reply activity remains
# message-derived because active episodes do not have a terminal snapshot yet.
class Captain::AssistantOverviewStatsBuilder
include Captain::AssistantOutcomeClassification
SECONDS_SAVED_PER_REPLY = 2.minutes.to_i
PACKED_METRICS = {
conversations_handled: %i[involved percent],
auto_resolution_rate: %i[auto_resolution_rate point],
autonomous_resolutions: %i[autonomous percent],
handoff_rate: %i[handoff_rate point],
handoff_count: %i[handoffs percent],
hours_saved: %i[hours_saved absolute],
reopen_rate: %i[reopen_rate point],
conversation_depth: %i[conversation_depth absolute],
durable_resolution_rate: %i[durable_rate point],
autonomous_csat_score: %i[autonomous_csat absolute],
assisted_csat_score: %i[assisted_csat absolute],
median_resolution_seconds: %i[median_resolution absolute]
}.freeze
attr_reader :assistant, :account
delegate :range, :period, to: :window
def initialize(assistant, range = Captain::AssistantStatsWindow::DEFAULT_RANGE, timezone_offset = nil)
@assistant = assistant
@account = assistant.account
@window = Captain::AssistantStatsWindow.new(range, timezone_offset)
end
def metrics
rows = outcome_window_rows
messages = message_window_rows
current = window_metrics(rows[:current], messages[:current])
previous = window_metrics(rows[:previous], messages[:previous])
PACKED_METRICS.transform_values { |(key, mode)| pack(current[key], previous[key], mode) }.merge(
human_only_csat_score: pack(human_only_csat(window.current), human_only_csat(window.previous), :absolute)
)
end
private
attr_reader :window
def window_metrics(row, message_row)
involved, autonomous, handoffs, reopened, assessable, durable, autonomous_csat, assisted_csat, resolution = row
public_replies, reply_conversations = message_row
{
involved: involved,
autonomous: autonomous,
auto_resolution_rate: rate(autonomous, involved),
handoffs: handoffs,
handoff_rate: rate(handoffs, involved),
hours_saved: (public_replies * SECONDS_SAVED_PER_REPLY / 3600.0).round,
reopen_rate: rate(reopened, autonomous),
conversation_depth: reply_conversations.zero? ? 0 : (public_replies.to_f / reply_conversations).round(1),
durable_rate: rate(durable, assessable),
autonomous_csat: autonomous_csat.to_f.round(2),
assisted_csat: assisted_csat.to_f.round(2),
median_resolution: resolution.to_i
}
end
# Both outcome windows are computed in one scan. Day-based windows share an
# endpoint, so the previous range excludes that boundary to avoid counting an
# episode in both cohorts.
def outcome_window_rows
current_aggregates = window_aggregates(window_predicate(window.current, table: outcomes_table, column: :started_at))
previous_aggregates = window_aggregates(
window_predicate(window.previous, table: outcomes_table, column: :started_at, exclude_end: shared_boundary?)
)
row = outcomes_scope(full_span).reorder(nil).pick(*(current_aggregates + previous_aggregates))
aggregate_count = current_aggregates.length
{ current: row.first(aggregate_count), previous: row.last(aggregate_count) }
end
def window_aggregates(predicate)
volume_aggregates(predicate) + durability_aggregates(predicate) + csat_aggregates(predicate) + [median_resolution(predicate)]
end
def volume_aggregates(predicate)
[
filtered_count(predicate.and(involved(outcomes_table))),
filtered_count(predicate.and(autonomous(outcomes_table))),
filtered_count(predicate.and(handoff(outcomes_table))),
filtered_count(predicate.and(reopened_autonomous(outcomes_table)))
]
end
def durability_aggregates(predicate)
assessable = autonomous(outcomes_table).and(outcomes_table[:resolved_at].lteq(durable_cutoff))
[
filtered_count(predicate.and(assessable)),
filtered_count(predicate.and(assessable).and(durable(outcomes_table)))
]
end
def csat_aggregates(predicate)
[
filtered_average(predicate.and(autonomous(outcomes_table))),
filtered_average(predicate.and(assisted(outcomes_table)))
]
end
# Reply-based metrics retain their event-time meaning. Outcome reply counts
# are snapshotted only at terminal events, so active episodes can be stale.
def message_window_rows
current_predicate = window_predicate(window.current, table: messages_table, column: :created_at)
previous_predicate = window_predicate(
window.previous, table: messages_table, column: :created_at, exclude_end: shared_boundary?
)
aggregates = [current_predicate, previous_predicate].flat_map do |predicate|
message_aggregates(predicate.and(public_reply_predicate))
end
row = assistant_messages.reorder(nil).pick(*aggregates)
{ current: row[0..1], previous: row[2..3] }
end
def message_aggregates(predicate)
[
filtered_count(predicate),
messages_table[:conversation_id].count(true).filter(predicate)
]
end
def public_reply_predicate
messages_table[:message_type].eq(Message.message_types[:outgoing]).and(messages_table[:private].eq(false))
end
# Account-wide CSAT from conversations where no Captain assistant ever
# participated provides the comparison baseline.
def human_only_csat(range)
involved_conversations = ConversationOutcome
.where(account_id: account.id)
.where(involved(outcomes_table))
.select(:conversation_id)
score = account.csat_survey_responses
.where(created_at: range)
.where.not(conversation_id: involved_conversations)
.average(:rating)
score&.to_f&.round(2) || 0
end
def outcomes_scope(range)
account.conversation_outcomes.where(assistant_id: assistant.id, started_at: range)
end
def assistant_messages
account.messages.where(sender_type: 'Captain::Assistant', sender_id: assistant.id, created_at: full_span)
end
def full_span
window.previous.first..window.current.last
end
def durable_cutoff
@durable_cutoff ||= Time.current - DURABLE_RESOLUTION_WINDOW
end
def shared_boundary?
window.previous.last == window.current.first
end
def window_predicate(range, table:, column:, exclude_end: false)
starts_in_window = table[column].gteq(range.first)
ends_in_window = exclude_end ? table[column].lt(range.last) : table[column].lteq(range.last)
starts_in_window.and(ends_in_window)
end
def median_resolution(predicate)
duration = (outcomes_table[:resolved_at] - outcomes_table[:started_at]).extract(:epoch)
percentile = Arel::Nodes::NamedFunction.new('percentile_cont', [Arel::Nodes.build_quoted(0.5)])
within_group = Arel::Nodes::InfixOperation.new(
'WITHIN GROUP', percentile, Arel::Nodes::Window.new.order(duration)
)
Arel::Nodes::Filter.new(
within_group,
predicate.and(involved(outcomes_table)).and(outcomes_table[:resolved_at].not_eq(nil))
)
end
def filtered_count(predicate)
Arel.star.count.filter(predicate)
end
def filtered_average(predicate)
outcomes_table[:csat_rating].average.filter(predicate.and(outcomes_table[:csat_rating].not_eq(nil)))
end
def outcomes_table
@outcomes_table ||= ConversationOutcome.arel_table
end
def messages_table
@messages_table ||= Message.arel_table
end
def rate(numerator, denominator)
return 0 if denominator.zero?
(numerator.to_f / denominator * 100).round(1)
end
def pack(current, previous, mode)
{ current: current, previous: previous, trend: trend(current, previous, mode) }
end
def trend(current, previous, mode)
case mode
when :percent
previous.zero? ? 0 : ((current - previous).to_f / previous * 100).round(1)
else
(current - previous).round(1)
end
end
end

View File

@@ -0,0 +1,127 @@
# Builds the current-window resolution flow and its handoff-reason breakdown.
# The Sankey branches are mutually exclusive so their values remain balanced.
class Captain::AssistantResolutionFlowBuilder
include Captain::AssistantOutcomeClassification
PRIMARY_HANDOFF_REASON_COUNT = 2
UNCLASSIFIED_REASON = 'unclassified'.freeze
attr_reader :assistant, :account
def initialize(assistant, range = Captain::AssistantStatsWindow::DEFAULT_RANGE, timezone_offset = nil)
@assistant = assistant
@account = assistant.account
@window = Captain::AssistantStatsWindow.new(range, timezone_offset)
end
def build
counts = flow_counts
distribution = handoff_distribution(counts[:handed_off])
{
sankey: sankey(counts, distribution),
handoff_distribution: distribution
}
end
private
attr_reader :window
def flow_counts
handled, autonomous, handoffs, reopened = outcomes_scope.reorder(nil).pick(
filtered_count(involved(outcomes_table)),
filtered_count(autonomous(outcomes_table)),
filtered_count(handoff(outcomes_table)),
filtered_count(reopened_within_7_days(outcomes_table))
)
{
conversations_handled: handled,
resolved_by_captain: autonomous,
handed_off: handoffs,
closed_with_team: handled - autonomous - handoffs,
reopened_within_7_days: reopened,
stayed_closed: autonomous - reopened
}
end
def handoff_distribution(handoff_count)
distribution = outcomes_scope.where(handoff(outcomes_table)).group(:handoff_reason_category).count.map do |category, count|
{
category: category || UNCLASSIFIED_REASON,
count: count,
percentage: percentage(count, handoff_count)
}
end
distribution.sort_by { |entry| [-entry[:count], entry[:category]] }
end
def sankey(counts, distribution)
reason_nodes, reason_links = sankey_handoff_reasons(distribution)
{
nodes: base_nodes(counts) + reason_nodes,
links: base_links(counts) + reason_links
}
end
def base_nodes(counts)
counts.map { |id, count| { id: id, count: count } }
end
def base_links(counts)
[
link(:conversations_handled, :resolved_by_captain, counts[:resolved_by_captain]),
link(:conversations_handled, :handed_off, counts[:handed_off]),
link(:conversations_handled, :closed_with_team, counts[:closed_with_team]),
link(:resolved_by_captain, :reopened_within_7_days, counts[:reopened_within_7_days]),
link(:resolved_by_captain, :stayed_closed, counts[:stayed_closed])
]
end
def sankey_handoff_reasons(distribution)
primary_reasons = distribution.first(PRIMARY_HANDOFF_REASON_COUNT)
other_count = distribution.drop(PRIMARY_HANDOFF_REASON_COUNT).sum { |entry| entry[:count] }
nodes = primary_reasons.map { |entry| reason_node(entry) }
links = primary_reasons.map { |entry| link(:handed_off, reason_id(entry[:category]), entry[:count]) }
if other_count.positive?
nodes << { id: :other_reasons, count: other_count }
links << link(:handed_off, :other_reasons, other_count)
end
[nodes, links]
end
def reason_node(entry)
{ id: reason_id(entry[:category]), count: entry[:count] }
end
def reason_id(category)
"handoff_reason_#{category}".to_sym
end
def link(source, target, value)
{ source: source, target: target, value: value }
end
def percentage(count, total)
return 0 if total.zero?
(count.to_f / total * 100).round(1)
end
def outcomes_scope
account.conversation_outcomes.where(assistant_id: assistant.id, started_at: window.current)
end
def filtered_count(predicate)
Arel.star.count.filter(predicate)
end
def outcomes_table
@outcomes_table ||= ConversationOutcome.arel_table
end
end

View File

@@ -0,0 +1,105 @@
# Builds a time series for Captain involvement and autonomous resolution.
# All buckets are computed in one outcome-table scan and follow the viewer's
# calendar timezone while remaining clipped to the selected reporting window.
class Captain::AssistantResolutionTrendStatsBuilder
include Captain::AssistantOutcomeClassification
DAILY_GRANULARITY_THRESHOLD = 15.days
WEEK_START = :sunday
attr_reader :assistant, :account
def initialize(assistant, range = Captain::AssistantStatsWindow::DEFAULT_RANGE, timezone_offset = nil)
@assistant = assistant
@account = assistant.account
@window = Captain::AssistantStatsWindow.new(range, timezone_offset)
end
def metrics
buckets = time_buckets
counts = bucket_counts(buckets)
{
granularity: granularity,
buckets: buckets.each_with_index.map { |bucket, index| serialize_bucket(bucket, counts[index]) }
}
end
private
attr_reader :window
def time_buckets
buckets = []
starts_at = window.current.first
while starts_at <= window.current.last
next_starts_at = next_bucket_starts_at(starts_at)
final_bucket = next_starts_at > window.current.last
buckets << {
starts_at: starts_at,
ends_at: final_bucket ? window.current.last : next_starts_at,
ends_on: final_bucket ? window.current.last.to_date : next_starts_at.to_date - 1.day,
final: final_bucket
}
starts_at = next_starts_at
end
buckets
end
def granularity
window.current.last - window.current.first <= DAILY_GRANULARITY_THRESHOLD ? :day : :week
end
def next_bucket_starts_at(starts_at)
return starts_at.beginning_of_day + 1.day if granularity == :day
starts_at.beginning_of_week(WEEK_START) + 1.week
end
def bucket_counts(buckets)
aggregates = buckets.flat_map do |bucket|
predicate = bucket_predicate(bucket)
[
filtered_count(predicate.and(involved(outcomes_table))),
filtered_count(predicate.and(autonomous(outcomes_table)))
]
end
outcomes_scope.reorder(nil).pick(*aggregates).each_slice(2).to_a
end
def serialize_bucket(bucket, counts)
{
starts_on: bucket[:starts_at].to_date,
ends_on: bucket[:ends_on],
conversations_handled: counts[0],
resolved_by_captain: counts[1]
}
end
def bucket_predicate(bucket)
starts_in_bucket = outcomes_table[:started_at].gteq(bucket[:starts_at])
ends_in_bucket = if bucket[:final]
outcomes_table[:started_at].lteq(bucket[:ends_at])
else
outcomes_table[:started_at].lt(bucket[:ends_at])
end
starts_in_bucket.and(ends_in_bucket)
end
def outcomes_scope
account.conversation_outcomes.where(assistant_id: assistant.id, started_at: window.current)
end
def filtered_count(predicate)
Arel.star.count.filter(predicate)
end
def outcomes_table
@outcomes_table ||= ConversationOutcome.arel_table
end
end

View File

@@ -0,0 +1,252 @@
require 'rails_helper'
RSpec.describe Captain::AssistantOverviewStatsBuilder do
subject(:metrics) { described_class.new(assistant, '30').metrics }
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:inbox) { create(:inbox, account: account) }
context 'with no outcomes' do
it 'returns only the overview metrics' do
expect(metrics.keys).to contain_exactly(
:conversations_handled,
:auto_resolution_rate,
:autonomous_resolutions,
:handoff_rate,
:handoff_count,
:hours_saved,
:reopen_rate,
:conversation_depth,
:durable_resolution_rate,
:autonomous_csat_score,
:assisted_csat_score,
:human_only_csat_score,
:median_resolution_seconds
)
end
it 'returns zeroed metrics' do
expect(metrics[:conversations_handled]).to eq(current: 0, previous: 0, trend: 0)
expect(metrics[:handoff_count][:current]).to eq(0)
expect(metrics[:durable_resolution_rate][:current]).to eq(0)
expect(metrics[:autonomous_csat_score][:current]).to eq(0)
expect(metrics[:assisted_csat_score][:current]).to eq(0)
end
end
context 'with demand in both windows' do
before do
demand_start = 20.days.ago.change(usec: 0)
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: demand_start,
first_captain_reply_at: demand_start + 30.seconds,
captain_reply_count: 1,
resolved_at: demand_start + 100.seconds,
csat_rating: 5
)
demand_start = 20.days.ago.change(usec: 0)
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: demand_start,
first_captain_reply_at: demand_start + 60.seconds,
captain_reply_count: 1,
resolved_at: demand_start + 200.seconds,
ended_at: demand_start + 1.day
)
demand_start = 6.days.ago.change(usec: 0)
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: demand_start,
first_captain_reply_at: demand_start + 90.seconds,
captain_reply_count: 1,
resolved_at: demand_start + 300.seconds
)
demand_start = 10.days.ago.change(usec: 0)
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: demand_start,
first_captain_reply_at: demand_start + 120.seconds,
captain_reply_count: 2,
handoff_at: demand_start + 150.seconds,
handoff_reason_category: 'missing_knowledge',
first_human_reply_at: demand_start + 250.seconds,
resolved_at: demand_start + 400.seconds,
csat_rating: 4
)
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: 5.days.ago,
handoff_at: 5.days.ago,
handoff_reason_category: 'usage_limit'
)
create(:conversation_outcome, account: account, assistant: assistant, inbox: inbox, started_at: 4.days.ago)
demand_start = 45.days.ago.change(usec: 0)
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: demand_start,
first_captain_reply_at: demand_start + 45.seconds,
captain_reply_count: 1,
resolved_at: demand_start + 500.seconds
)
end
it 'computes resolution counts and durability' do
expect(metrics[:autonomous_resolutions][:current]).to eq(3)
expect(metrics[:durable_resolution_rate][:current]).to eq(50.0)
end
it 'derives the legacy funnel metrics from outcome episodes' do
expect(metrics[:conversations_handled]).to eq(current: 4, previous: 1, trend: 300.0)
expect(metrics[:auto_resolution_rate][:current]).to eq(75.0)
expect(metrics[:handoff_rate][:current]).to eq(25.0)
expect(metrics[:handoff_count][:current]).to eq(1)
expect(metrics[:reopen_rate][:current]).to eq(33.3)
end
it 'splits autonomous and assisted CSAT and computes the median resolution time' do
expect(metrics[:autonomous_csat_score][:current]).to eq(5.0)
expect(metrics[:assisted_csat_score][:current]).to eq(4.0)
expect(metrics[:median_resolution_seconds][:current]).to eq(250)
end
end
it 'counts an unclassified handoff as Captain involvement' do
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: 1.day.ago,
handoff_at: 1.day.ago
)
expect(metrics[:conversations_handled][:current]).to eq(1)
expect(metrics[:handoff_rate][:current]).to eq(100.0)
expect(metrics[:handoff_count][:current]).to eq(1)
end
it 'derives reply activity from messages while an outcome episode is still active' do
stub_const("#{described_class}::SECONDS_SAVED_PER_REPLY", 20.minutes.to_i)
first_outcome = create(:conversation_outcome, account: account, assistant: assistant, inbox: inbox, started_at: 2.days.ago)
second_outcome = create(:conversation_outcome, account: account, assistant: assistant, inbox: inbox, started_at: 1.day.ago)
create_list(
:message,
2,
account: account,
inbox: inbox,
conversation: first_outcome.conversation,
sender: assistant,
message_type: :outgoing,
private: false,
created_at: 1.day.ago
)
create(
:message,
account: account,
inbox: inbox,
conversation: second_outcome.conversation,
sender: assistant,
message_type: :outgoing,
private: false,
created_at: 1.day.ago
)
create(
:message,
account: account,
inbox: inbox,
conversation: first_outcome.conversation,
sender: assistant,
message_type: :outgoing,
private: true,
created_at: 1.day.ago
)
expect(metrics[:hours_saved]).to eq(current: 1, previous: 0, trend: 1)
expect(metrics[:conversation_depth][:current]).to eq(1.5)
end
it 'does not count an episode in both adjacent day windows' do
travel_to Time.zone.parse('2026-08-12 12:00:00') do
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: 7.days.ago,
first_captain_reply_at: 7.days.ago
)
boundary_metrics = described_class.new(assistant, '7').metrics
expect(boundary_metrics[:conversations_handled]).to include(current: 1, previous: 0)
end
end
it 'compares Captain CSAT with conversations where Captain never participated' do
human_conversation = create(:conversation, account: account, inbox: inbox)
human_survey = create(:message, account: account, inbox: inbox, conversation: human_conversation, content_type: :input_csat)
create(
:csat_survey_response,
account: account,
conversation: human_conversation,
contact: human_conversation.contact,
message: human_survey,
rating: 3,
created_at: 5.days.ago
)
involved_outcome = create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: 5.days.ago,
first_captain_reply_at: 5.days.ago,
csat_rating: 5
)
involved_survey = create(
:message,
account: account,
inbox: inbox,
conversation: involved_outcome.conversation,
content_type: :input_csat
)
create(
:csat_survey_response,
account: account,
conversation: involved_outcome.conversation,
contact: involved_outcome.conversation.contact,
message: involved_survey,
rating: 5,
created_at: 5.days.ago
)
expect(metrics[:human_only_csat_score][:current]).to eq(3.0)
end
end

View File

@@ -0,0 +1,187 @@
require 'rails_helper'
RSpec.describe Captain::AssistantResolutionFlowBuilder do
subject(:resolution_flow) { described_class.new(assistant, '30').build }
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:inbox) { create(:inbox, account: account) }
context 'with no outcomes' do
it 'returns an empty flow with stable nodes and links' do
expect(resolution_flow).to eq(
sankey: {
nodes: [
{ id: :conversations_handled, count: 0 },
{ id: :resolved_by_captain, count: 0 },
{ id: :handed_off, count: 0 },
{ id: :closed_with_team, count: 0 },
{ id: :reopened_within_7_days, count: 0 },
{ id: :stayed_closed, count: 0 }
],
links: [
{ source: :conversations_handled, target: :resolved_by_captain, value: 0 },
{ source: :conversations_handled, target: :handed_off, value: 0 },
{ source: :conversations_handled, target: :closed_with_team, value: 0 },
{ source: :resolved_by_captain, target: :reopened_within_7_days, value: 0 },
{ source: :resolved_by_captain, target: :stayed_closed, value: 0 }
]
},
handoff_distribution: []
)
end
end
context 'with handled outcomes in the current window' do
before do
now = Time.current.change(usec: 0)
reopened_start = now - 20.days
reopened_resolution = reopened_start + 1.hour
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: reopened_start,
first_captain_reply_at: reopened_start + 1.minute,
resolved_at: reopened_resolution,
ended_at: reopened_resolution + 2.days
)
boundary_start = now - 15.days
boundary_resolution = boundary_start + 1.hour
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: boundary_start,
first_captain_reply_at: boundary_start + 1.minute,
resolved_at: boundary_resolution,
ended_at: boundary_resolution + 7.days
)
recent_start = now - 3.days
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: recent_start,
first_captain_reply_at: recent_start + 1.minute,
resolved_at: recent_start + 1.hour
)
assisted_start = now - 2.days
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: assisted_start,
first_captain_reply_at: assisted_start + 1.minute,
first_human_reply_at: assisted_start + 2.minutes,
resolved_at: assisted_start + 1.hour
)
2.times do |index|
started_at = now - (index + 4).days
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: started_at,
first_captain_reply_at: started_at + 1.minute,
handoff_at: started_at + 2.minutes,
handoff_reason_category: 'customer_request'
)
end
%w[missing_knowledge policy_restriction].each_with_index do |category, index|
started_at = now - (index + 6).days
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: started_at,
first_captain_reply_at: started_at + 1.minute,
handoff_at: started_at + 2.minutes,
handoff_reason_category: category
)
end
unclassified_start = now - 8.days
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: unclassified_start,
handoff_at: unclassified_start + 2.minutes
)
usage_limit_start = now - 9.days
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: usage_limit_start,
handoff_at: usage_limit_start + 2.minutes,
handoff_reason_category: 'usage_limit'
)
untouched_start = now - 10.days
create(:conversation_outcome, account: account, assistant: assistant, inbox: inbox, started_at: untouched_start)
outside_window_start = now - 45.days
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: outside_window_start,
first_captain_reply_at: outside_window_start + 1.minute,
resolved_at: outside_window_start + 1.hour
)
end
it 'builds a balanced resolution flow' do
expect(resolution_flow[:sankey]).to eq(
nodes: [
{ id: :conversations_handled, count: 9 },
{ id: :resolved_by_captain, count: 3 },
{ id: :handed_off, count: 5 },
{ id: :closed_with_team, count: 1 },
{ id: :reopened_within_7_days, count: 1 },
{ id: :stayed_closed, count: 2 },
{ id: :handoff_reason_customer_request, count: 2 },
{ id: :handoff_reason_missing_knowledge, count: 1 },
{ id: :other_reasons, count: 2 }
],
links: [
{ source: :conversations_handled, target: :resolved_by_captain, value: 3 },
{ source: :conversations_handled, target: :handed_off, value: 5 },
{ source: :conversations_handled, target: :closed_with_team, value: 1 },
{ source: :resolved_by_captain, target: :reopened_within_7_days, value: 1 },
{ source: :resolved_by_captain, target: :stayed_closed, value: 2 },
{ source: :handed_off, target: :handoff_reason_customer_request, value: 2 },
{ source: :handed_off, target: :handoff_reason_missing_knowledge, value: 1 },
{ source: :handed_off, target: :other_reasons, value: 2 }
]
)
end
it 'returns every handoff reason with its share of involved handoffs' do
expect(resolution_flow[:handoff_distribution]).to eq([
{ category: 'customer_request', count: 2, percentage: 40.0 },
{ category: 'missing_knowledge', count: 1, percentage: 20.0 },
{ category: 'policy_restriction', count: 1, percentage: 20.0 },
{ category: 'unclassified', count: 1, percentage: 20.0 }
])
end
end
end

View File

@@ -0,0 +1,189 @@
require 'rails_helper'
RSpec.describe Captain::AssistantResolutionTrendStatsBuilder do
subject(:metrics) { described_class.new(assistant, range, timezone_offset).metrics }
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:range) { 'this_month' }
let(:timezone_offset) { 0 }
let(:now) { Time.zone.parse('2025-06-30 12:00:00') }
around do |example|
travel_to(now) { example.run }
end
context 'with no outcomes' do
it 'returns zeroed weekly buckets for the complete reporting window' do
expect(metrics).to eq(
granularity: :week,
buckets: [
{ starts_on: Date.new(2025, 6, 1), ends_on: Date.new(2025, 6, 7), conversations_handled: 0, resolved_by_captain: 0 },
{ starts_on: Date.new(2025, 6, 8), ends_on: Date.new(2025, 6, 14), conversations_handled: 0, resolved_by_captain: 0 },
{ starts_on: Date.new(2025, 6, 15), ends_on: Date.new(2025, 6, 21), conversations_handled: 0, resolved_by_captain: 0 },
{ starts_on: Date.new(2025, 6, 22), ends_on: Date.new(2025, 6, 28), conversations_handled: 0, resolved_by_captain: 0 },
{ starts_on: Date.new(2025, 6, 29), ends_on: Date.new(2025, 6, 30), conversations_handled: 0, resolved_by_captain: 0 }
]
)
end
end
context 'when the reporting window spans 15 days or less' do
let(:now) { Time.zone.parse('2025-06-15 12:00:00') }
it 'returns zeroed daily buckets' do
expect(metrics[:granularity]).to eq(:day)
expect(metrics[:buckets].map { |bucket| bucket.values_at(:starts_on, :ends_on) }).to eq(
(Date.new(2025, 6, 1)..Date.new(2025, 6, 15)).map { |date| [date, date] }
)
expect(metrics[:buckets]).to all(include(conversations_handled: 0, resolved_by_captain: 0))
end
it 'computes every bucket in one outcome query' do
queries = []
subscriber = lambda do |_name, _started, _finished, _unique_id, payload|
queries << payload[:sql] if payload[:sql].include?('FROM "conversation_outcomes"') && !payload[:cached]
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { metrics }
expect(queries.size).to eq(1)
end
end
context 'with outcomes across the reporting window' do
before do
first_week_start = Time.zone.parse('2025-06-01 12:00:00')
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: first_week_start,
first_captain_reply_at: first_week_start + 1.minute,
resolved_at: first_week_start + 1.hour
)
second_week_start = Time.zone.parse('2025-06-08 00:00:00')
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: second_week_start,
handoff_at: second_week_start + 1.minute,
handoff_reason_category: 'customer_request'
)
usage_limit_start = Time.zone.parse('2025-06-09 12:00:00')
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: usage_limit_start,
handoff_at: usage_limit_start + 1.minute,
handoff_reason_category: 'usage_limit'
)
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: Time.zone.parse('2025-06-15 12:00:00')
)
fourth_week_start = Time.zone.parse('2025-06-22 12:00:00')
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: fourth_week_start,
first_captain_reply_at: fourth_week_start + 1.minute,
first_human_reply_at: fourth_week_start + 2.minutes,
resolved_at: fourth_week_start + 1.hour
)
final_week_start = Time.zone.parse('2025-06-29 12:00:00')
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: final_week_start,
first_captain_reply_at: final_week_start + 1.minute,
resolved_at: final_week_start + 1.hour
)
outside_window_start = Time.zone.parse('2025-05-31 12:00:00')
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: outside_window_start,
first_captain_reply_at: outside_window_start + 1.minute,
resolved_at: outside_window_start + 1.hour
)
other_assistant = create(:captain_assistant, account: account)
other_assistant_start = Time.zone.parse('2025-06-02 12:00:00')
create(
:conversation_outcome,
account: account,
assistant: other_assistant,
inbox: inbox,
started_at: other_assistant_start,
first_captain_reply_at: other_assistant_start + 1.minute,
resolved_at: other_assistant_start + 1.hour
)
end
it 'counts handled and Captain-resolved outcomes in their demand week' do
bucket_counts = metrics[:buckets].map { |bucket| bucket.values_at(:conversations_handled, :resolved_by_captain) }
expect(bucket_counts).to eq([
[1, 1],
[1, 0],
[0, 0],
[1, 0],
[1, 1]
])
end
it 'computes every bucket in one outcome query' do
queries = []
subscriber = lambda do |_name, _started, _finished, _unique_id, payload|
queries << payload[:sql] if payload[:sql].include?('FROM "conversation_outcomes"') && !payload[:cached]
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { metrics }
expect(queries.size).to eq(1)
end
end
it 'anchors calendar buckets to the viewer timezone' do
started_at = Time.zone.parse('2025-05-31 19:00:00')
create(
:conversation_outcome,
account: account,
assistant: assistant,
inbox: inbox,
started_at: started_at,
first_captain_reply_at: started_at + 1.minute,
resolved_at: started_at + 1.hour
)
timezone_metrics = described_class.new(assistant, range, 5.5).metrics
expect(timezone_metrics[:buckets].first).to include(
starts_on: Date.new(2025, 6, 1),
conversations_handled: 1,
resolved_by_captain: 1
)
end
end