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>
163 lines
6.1 KiB
Ruby
163 lines
6.1 KiB
Ruby
# Lists the underlying records behind a single Captain assistant stat card, so a
|
|
# viewer can drill from an aggregate (e.g. "auto-resolution 42%") into the exact
|
|
# conversations or messages that produced it.
|
|
#
|
|
# The window is resolved by Captain::AssistantStatsWindow from the same `range`
|
|
# and `timezone_offset` the stat card used, so the drilldown covers precisely the
|
|
# rows the card counted. Records are serialized with the shared reports drilldown
|
|
# serializer, so the existing frontend drilldown drawer/card can render them.
|
|
class Captain::AssistantDrilldownBuilder
|
|
ASSISTANT_SENDER_TYPE = 'Captain::Assistant'.freeze
|
|
RESOLVED_EVENT_NAMES = Captain::AssistantStatsBuilder::RESOLVED_EVENT_NAMES
|
|
HANDOFF_EVENT_NAMES = Captain::AssistantStatsBuilder::HANDOFF_EVENT_NAMES
|
|
|
|
# Metrics whose records are individual messages rather than conversations.
|
|
MESSAGE_METRICS = %w[hours_saved].freeze
|
|
SUPPORTED_METRICS = %w[
|
|
conversations_handled auto_resolution_rate handoff_rate hours_saved reopen_rate conversation_depth
|
|
].freeze
|
|
|
|
DEFAULT_PAGE = 1
|
|
DEFAULT_PER_PAGE = 25
|
|
MAX_PER_PAGE = 100
|
|
|
|
pattr_initialize :assistant, :params
|
|
|
|
def self.supported_metric?(metric) = SUPPORTED_METRICS.include?(metric.to_s)
|
|
|
|
def build
|
|
records = paginated_records.to_a
|
|
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
|
|
end
|
|
|
|
private
|
|
|
|
def account = assistant.account
|
|
|
|
def window
|
|
@window ||= Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
|
|
end
|
|
|
|
def range = window.current
|
|
|
|
def meta
|
|
{
|
|
metric: metric,
|
|
record_type: record_type,
|
|
current_page: current_page,
|
|
per_page: per_page,
|
|
total_count: paginated_records.total_count,
|
|
conversation_count: conversation_count,
|
|
range: { since: range.first.to_i, until: range.last.to_i }
|
|
}
|
|
end
|
|
|
|
def conversation_count
|
|
return paginated_records.total_count unless message_metric?
|
|
|
|
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
|
|
end
|
|
|
|
def paginated_records
|
|
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
|
|
end
|
|
|
|
def drilldown_scope
|
|
case metric
|
|
when 'conversations_handled' then handled_conversations
|
|
when 'auto_resolution_rate' then conversations_for(resolved_events.select(:conversation_id))
|
|
when 'handoff_rate' then event_conversations(HANDOFF_EVENT_NAMES)
|
|
when 'hours_saved' then public_reply_messages
|
|
when 'reopen_rate' then reopened_conversations
|
|
when 'conversation_depth' then depth_conversations
|
|
else
|
|
raise ArgumentError, "Unsupported assistant drilldown metric: #{metric}"
|
|
end
|
|
end
|
|
|
|
# Messages the assistant authored in the window; the cohort every metric derives from.
|
|
def handled_messages
|
|
account.messages.where(sender_type: ASSISTANT_SENDER_TYPE, sender_id: assistant.id, created_at: range)
|
|
end
|
|
|
|
def handled_conversation_ids
|
|
handled_messages.select(:conversation_id)
|
|
end
|
|
|
|
def handled_conversations
|
|
conversations_for(handled_conversation_ids)
|
|
end
|
|
|
|
# Public agent-facing replies the assistant sent; the rows behind hours_saved.
|
|
def public_reply_messages
|
|
handled_messages.where(message_type: :outgoing, private: false)
|
|
.includes(:sender, conversation: [:assignee, :contact, :inbox])
|
|
.reorder(created_at: :desc)
|
|
end
|
|
|
|
# Conversations in the handled cohort that recorded one of the given reporting
|
|
# events in the window (resolved or handed-off).
|
|
def event_conversations(event_names)
|
|
ids = account.reporting_events
|
|
.where(name: event_names, created_at: range, conversation_id: handled_conversation_ids)
|
|
.select(:conversation_id)
|
|
conversations_for(ids)
|
|
end
|
|
|
|
# Captain resolves in the window, excluding bot-resolved rows whose conversation
|
|
# was also handed off, mirroring AssistantStatsBuilder#resolved_clause so the
|
|
# drilldown lists exactly the conversations the auto-resolution card counted.
|
|
def resolved_events
|
|
handoff_ids = account.reporting_events.where(name: HANDOFF_EVENT_NAMES, created_at: range).select(:conversation_id)
|
|
account.reporting_events
|
|
.where(name: RESOLVED_EVENT_NAMES, created_at: range, conversation_id: handled_conversation_ids)
|
|
.where("NOT (name = ? AND conversation_id IN (#{handoff_ids.to_sql}))",
|
|
Captain::AssistantStatsBuilder::BOT_RESOLVED_EVENT_NAME)
|
|
end
|
|
|
|
# Auto-resolved conversations that reopened at/after their Captain resolve,
|
|
# mirroring AssistantStatsBuilder#reopen_rate's numerator cohort.
|
|
def reopened_conversations
|
|
ids = account.reporting_events
|
|
.where(name: 'conversation_opened')
|
|
.where('reporting_events.value > 0')
|
|
.where('reporting_events.event_end_time <= ?', range.last)
|
|
.joins("INNER JOIN (#{resolved_events.to_sql}) resolves " \
|
|
'ON resolves.conversation_id = reporting_events.conversation_id ' \
|
|
'AND reporting_events.event_end_time >= resolves.event_end_time')
|
|
.select('reporting_events.conversation_id')
|
|
conversations_for(ids)
|
|
end
|
|
|
|
# Conversations the assistant sent at least one public reply in; the denominator behind conversation_depth.
|
|
def depth_conversations
|
|
conversations_for(handled_messages.where(message_type: :outgoing, private: false).select(:conversation_id))
|
|
end
|
|
|
|
def conversations_for(conversation_ids)
|
|
account.conversations
|
|
.where(id: conversation_ids)
|
|
.includes(:assignee, :contact, :inbox)
|
|
.order(created_at: :desc)
|
|
end
|
|
|
|
def record_serializer(records)
|
|
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(account, metric, false, records)
|
|
end
|
|
|
|
def metric = params[:metric].to_s
|
|
|
|
def message_metric? = MESSAGE_METRICS.include?(metric)
|
|
|
|
def record_type = message_metric? ? 'message' : 'conversation'
|
|
|
|
def current_page = [params[:page].to_i, DEFAULT_PAGE].max
|
|
|
|
def per_page
|
|
requested_per_page = params[:per_page].to_i
|
|
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
|
|
|
|
[requested_per_page, MAX_PER_PAGE].min
|
|
end
|
|
end
|