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