feat: add per-assistant credit usage to captain overview stats

This commit is contained in:
Shivam Mishra
2026-07-21 16:50:03 +05:30
parent 67cab7171d
commit 8a5b552930
4 changed files with 110 additions and 2 deletions
@@ -0,0 +1,100 @@
# Computes per-assistant credit consumption (from agent_sessions) for the
# Captain Overview page: window totals for the trend plus a per-day series for
# the usage chart.
#
# Days are bucketed on the viewer's calendar day, so a completed day's sum never
# changes. Those sums are cached per (assistant, timezone, date) with a long TTL
# and only the still-accruing current day is computed live on every request.
class Captain::AssistantCreditUsageBuilder
CACHE_TTL = 30.days
# Credit tracking on agent_sessions began on this date; there is no data
# before it, so both spans are clamped to start here.
DATA_EPOCH = Date.new(2026, 7, 17)
def initialize(assistant, window)
@assistant = assistant
@window = window
end
# => { current:, previous:, daily: [{ date:, value: }, ...] }
# `daily` covers the current window only; `previous` is the total for the
# preceding equal-length day span, used for the trend.
def build
sums = daily_sums(previous_dates.to_a + current_dates.to_a)
{
current: total(sums, current_dates),
previous: total(sums, previous_dates),
daily: current_dates.map { |date| { date: date, value: sums[date] } }
}
end
private
attr_reader :assistant, :window
# Credit usage is bucketed on calendar days (unlike the rolling timestamp
# windows of the other metrics), so the previous span is derived from the
# current one's dates rather than window.previous — this keeps the two spans
# non-overlapping at the boundary day.
def current_dates
@current_dates ||= begin
first = window.current.first.in_time_zone(timezone).to_date
[first, DATA_EPOCH].max..window.current.last.in_time_zone(timezone).to_date
end
end
# Clamping can leave this span empty (window fully before the epoch); the
# trend then compares against 0 and the frontend hides it.
def previous_dates
first = current_dates.first - current_dates.count
[first, DATA_EPOCH].max..(current_dates.first - 1)
end
def daily_sums(dates)
cacheable = dates.select { |date| date < today }
cached = Rails.cache.read_multi(*cacheable.map { |date| cache_key(date) })
missing = dates.reject { |date| cached.key?(cache_key(date)) }
live = live_sums(missing)
missing.each do |date|
Rails.cache.write(cache_key(date), live[date], expires_in: CACHE_TTL) if date < today
end
dates.index_with { |date| cached.fetch(cache_key(date)) { live[date] } }
end
# One grouped scan covering the span of the requested dates. Zero-filled via
# groupdate so cache entries exist even for days without sessions.
def live_sums(dates)
return {} if dates.empty?
span = dates.min.in_time_zone(timezone).beginning_of_day..dates.max.in_time_zone(timezone).end_of_day
Captain::AgentSession
.where(assistant_id: assistant.id, created_at: span)
.group_by_day(:created_at, time_zone: timezone, range: span)
.sum(:credits_consumed)
.to_h { |day, value| [day.to_date, (value || 0).round(2)] }
end
def total(sums, dates)
dates.sum { |date| sums[date] }.round(2)
end
def today
@today ||= Time.current.in_time_zone(timezone).to_date
end
def timezone
window.timezone
end
def cache_key(date)
"captain_credit_usage/#{assistant.id}/#{timezone_name}/#{date}"
end
def timezone_name
@timezone_name ||= timezone.is_a?(String) ? timezone : timezone.name
end
end
@@ -57,6 +57,7 @@ class Captain::AssistantStatsBuilder
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),
credit_usage: credit_usage,
knowledge: knowledge
}
end
@@ -181,6 +182,13 @@ class Captain::AssistantStatsBuilder
rate(reopened, resolved_scope.distinct.count(:conversation_id))
end
# Credits consumed by the assistant's agent sessions: window totals packed like
# the other trend metrics, plus the per-day series for the usage chart.
def credit_usage
usage = Captain::AssistantCreditUsageBuilder.new(assistant, window).build
pack(usage[:current], usage[:previous], :percent).merge(daily: usage[:daily])
end
# Approved/pending FAQ counts and the document total in a single round trip.
def knowledge
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
@@ -14,7 +14,7 @@ class Captain::AssistantStatsWindow
DEFAULT_RANGE = '30'.freeze
ALLOWED_RANGES = %w[7 30 90 this_month last_month].freeze
attr_reader :range
attr_reader :range, :timezone
def initialize(range = DEFAULT_RANGE, timezone_offset = nil)
@range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
@@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
expect(metrics.keys).to contain_exactly(
:conversations_handled, :auto_resolution_rate, :handoff_rate,
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
:hours_saved, :reopen_rate, :conversation_depth, :credit_usage, :knowledge
)
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
end