From 40f5381da62476f68124c19ba121f64a5ec63f81 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 13 Aug 2026 18:29:22 +0530 Subject: [PATCH] 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. | --- .../assistant_outcome_classification.rb | 63 +++++ .../assistant_overview_stats_builder.rb | 229 ++++++++++++++++ .../assistant_resolution_flow_builder.rb | 127 +++++++++ ...ssistant_resolution_trend_stats_builder.rb | 105 ++++++++ .../assistant_overview_stats_builder_spec.rb | 252 ++++++++++++++++++ .../assistant_resolution_flow_builder_spec.rb | 187 +++++++++++++ ...ant_resolution_trend_stats_builder_spec.rb | 189 +++++++++++++ 7 files changed, 1152 insertions(+) create mode 100644 enterprise/app/builders/captain/assistant_outcome_classification.rb create mode 100644 enterprise/app/builders/captain/assistant_overview_stats_builder.rb create mode 100644 enterprise/app/builders/captain/assistant_resolution_flow_builder.rb create mode 100644 enterprise/app/builders/captain/assistant_resolution_trend_stats_builder.rb create mode 100644 spec/enterprise/builders/captain/assistant_overview_stats_builder_spec.rb create mode 100644 spec/enterprise/builders/captain/assistant_resolution_flow_builder_spec.rb create mode 100644 spec/enterprise/builders/captain/assistant_resolution_trend_stats_builder_spec.rb diff --git a/enterprise/app/builders/captain/assistant_outcome_classification.rb b/enterprise/app/builders/captain/assistant_outcome_classification.rb new file mode 100644 index 000000000..b8ad2af5f --- /dev/null +++ b/enterprise/app/builders/captain/assistant_outcome_classification.rb @@ -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 diff --git a/enterprise/app/builders/captain/assistant_overview_stats_builder.rb b/enterprise/app/builders/captain/assistant_overview_stats_builder.rb new file mode 100644 index 000000000..290267fef --- /dev/null +++ b/enterprise/app/builders/captain/assistant_overview_stats_builder.rb @@ -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 diff --git a/enterprise/app/builders/captain/assistant_resolution_flow_builder.rb b/enterprise/app/builders/captain/assistant_resolution_flow_builder.rb new file mode 100644 index 000000000..7b64d9ccc --- /dev/null +++ b/enterprise/app/builders/captain/assistant_resolution_flow_builder.rb @@ -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 diff --git a/enterprise/app/builders/captain/assistant_resolution_trend_stats_builder.rb b/enterprise/app/builders/captain/assistant_resolution_trend_stats_builder.rb new file mode 100644 index 000000000..6a52d42bf --- /dev/null +++ b/enterprise/app/builders/captain/assistant_resolution_trend_stats_builder.rb @@ -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 diff --git a/spec/enterprise/builders/captain/assistant_overview_stats_builder_spec.rb b/spec/enterprise/builders/captain/assistant_overview_stats_builder_spec.rb new file mode 100644 index 000000000..d33b95367 --- /dev/null +++ b/spec/enterprise/builders/captain/assistant_overview_stats_builder_spec.rb @@ -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 diff --git a/spec/enterprise/builders/captain/assistant_resolution_flow_builder_spec.rb b/spec/enterprise/builders/captain/assistant_resolution_flow_builder_spec.rb new file mode 100644 index 000000000..f9744204d --- /dev/null +++ b/spec/enterprise/builders/captain/assistant_resolution_flow_builder_spec.rb @@ -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 diff --git a/spec/enterprise/builders/captain/assistant_resolution_trend_stats_builder_spec.rb b/spec/enterprise/builders/captain/assistant_resolution_trend_stats_builder_spec.rb new file mode 100644 index 000000000..63bc71ae5 --- /dev/null +++ b/spec/enterprise/builders/captain/assistant_resolution_trend_stats_builder_spec.rb @@ -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