feat: add captain assistant overview stats and summary endpoints
This commit is contained in:
@@ -66,6 +66,8 @@ Rails.application.routes.draw do
|
||||
resources :assistants do
|
||||
member do
|
||||
post :playground
|
||||
get :stats
|
||||
get :summary
|
||||
end
|
||||
collection do
|
||||
get :tools
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# 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.
|
||||
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
|
||||
DEFAULT_RANGE_DAYS = 30
|
||||
|
||||
attr_reader :assistant, :account, :range_days
|
||||
|
||||
def initialize(assistant, range_days = DEFAULT_RANGE_DAYS)
|
||||
@assistant = assistant
|
||||
@account = assistant.account
|
||||
@range_days = range_days.to_i.positive? ? range_days.to_i : DEFAULT_RANGE_DAYS
|
||||
end
|
||||
|
||||
def metrics
|
||||
current = window_metrics(current_range)
|
||||
previous = window_metrics(previous_range)
|
||||
|
||||
{
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def current_range
|
||||
@current_range ||= (range_days.days.ago)..Time.current
|
||||
end
|
||||
|
||||
def previous_range
|
||||
@previous_range ||= ((2 * range_days).days.ago)..range_days.days.ago
|
||||
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)
|
||||
|
||||
{
|
||||
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,
|
||||
reopen: reopen_rate(range),
|
||||
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(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)
|
||||
end
|
||||
|
||||
def resolved_count(range)
|
||||
distinct_event_conversations(RESOLVED_EVENT_NAMES, handled_scope(range))
|
||||
end
|
||||
|
||||
def handoff_count(range)
|
||||
distinct_event_conversations(HANDOFF_EVENT_NAMES, handled_scope(range))
|
||||
end
|
||||
|
||||
def distinct_event_conversations(names, handled)
|
||||
account.reporting_events
|
||||
.where(name: names, conversation_id: handled.select(:conversation_id))
|
||||
.distinct.count(:conversation_id)
|
||||
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)
|
||||
reopened = account.reporting_events
|
||||
.where(name: 'conversation_opened', conversation_id: resolved_conversation_ids)
|
||||
.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
|
||||
|
||||
def knowledge
|
||||
responses = Captain::AssistantResponse.by_assistant(assistant.id)
|
||||
approved = responses.approved.count
|
||||
pending = responses.pending.count
|
||||
total = approved + pending
|
||||
|
||||
{
|
||||
approved: approved,
|
||||
pending: pending,
|
||||
documents: Captain::Document.for_assistant(assistant.id).count,
|
||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||
}
|
||||
end
|
||||
|
||||
def rate(numerator, denominator)
|
||||
return 0 if denominator.zero?
|
||||
|
||||
(numerator.to_f / denominator * 100).round(1)
|
||||
end
|
||||
|
||||
def pack(current, previous, mode)
|
||||
{ current: current, previous: previous, trend: trend(current, previous, mode) }
|
||||
end
|
||||
|
||||
def trend(current, previous, mode)
|
||||
case mode
|
||||
when :percent
|
||||
previous.zero? ? 0 : ((current - previous).to_f / previous * 100).round(1)
|
||||
else # :point and :absolute are both current - previous
|
||||
(current - previous).round(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground]
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary]
|
||||
|
||||
def index
|
||||
@assistants = account_assistants.ordered
|
||||
@@ -43,8 +43,34 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
@tools = assistant.available_agent_tools
|
||||
end
|
||||
|
||||
def stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range]).metrics
|
||||
end
|
||||
|
||||
def summary
|
||||
result = Rails.cache.fetch(summary_cache_key, expires_in: 1.day) do
|
||||
stats = Captain::AssistantStatsBuilder.new(@assistant, params[:range]).metrics
|
||||
Captain::OverviewSummaryService.new(
|
||||
account: Current.account,
|
||||
assistant: @assistant,
|
||||
first_name: Current.user.name.to_s.split.first,
|
||||
stats: stats
|
||||
).perform
|
||||
end
|
||||
|
||||
if result[:error]
|
||||
render json: { error: result[:error] }, status: :unprocessable_content
|
||||
else
|
||||
render json: { message: result[:message] }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def summary_cache_key
|
||||
"captain_overview_summary/#{@assistant.id}/#{params[:range]}/#{Date.current}"
|
||||
end
|
||||
|
||||
def set_assistant
|
||||
@assistant = account_assistants.find(params[:id])
|
||||
end
|
||||
|
||||
@@ -11,6 +11,10 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def summary?
|
||||
true
|
||||
end
|
||||
|
||||
def tools?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Generates the LLM welcome summary for the Captain Overview page from the
|
||||
# assistant's stats hash (see Captain::AssistantStatsBuilder). Renders the
|
||||
# captain_overview_summary.liquid prompt and returns markdown.
|
||||
class Captain::OverviewSummaryService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :assistant!, :first_name!, :stats!]
|
||||
|
||||
def perform
|
||||
api_response = make_api_call(
|
||||
feature: 'editor',
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: 'Write the summary.' }
|
||||
]
|
||||
)
|
||||
|
||||
return api_response if api_response[:error]
|
||||
|
||||
{ message: api_response[:message] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def system_prompt
|
||||
Liquid::Template.parse(prompt_from_file('captain_overview_summary')).render(prompt_variables)
|
||||
end
|
||||
|
||||
def prompt_variables
|
||||
{
|
||||
'first_name' => first_name.to_s,
|
||||
'assistant_name' => assistant.name.to_s,
|
||||
'conversations_handled' => current(:conversations_handled),
|
||||
'hours_saved' => current(:hours_saved),
|
||||
'auto_resolution_rate' => current(:auto_resolution_rate),
|
||||
'auto_resolution_trend' => trend(:auto_resolution_rate),
|
||||
'handoff_rate' => current(:handoff_rate),
|
||||
'handoff_trend' => trend(:handoff_rate),
|
||||
'reopen_rate' => current(:reopen_rate),
|
||||
'reopen_trend' => trend(:reopen_rate),
|
||||
'knowledge_coverage' => stats.dig(:knowledge, :coverage).to_s
|
||||
}
|
||||
end
|
||||
|
||||
def current(key)
|
||||
stats.dig(key, :current).to_s
|
||||
end
|
||||
|
||||
def trend(key)
|
||||
stats.dig(key, :trend).to_s
|
||||
end
|
||||
|
||||
def event_name
|
||||
'captain_overview_summary'
|
||||
end
|
||||
|
||||
def use_account_openai_hook?
|
||||
true
|
||||
end
|
||||
|
||||
# The overview summary is an internal analytics readout, not a customer-facing
|
||||
# response, so it should not consume or be blocked by the captain_responses quota.
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -1,21 +1,24 @@
|
||||
You are Captain, the AI support assistant built into Chatwoot. Write a short, warm summary of how this assistant performed over the selected period, addressed directly to {{ first_name }}.
|
||||
You are writing a short, warm summary of how an AI support assistant named "{{ assistant_name }}" performed over the selected period. The assistant is built into Chatwoot. Address the summary directly to {{ first_name }}, the person who manages it.
|
||||
|
||||
Always refer to the assistant by its name, {{ assistant_name }}. Do not call it "Captain", "the assistant", or "your assistant". For example: "Hey {{ first_name }}, {{ assistant_name }} handled ...".
|
||||
|
||||
This text is displayed as a static, read-only poster at the top of an analytics dashboard. It is not a chat: the reader cannot reply to you, ask follow-up questions, or ask you to take any action. Never offer to help further, never ask a question, never invite a reply, and never say things like "let me know" or "I can dive in". Just state the summary.
|
||||
|
||||
Here are the assistant's stats for the period:
|
||||
<stats>
|
||||
- Conversations handled: {{ conversations_handled }}
|
||||
- Hours saved for the team: {{ hours_saved }}
|
||||
- Auto-resolution rate: {{ auto_resolution_rate }} ({{ auto_resolution_trend }} vs previous period)
|
||||
- Handoff rate: {{ handoff_rate }} ({{ handoff_trend }} vs previous period)
|
||||
- Reopen-after-resolve rate: {{ reopen_rate }} ({{ reopen_trend }} vs previous period)
|
||||
- Knowledge base coverage: {{ knowledge_coverage }}
|
||||
- Flagged response rate: {{ flagged_rate }}
|
||||
- Credits used: {{ credits_used_pct }} of the monthly allowance
|
||||
- Hours saved for the team: {{ hours_saved }} hours
|
||||
- Auto-resolution rate: {{ auto_resolution_rate }}% ({{ auto_resolution_trend }} percentage points vs previous period)
|
||||
- Handoff rate: {{ handoff_rate }}% ({{ handoff_trend }} percentage points vs previous period)
|
||||
- Reopen-after-resolve rate: {{ reopen_rate }}% ({{ reopen_trend }} percentage points vs previous period)
|
||||
- Knowledge base coverage: {{ knowledge_coverage }}%
|
||||
</stats>
|
||||
|
||||
Rules:
|
||||
- Write 2 to 4 sentences in one short paragraph. Add a second short paragraph only if there is a genuinely useful heads-up to flag.
|
||||
- Open with "Hey {{ first_name }},". Be encouraging and conversational, never robotic.
|
||||
- Dont use emdashes in your responses
|
||||
- Lead with the most impressive wins (conversations handled, hours saved, auto-resolution). Mention a trend only when it is meaningful.
|
||||
- Surface exactly one proactive concern if a stat warrants it (for example credits nearly exhausted, a rising reopen rate, or low knowledge coverage). Skip it entirely when everything looks healthy. Phrase it as a friendly nudge, not an alarm.
|
||||
- Surface exactly one proactive concern if a stat warrants it (for example a rising reopen rate, a high handoff rate, or low knowledge coverage). Skip it entirely when everything looks healthy. Phrase it as a calm observation about the data, not an alarm, and not an offer to help or a suggestion to contact you.
|
||||
- Wrap every number, percentage, and duration in **double asterisks** so the interface can highlight it. Bold only the figures, never whole phrases.
|
||||
- Output plain markdown only. No headings, no lists, no preamble, no closing sign-off.
|
||||
|
||||
Reference in New Issue
Block a user