feat: merge query

This commit is contained in:
Shivam Mishra
2026-06-30 15:36:20 +05:30
parent 9f17fbe697
commit 5b4ff80777
2 changed files with 209 additions and 54 deletions
@@ -1,7 +1,10 @@
# Computes per-assistant overview metrics for the Captain Overview page.
# Each metric is returned for the current window and the previous equal-length
# window, plus a derived trend. See stats.md (repo root) for the metric
# definitions and the source-of-truth queries this mirrors.
# window, plus a derived trend.
#
# Queries are batched to cut round trips: the message-derived counts (handled,
# public replies, depth) and the average reply time are each computed for both
# windows in a single scan via conditional FILTER aggregation.
class Captain::AssistantStatsBuilder
RESOLVED_EVENT_NAMES = %w[conversation_captain_inference_resolved conversation_bot_resolved].freeze
HANDOFF_EVENT_NAMES = %w[conversation_captain_inference_handoff conversation_bot_handoff].freeze
@@ -19,18 +22,12 @@ class Captain::AssistantStatsBuilder
end
def metrics
current = window_metrics(current_range)
previous = window_metrics(previous_range)
messages = message_window_metrics
reply_times = avg_reply_times
current = window_metrics(current_range, messages[:current], reply_times[:current])
previous = window_metrics(previous_range, messages[:previous], reply_times[:previous])
{
conversations_handled: pack(current[:handled], previous[:handled], :percent),
auto_resolution_rate: pack(current[:auto_resolution], previous[:auto_resolution], :point),
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
knowledge: knowledge
}
build_metrics(current, previous)
end
# Human-readable description of the period the metrics cover, for grounding the
@@ -45,12 +42,20 @@ class Captain::AssistantStatsBuilder
private
def build_metrics(current, previous)
{
conversations_handled: pack(current[:handled], previous[:handled], :percent),
auto_resolution_rate: pack(current[:auto_resolution], previous[:auto_resolution], :point),
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
knowledge: knowledge
}
end
def period_label
case range.to_s
when 'this_month' then 'this month'
when 'last_month' then 'last month'
else "the last #{day_count} days"
end
{ 'this_month' => 'this month', 'last_month' => 'last month' }[range.to_s] || "the last #{day_count} days"
end
def current_range
@@ -91,81 +96,120 @@ class Captain::AssistantStatsBuilder
range.to_i.positive? ? range.to_i : 30
end
# Raw metric values for a single window.
def window_metrics(range)
handled = handled_scope(range).distinct.count(:conversation_id)
public_messages = public_outgoing_scope(range)
public_count = public_messages.count
depth_conversations = public_messages.distinct.count(:conversation_id)
# Combines the per-window message counts and reply time (batched, passed in)
# with the reporting-event metrics (resolution and reopen) for one window.
def window_metrics(range, message_counts, avg_reply)
handled = message_counts[:handled]
public_count = message_counts[:public_count]
depth_conversations = message_counts[:depth_conversations]
resolution = resolution_counts(range)
{
handled: handled,
auto_resolution: rate(resolved_count(range), handled),
handoff: rate(handoff_count(range), handled),
hours_saved: (public_count * avg_reply_time(range) / 3600.0).round,
auto_resolution: rate(resolution[:resolved], handled),
handoff: rate(resolution[:handoff], handled),
hours_saved: (public_count * avg_reply / 3600.0).round,
reopen: reopen_rate(range),
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
}
end
# One scan over the assistant's messages computes handled, public-reply count,
# and depth-conversation count for both windows via conditional aggregation.
def message_window_metrics
public_clause = "message_type = #{Message.message_types[:outgoing]} AND private = false"
cur = window_clause(current_range)
prev = window_clause(previous_range)
row = handled_scope(full_span).reorder(nil).pick(
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur})"),
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev})"),
Arel.sql("COUNT(*) FILTER (WHERE #{cur} AND #{public_clause})"),
Arel.sql("COUNT(*) FILTER (WHERE #{prev} AND #{public_clause})"),
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur} AND #{public_clause})"),
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev} AND #{public_clause})")
)
{
current: { handled: row[0], public_count: row[2], depth_conversations: row[4] },
previous: { handled: row[1], public_count: row[3], depth_conversations: row[5] }
}
end
# Average reply time (seconds) for both windows in one scan.
def avg_reply_times
row = account.reporting_events.where(name: 'reply_time', created_at: full_span).reorder(nil).pick(
Arel.sql("AVG(value) FILTER (WHERE #{window_clause(current_range)})"),
Arel.sql("AVG(value) FILTER (WHERE #{window_clause(previous_range)})")
)
{ current: row[0].to_f, previous: row[1].to_f }
end
# Resolved and handed-off conversation counts for one window, in a single scan
# of the handled set's reporting events.
def resolution_counts(range)
row = account.reporting_events
.where(name: RESOLVED_EVENT_NAMES + HANDOFF_EVENT_NAMES,
conversation_id: handled_scope(range).select(:conversation_id))
.reorder(nil)
.pick(
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE name IN (#{quoted(RESOLVED_EVENT_NAMES)}))"),
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE name IN (#{quoted(HANDOFF_EVENT_NAMES)}))")
)
{ resolved: row[0], handoff: row[1] }
end
# Conversations the assistant participated in (authored any message).
def handled_scope(range)
account.messages.where(sender_type: 'Captain::Assistant', sender_id: assistant.id, created_at: range)
end
# Public outgoing replies the assistant sent (excludes private notes / handoff activity).
def public_outgoing_scope(range)
handled_scope(range).where(message_type: :outgoing, private: false)
# Span covering both windows so a single scan can split them with FILTER.
def full_span
[current_range.first, previous_range.first].min..current_range.last
end
def resolved_count(range)
distinct_event_conversations(RESOLVED_EVENT_NAMES, handled_scope(range))
def window_clause(range)
"created_at >= #{quote(range.first)} AND created_at <= #{quote(range.last)}"
end
def handoff_count(range)
distinct_event_conversations(HANDOFF_EVENT_NAMES, handled_scope(range))
def quote(value)
account.class.connection.quote(value)
end
def distinct_event_conversations(names, handled)
account.reporting_events
.where(name: names, conversation_id: handled.select(:conversation_id))
.distinct.count(:conversation_id)
def quoted(values)
values.map { |value| quote(value) }.join(', ')
end
# Of the conversations Captain auto-resolved (inbox-based), the share reopened afterwards.
def reopen_rate(range)
resolved_conversation_ids = account.reporting_events
.where(name: 'conversation_captain_inference_resolved',
inbox_id: assistant_inbox_ids, created_at: range)
.select(:conversation_id)
resolved = account.reporting_events.where(name: 'conversation_captain_inference_resolved',
inbox_id: assistant_inbox_ids, created_at: range)
.distinct.count(:conversation_id)
resolved_scope = account.reporting_events.where(name: 'conversation_captain_inference_resolved',
inbox_id: assistant_inbox_ids, created_at: range)
resolved = resolved_scope.distinct.count(:conversation_id)
reopened = account.reporting_events
.where(name: 'conversation_opened', conversation_id: resolved_conversation_ids)
.where(name: 'conversation_opened', conversation_id: resolved_scope.select(:conversation_id))
.where('reporting_events.value > 0')
.distinct.count(:conversation_id)
rate(reopened, resolved)
end
def avg_reply_time(range)
account.reporting_events.where(name: 'reply_time', created_at: range).average(:value).to_f
end
def assistant_inbox_ids
@assistant_inbox_ids ||= assistant.inboxes.ids
end
# Approved/pending FAQ counts and the document total in a single round trip.
def knowledge
responses = Captain::AssistantResponse.by_assistant(assistant.id)
approved = responses.approved.count
pending = responses.pending.count
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
)
total = approved + pending
{
approved: approved,
pending: pending,
documents: Captain::Document.for_assistant(assistant.id).count,
documents: documents,
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
}
end
@@ -0,0 +1,111 @@
require 'rails_helper'
RSpec.describe Captain::AssistantStatsBuilder do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:inbox) { create(:inbox, account: account) }
before { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
describe '#metrics' do
# Two conversations handled in the current 30-day window, one in the previous.
let(:current_convo_a) { create(:conversation, account: account, inbox: inbox) }
let(:current_convo_b) { create(:conversation, account: account, inbox: inbox) }
let(:previous_convo) { create(:conversation, account: account, inbox: inbox) }
before do
[current_convo_a, current_convo_b].each do |conversation|
create(:message, account: account, inbox: inbox, conversation: conversation,
sender: assistant, message_type: :outgoing, private: false, created_at: 5.days.ago)
end
create(:message, account: account, inbox: inbox, conversation: previous_convo,
sender: assistant, message_type: :outgoing, private: false, created_at: 45.days.ago)
end
it 'returns every metric for the current and previous window' do
metrics = described_class.new(assistant, '30').metrics
expect(metrics.keys).to contain_exactly(
:conversations_handled, :auto_resolution_rate, :handoff_rate,
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
)
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
end
it 'counts distinct handled conversations per window and the percent trend' do
handled = described_class.new(assistant, '30').metrics[:conversations_handled]
expect(handled[:current]).to eq(2)
expect(handled[:previous]).to eq(1)
expect(handled[:trend]).to eq(100.0)
end
it 'derives auto-resolution and handoff rates from reporting events on the handled set' do
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_captain_inference_resolved')
create(:reporting_event, account: account, conversation: current_convo_b,
name: 'conversation_captain_inference_handoff')
metrics = described_class.new(assistant, '30').metrics
expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
expect(metrics[:handoff_rate][:current]).to eq(50.0)
end
it 'computes conversation depth as public replies per handled conversation' do
depth = described_class.new(assistant, '30').metrics[:conversation_depth]
# 2 public outgoing replies across 2 distinct conversations in the current window.
expect(depth[:current]).to eq(1.0)
end
it 'ignores private notes and incoming messages when counting public replies' do
create(:message, account: account, inbox: inbox, conversation: current_convo_a,
sender: assistant, message_type: :outgoing, private: true, created_at: 5.days.ago)
depth = described_class.new(assistant, '30').metrics[:conversation_depth]
expect(depth[:current]).to eq(1.0)
end
end
describe '#metrics knowledge' do
before do
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
create_list(:captain_document, 2, assistant: assistant, account: account)
end
it 'returns approved, pending, document counts and coverage' do
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
end
it 'reports zero coverage when there are no responses' do
Captain::AssistantResponse.where(assistant: assistant).delete_all
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
expect(knowledge[:coverage]).to eq(0)
end
end
describe '#period' do
it 'labels a day range and exposes its bounds' do
period = described_class.new(assistant, '30').period
expect(period[:label]).to eq('the last 30 days')
expect(period[:starts_on]).to eq(30.days.ago.to_date)
expect(period[:ends_on]).to eq(Time.zone.today)
end
it 'labels the this_month range' do
expect(described_class.new(assistant, 'this_month').period[:label]).to eq('this month')
end
it 'labels the last_month range' do
expect(described_class.new(assistant, 'last_month').period[:label]).to eq('last month')
end
end
end