From 563619c95da95891e48b57168d3a5f68edf4f1d8 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 1 Jul 2026 15:44:47 +0530 Subject: [PATCH] fix: match captain reopens by reopen time to survive inference event ordering --- .../app/builders/captain/assistant_stats_builder.rb | 10 ++++++---- .../captain/assistant_stats_builder_spec.rb | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/enterprise/app/builders/captain/assistant_stats_builder.rb b/enterprise/app/builders/captain/assistant_stats_builder.rb index 7ccc01977..84d7a7942 100644 --- a/enterprise/app/builders/captain/assistant_stats_builder.rb +++ b/enterprise/app/builders/captain/assistant_stats_builder.rb @@ -188,15 +188,17 @@ class Captain::AssistantStatsBuilder def reopen_rate(range) resolved_scope = account.reporting_events.where(name: RESOLVED_EVENT_NAMES, created_at: range, conversation_id: handled_scope(range).select(:conversation_id)) - # event_start_time on a reopen is the preceding resolve's timestamp. Join it to the conversation's - # own Captain resolves and keep only reopens at/after one of them, so a human resolve/reopen - # earlier in the same window isn't mistaken for a reopen-after-Captain-resolve. + # event_end_time on a reopen is when it actually reopened. Join it to the conversation's own + # Captain resolves and keep only reopens at/after one of them, so a human resolve/reopen earlier + # in the same window isn't mistaken for a reopen-after-Captain-resolve. (Comparing the reopen's + # start time instead would misfire: the inference event is dispatched just after the generic + # conversation_resolved that seeds event_start_time, so it can land after the reopen's start.) reopened = account.reporting_events .where(name: 'conversation_opened') .where('reporting_events.value > 0') .joins("INNER JOIN (#{resolved_scope.to_sql}) resolves " \ 'ON resolves.conversation_id = reporting_events.conversation_id ' \ - 'AND reporting_events.event_start_time >= resolves.event_end_time') + 'AND reporting_events.event_end_time >= resolves.event_end_time') .distinct.count('reporting_events.conversation_id') rate(reopened, resolved_scope.distinct.count(:conversation_id)) end diff --git a/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb index cbbcdc0d7..9ad06bbcf 100644 --- a/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb +++ b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb @@ -120,6 +120,19 @@ RSpec.describe Captain::AssistantStatsBuilder do expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(0.0) end + it 'counts an evaluated-path reopen when bot_resolved is skipped and the inference event is newer' do + # Prior human reply => create_bot_resolved_event skips conversation_bot_resolved, so the cohort + # only holds the inference event, which is dispatched a moment after the generic conversation_resolved + # that seeds the reopen's event_start_time. The match must use the reopen's actual reopen time. + create(:reporting_event, account: account, inbox: inbox, conversation: conversation, + name: 'conversation_captain_inference_resolved', + event_start_time: 6.days.ago, event_end_time: 6.days.ago + 1.second) + create(:reporting_event, account: account, inbox: inbox, conversation: conversation, + name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 3.days.ago) + + expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0) + end + it 'counts both inference and time-based bot resolves in the denominator' do # conversation: inference-resolved and reopened create(:reporting_event, account: account, inbox: inbox, conversation: conversation,