This PR adds a Captain Assistant **Overview** page to show some KPI metrics (conversations handled, auto-resolution, handoff, hours saved, reopen-after-resolve, conversation depth) with trend deltas vs the previous window, a real knowledge card, and a lazily-loaded, cached LLM welcome summary. ### Highlights - **Two contextual banners** on the overview: - **Inbox banner** — prompts the user to connect an inbox when the assistant has none, so it can actually do work. - **Coverage banner** — warns when FAQ coverage is below 85% with more than 100 responses pending review, linking straight to the pending queue. Dismissal persists per-assistant for 24h via localStorage. - **Batched stats builder** (`Captain::AssistantStatsBuilder`) computes both windows in single FILTER-aggregated scans to cut round trips, behind new `stats`/`summary` endpoints. - **Cards included but intentionally left dummy / not rendered yet:** `ResponseQualityCard` (flagged responses) and `CreditUsageCard` (credit usage + daily chart). Credits are an account-wide counter with no per-assistant or daily history, so there is no real data to back them yet; they ship in the codebase but are not wired into the page. ### Index migration - Replaces `index_messages_on_sender_type_and_sender_id` with `index_messages_on_sender_and_created` `(sender_type, sender_id, created_at)`. - **Why it helps:** the per-assistant windowed lookups filter `sender_*` *and* a `created_at` range. The old 2-column index matched every lifetime row for the assistant and filtered the time slice at the heap (~89% of rows discarded); adding `created_at` as a range column lets Postgres scan only the window, and fixes the row-count estimate so the planner picks a hash join over a nested loop on `reporting_events`. - **Why dropping the old index is safe:** the new index is a left-prefix superset `(sender_type, sender_id, ...)`, so every query the old one served is still served. No code references it by name, and dropping it keeps write amplification on `messages` neutral. Built/dropped with `CONCURRENTLY` and `if_not_exists`/`if_exists` guards. ## Preview <img width="2572" height="1754" alt="CleanShot 2026-06-29 at 22 38 51@2x" src="https://github.com/user-attachments/assets/3798d09e-7850-48e4-b2cd-508533f15cea" /> ## Banners #### Inbox connect alert <img width="2178" height="612" alt="CleanShot 2026-06-30 at 14 26 55@2x" src="https://github.com/user-attachments/assets/373c371c-bb7d-4291-a0f9-620673078302" /> #### Coverage alert <img width="2178" height="612" alt="CleanShot 2026-06-30 at 14 25 41@2x" src="https://github.com/user-attachments/assets/e12d6308-11b6-4ba2-88a2-8a3077dd3e8f" /> --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
79 lines
2.7 KiB
Ruby
79 lines
2.7 KiB
Ruby
# Resolves the current and previous comparison windows for Captain assistant
|
|
# stats. `range` is either a day count ('7', '30', '90') or a named period
|
|
# ('this_month', 'last_month'). The previous window mirrors the current one: the
|
|
# preceding N days for day ranges, or the preceding month for month ranges.
|
|
# `timezone_offset` is the viewer's UTC offset in hours (as the reports API sends
|
|
# it), so month/day boundaries anchor to the viewer's day rather than UTC.
|
|
#
|
|
# Shared by Captain::AssistantStatsBuilder (which needs both windows) and
|
|
# Captain::AssistantDrilldownBuilder (which drills into the current window), so a
|
|
# drilldown always covers exactly the rows its stat card counted.
|
|
class Captain::AssistantStatsWindow
|
|
include TimezoneHelper
|
|
|
|
DEFAULT_RANGE = '30'.freeze
|
|
ALLOWED_RANGES = %w[7 30 90 this_month last_month].freeze
|
|
|
|
attr_reader :range
|
|
|
|
def initialize(range = DEFAULT_RANGE, timezone_offset = nil)
|
|
@range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
|
|
@timezone = timezone_name_from_offset(timezone_offset) || Time.zone
|
|
end
|
|
|
|
def current
|
|
resolved_ranges[:current]
|
|
end
|
|
|
|
def previous
|
|
resolved_ranges[:previous]
|
|
end
|
|
|
|
# Human-readable description of the period the current window covers, for
|
|
# grounding the LLM summary in real dates.
|
|
def period
|
|
{ label: period_label, starts_on: current.first.to_date, ends_on: current.last.to_date }
|
|
end
|
|
|
|
private
|
|
|
|
def resolved_ranges
|
|
@resolved_ranges ||= case range
|
|
when 'this_month' then this_month_ranges
|
|
when 'last_month' then last_month_ranges
|
|
else day_ranges
|
|
end
|
|
end
|
|
|
|
# Current time anchored to the viewer's timezone, so calendar boundaries land on
|
|
# the viewer's day instead of UTC's.
|
|
def now
|
|
@now ||= Time.current.in_time_zone(@timezone)
|
|
end
|
|
|
|
def this_month_ranges
|
|
start = now.beginning_of_month
|
|
elapsed = now - start
|
|
previous_start = start - 1.month
|
|
# Clamp to the previous month's end so a longer current month can't pull the
|
|
# comparison window into the current month and double-count its rows.
|
|
previous_end = [previous_start + elapsed, previous_start.end_of_month].min
|
|
{ current: start..now, previous: previous_start..previous_end }
|
|
end
|
|
|
|
def last_month_ranges
|
|
start = (now - 1.month).beginning_of_month
|
|
previous_start = start - 1.month
|
|
{ current: start..start.end_of_month, previous: previous_start..previous_start.end_of_month }
|
|
end
|
|
|
|
def day_ranges
|
|
days = range.to_i
|
|
{ current: (now - days.days)..now, previous: (now - (2 * days).days)..(now - days.days) }
|
|
end
|
|
|
|
def period_label
|
|
{ 'this_month' => 'this month', 'last_month' => 'last month' }[range] || "the last #{range.to_i} days"
|
|
end
|
|
end
|