Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeff17dfd7 | ||
|
|
02d4de7357 | ||
|
|
afa2b49e45 | ||
|
|
6ba86788b2 | ||
|
|
3c4225cf57 | ||
|
|
d0e7753258 | ||
|
|
563619c95d | ||
|
|
f5c6d375ef | ||
|
|
da8ce630db | ||
|
|
c964f53288 |
@@ -1,6 +1,10 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
// Viewer's UTC offset in hours, matching the reports API convention so the
|
||||
// backend can anchor calendar ranges to the viewer's day.
|
||||
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
|
||||
class CaptainAssistant extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/assistants', { accountScoped: true });
|
||||
@@ -23,12 +27,14 @@ class CaptainAssistant extends ApiClient {
|
||||
}
|
||||
|
||||
getStats({ assistantId, range }) {
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, { params: { range } });
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
});
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range }) {
|
||||
return axios.get(`${this.url}/${assistantId}/summary`, {
|
||||
params: { range },
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,14 +43,19 @@ const resolveTrendGood = (trendValue, direction) => {
|
||||
return direction === 'up' ? trendValue > 0 : trendValue < 0;
|
||||
};
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, absoluteTrend = false) => {
|
||||
// Trend units mirror the backend pack mode: a relative percent change ('%') for
|
||||
// :percent metrics, a percentage-point delta (' pts') for rate metrics packed as
|
||||
// :point, and a plain number for :absolute counts like conversation depth.
|
||||
const TREND_SUFFIX = { percent: '%', point: ' pts', absolute: '' };
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||
const data = stats.value?.[statKey];
|
||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||
|
||||
const sign = data.trend > 0 ? '+' : '';
|
||||
return {
|
||||
value: formatValue(data.current),
|
||||
trend: absoluteTrend ? `${sign}${data.trend}` : `${sign}${data.trend}%`,
|
||||
trend: `${sign}${data.trend}${TREND_SUFFIX[trendKind]}`,
|
||||
trendGood: resolveTrendGood(data.trend, direction),
|
||||
};
|
||||
};
|
||||
@@ -66,13 +71,13 @@ const metrics = computed(() => [
|
||||
key: 'autoResolution',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
|
||||
...metricFor('auto_resolution_rate', v => `${v}%`, 'up'),
|
||||
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'handoff',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
|
||||
...metricFor('handoff_rate', v => `${v}%`, 'down'),
|
||||
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'hoursSaved',
|
||||
@@ -84,13 +89,18 @@ const metrics = computed(() => [
|
||||
key: 'reopen',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
|
||||
...metricFor('reopen_rate', v => `${v}%`, 'down'),
|
||||
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'depth',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.HINT'),
|
||||
...metricFor('conversation_depth', v => v.toFixed(1), 'neutral', true),
|
||||
...metricFor(
|
||||
'conversation_depth',
|
||||
v => v.toFixed(1),
|
||||
'neutral',
|
||||
'absolute'
|
||||
),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -16,12 +16,12 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event_end_time
|
||||
event_end_time: event_end_time,
|
||||
**actor_attributes(actor_from_event(event))
|
||||
)
|
||||
|
||||
create_bot_resolved_event(conversation, reporting_event)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
persist_reporting_event(reporting_event)
|
||||
end
|
||||
|
||||
def first_reply_created(event)
|
||||
@@ -39,11 +39,11 @@ class ReportingEventListener < BaseListener
|
||||
user_id: message.sender_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: last_non_human_activity(conversation),
|
||||
event_end_time: message.created_at
|
||||
event_end_time: message.created_at,
|
||||
**actor_attributes(actor_from_event(event) || message.sender)
|
||||
)
|
||||
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
persist_reporting_event(reporting_event)
|
||||
end
|
||||
|
||||
def reply_created(event)
|
||||
@@ -65,10 +65,10 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: waiting_since,
|
||||
event_end_time: message.created_at
|
||||
event_end_time: message.created_at,
|
||||
**actor_attributes(actor_from_event(event) || message.sender)
|
||||
)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
persist_reporting_event(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_bot_handoff(event)
|
||||
@@ -92,10 +92,10 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event_end_time
|
||||
event_end_time: event_end_time,
|
||||
**actor_attributes(actor_from_event(event))
|
||||
)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event)
|
||||
persist_reporting_event(reporting_event)
|
||||
end
|
||||
|
||||
def conversation_captain_inference_resolved(event)
|
||||
@@ -116,43 +116,53 @@ class ReportingEventListener < BaseListener
|
||||
name: 'conversation_resolved'
|
||||
).where('event_end_time <= ?', event_end_time).order(event_end_time: :desc).first
|
||||
|
||||
# For first-time openings, value is 0
|
||||
# For reopenings, calculate time since resolution
|
||||
if last_resolved_event
|
||||
time_since_resolved = event_end_time.to_i - last_resolved_event.event_end_time.to_i
|
||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time)
|
||||
start_time = last_resolved_event.event_end_time
|
||||
else
|
||||
time_since_resolved = 0
|
||||
business_hours_value = 0
|
||||
start_time = conversation.created_at
|
||||
end
|
||||
|
||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
create_conversation_opened_event(
|
||||
conversation,
|
||||
conversation_opened_event_attributes(conversation, last_resolved_event, event_end_time),
|
||||
actor_from_event(event)
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
def conversation_opened_event_attributes(conversation, last_resolved_event, event_end_time)
|
||||
return first_conversation_opened_event_attributes(conversation, event_end_time) if last_resolved_event.blank?
|
||||
|
||||
{
|
||||
value: event_end_time.to_i - last_resolved_event.event_end_time.to_i,
|
||||
value_in_business_hours: business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time),
|
||||
event_start_time: last_resolved_event.event_end_time,
|
||||
event_end_time: event_end_time
|
||||
}
|
||||
end
|
||||
|
||||
def first_conversation_opened_event_attributes(conversation, event_end_time)
|
||||
{
|
||||
value: 0,
|
||||
value_in_business_hours: 0,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event_end_time
|
||||
}
|
||||
end
|
||||
|
||||
def create_conversation_opened_event(conversation, event_attributes, actor)
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_opened',
|
||||
value: time_since_resolved,
|
||||
value_in_business_hours: business_hours_value,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: start_time,
|
||||
event_end_time: event_end_time
|
||||
**event_attributes,
|
||||
**actor_attributes(actor)
|
||||
)
|
||||
reporting_event.save!
|
||||
persist_reporting_event(reporting_event, rollup: false)
|
||||
end
|
||||
|
||||
def create_captain_inference_event(event, event_name)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
time_to_event = event.timestamp.to_i - conversation.created_at.to_i
|
||||
|
||||
ReportingEvent.create!(
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: event_name,
|
||||
value: time_to_event,
|
||||
account_id: conversation.account_id,
|
||||
@@ -160,8 +170,10 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event.timestamp
|
||||
event_end_time: event.timestamp,
|
||||
**actor_attributes(actor_from_event(event))
|
||||
)
|
||||
persist_reporting_event(reporting_event, rollup: false)
|
||||
end
|
||||
|
||||
def create_bot_resolved_event(conversation, reporting_event)
|
||||
@@ -171,8 +183,29 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
bot_resolved_event = reporting_event.dup
|
||||
bot_resolved_event.name = 'conversation_bot_resolved'
|
||||
bot_resolved_event.save!
|
||||
safe_rollup(bot_resolved_event)
|
||||
persist_reporting_event(bot_resolved_event)
|
||||
end
|
||||
|
||||
def persist_reporting_event(reporting_event, rollup: true)
|
||||
reporting_event.save!
|
||||
safe_rollup(reporting_event) if rollup
|
||||
update_captain_conversation_fact(reporting_event)
|
||||
end
|
||||
|
||||
def update_captain_conversation_fact(reporting_event)
|
||||
return unless defined?(Captain::ConversationFactUpdater)
|
||||
|
||||
Captain::ConversationFactUpdater.record_reporting_event(reporting_event)
|
||||
end
|
||||
|
||||
def actor_from_event(event)
|
||||
event.data[:performed_by]
|
||||
end
|
||||
|
||||
def actor_attributes(actor)
|
||||
return {} if actor.blank? || actor.id.blank?
|
||||
|
||||
{ actor_type: actor.class.name, actor_id: actor.id }
|
||||
end
|
||||
|
||||
def safe_rollup(reporting_event)
|
||||
|
||||
@@ -343,7 +343,7 @@ class Conversation < ApplicationRecord
|
||||
def dispatcher_dispatch(event_name, changed_attributes = nil)
|
||||
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, conversation: self, notifiable_assignee_change: notifiable_assignee_change?,
|
||||
changed_attributes: changed_attributes,
|
||||
performed_by: Current.executed_by)
|
||||
performed_by: Current.executed_by || Current.user)
|
||||
end
|
||||
|
||||
def set_unread_count_deletion_data
|
||||
|
||||
@@ -45,3 +45,5 @@ class CsatSurveyResponse < ApplicationRecord
|
||||
# filter by rating value
|
||||
scope :filter_by_rating, ->(rating) { where(rating: rating) if rating.present? }
|
||||
end
|
||||
|
||||
CsatSurveyResponse.include_mod_with('CsatSurveyResponse')
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# Table name: reporting_events
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# actor_type :string
|
||||
# event_end_time :datetime
|
||||
# event_start_time :datetime
|
||||
# name :string
|
||||
@@ -11,20 +12,22 @@
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer
|
||||
# actor_id :bigint
|
||||
# conversation_id :integer
|
||||
# inbox_id :integer
|
||||
# user_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_reporting_events_for_response_distribution (account_id,name,inbox_id,created_at)
|
||||
# index_reporting_events_on_account_id (account_id)
|
||||
# index_reporting_events_on_conversation_id (conversation_id)
|
||||
# index_reporting_events_on_created_at (created_at)
|
||||
# index_reporting_events_on_inbox_id (inbox_id)
|
||||
# index_reporting_events_on_name (name)
|
||||
# index_reporting_events_on_user_id (user_id)
|
||||
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
||||
# idx_reporting_events_on_account_actor_name_created (account_id,actor_type,actor_id,name,created_at)
|
||||
# index_reporting_events_for_response_distribution (account_id,name,inbox_id,created_at)
|
||||
# index_reporting_events_on_account_id (account_id)
|
||||
# index_reporting_events_on_conversation_id (conversation_id)
|
||||
# index_reporting_events_on_created_at (created_at)
|
||||
# index_reporting_events_on_inbox_id (inbox_id)
|
||||
# index_reporting_events_on_name (name)
|
||||
# index_reporting_events_on_user_id (user_id)
|
||||
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
||||
#
|
||||
|
||||
class ReportingEvent < ApplicationRecord
|
||||
@@ -36,6 +39,7 @@ class ReportingEvent < ApplicationRecord
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :inbox, optional: true
|
||||
belongs_to :conversation, optional: true
|
||||
belongs_to :actor, polymorphic: true, optional: true
|
||||
|
||||
# Scopes for filtering
|
||||
scope :filter_by_date_range, lambda { |range|
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class AddActorToReportingEvents < ActiveRecord::Migration[7.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_column :reporting_events, :actor_type, :string
|
||||
add_column :reporting_events, :actor_id, :bigint
|
||||
|
||||
add_index :reporting_events,
|
||||
[:account_id, :actor_type, :actor_id, :name, :created_at],
|
||||
name: 'idx_reporting_events_on_account_actor_name_created',
|
||||
algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
class CreateCaptainConversationFacts < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_captain_conversation_facts_table
|
||||
add_captain_conversation_facts_indexes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_captain_conversation_facts_table
|
||||
create_table :captain_conversation_facts do |t|
|
||||
t.bigint :account_id, null: false
|
||||
t.bigint :conversation_id, null: false
|
||||
t.bigint :assistant_id, null: false
|
||||
t.bigint :inbox_id, null: false
|
||||
t.datetime :first_captain_message_at
|
||||
t.datetime :last_captain_message_at
|
||||
t.datetime :captain_resolved_at
|
||||
t.datetime :captain_handed_off_at
|
||||
t.datetime :first_human_reply_after_captain_at
|
||||
t.datetime :reopened_after_captain_resolution_at
|
||||
t.bigint :csat_response_id
|
||||
t.integer :csat_rating
|
||||
t.datetime :csat_submitted_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
|
||||
def add_captain_conversation_facts_indexes
|
||||
add_index :captain_conversation_facts, :account_id
|
||||
add_index :captain_conversation_facts, :conversation_id, unique: true
|
||||
add_index :captain_conversation_facts, [:account_id, :assistant_id, :first_captain_message_at],
|
||||
name: 'idx_captain_facts_on_account_assistant_first_message'
|
||||
add_index :captain_conversation_facts, [:account_id, :captain_resolved_at],
|
||||
name: 'idx_captain_facts_on_account_resolved_at'
|
||||
add_index :captain_conversation_facts, [:account_id, :captain_handed_off_at],
|
||||
name: 'idx_captain_facts_on_account_handed_off_at'
|
||||
add_index :captain_conversation_facts, [:account_id, :csat_submitted_at],
|
||||
name: 'idx_captain_facts_on_account_csat_submitted_at'
|
||||
end
|
||||
end
|
||||
+28
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_30_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_01_001000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -350,6 +350,30 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_30_000000) do
|
||||
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
|
||||
end
|
||||
|
||||
create_table "captain_conversation_facts", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "assistant_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
t.datetime "first_captain_message_at"
|
||||
t.datetime "last_captain_message_at"
|
||||
t.datetime "captain_resolved_at"
|
||||
t.datetime "captain_handed_off_at"
|
||||
t.datetime "first_human_reply_after_captain_at"
|
||||
t.datetime "reopened_after_captain_resolution_at"
|
||||
t.bigint "csat_response_id"
|
||||
t.integer "csat_rating"
|
||||
t.datetime "csat_submitted_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "assistant_id", "first_captain_message_at"], name: "idx_captain_facts_on_account_assistant_first_message"
|
||||
t.index ["account_id", "captain_handed_off_at"], name: "idx_captain_facts_on_account_handed_off_at"
|
||||
t.index ["account_id", "captain_resolved_at"], name: "idx_captain_facts_on_account_resolved_at"
|
||||
t.index ["account_id", "csat_submitted_at"], name: "idx_captain_facts_on_account_csat_submitted_at"
|
||||
t.index ["account_id"], name: "index_captain_conversation_facts_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_captain_conversation_facts_on_conversation_id", unique: true
|
||||
end
|
||||
|
||||
create_table "captain_custom_tools", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "slug", null: false
|
||||
@@ -1167,6 +1191,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_30_000000) do
|
||||
t.float "value_in_business_hours"
|
||||
t.datetime "event_start_time", precision: nil
|
||||
t.datetime "event_end_time", precision: nil
|
||||
t.string "actor_type"
|
||||
t.bigint "actor_id"
|
||||
t.index ["account_id", "actor_type", "actor_id", "name", "created_at"], name: "idx_reporting_events_on_account_actor_name_created"
|
||||
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
|
||||
t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution"
|
||||
t.index ["account_id"], name: "index_reporting_events_on_account_id"
|
||||
|
||||
@@ -16,10 +16,13 @@ class Captain::AssistantStatsBuilder
|
||||
# `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.
|
||||
def initialize(assistant, range = DEFAULT_RANGE)
|
||||
# `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.
|
||||
def initialize(assistant, range = DEFAULT_RANGE, timezone_offset = nil)
|
||||
@assistant = assistant
|
||||
@account = assistant.account
|
||||
@range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
|
||||
@timezone = ActiveSupport::TimeZone[timezone_offset.to_f] || Time.zone
|
||||
end
|
||||
|
||||
def metrics
|
||||
@@ -34,11 +37,7 @@ class Captain::AssistantStatsBuilder
|
||||
# Human-readable description of the period the metrics cover, for grounding the
|
||||
# LLM summary in real dates.
|
||||
def period
|
||||
{
|
||||
label: period_label,
|
||||
starts_on: current_range.first.to_date,
|
||||
ends_on: current_range.last.to_date
|
||||
}
|
||||
{ label: period_label, starts_on: current_range.first.to_date, ends_on: current_range.last.to_date }
|
||||
end
|
||||
|
||||
private
|
||||
@@ -75,25 +74,31 @@ class Captain::AssistantStatsBuilder
|
||||
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 = Time.current.beginning_of_month
|
||||
elapsed = Time.current - start
|
||||
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..Time.current, previous: previous_start..previous_end }
|
||||
{ current: start..now, previous: previous_start..previous_end }
|
||||
end
|
||||
|
||||
def last_month_ranges
|
||||
start = 1.month.ago.beginning_of_month
|
||||
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: days.days.ago..Time.current, previous: (2 * days).days.ago..days.days.ago }
|
||||
{ current: (now - days.days)..now, previous: (now - (2 * days).days)..(now - days.days) }
|
||||
end
|
||||
|
||||
# Combines the per-window message counts and reply time with the reporting-event metrics for one window.
|
||||
@@ -188,14 +193,18 @@ class Captain::AssistantStatsBuilder
|
||||
def reopen_rate(range)
|
||||
resolved_scope = account.reporting_events.where(name: RESOLVED_EVENT_NAMES, created_at: range,
|
||||
conversation_id: handled_scope(range).select(:conversation_id))
|
||||
# event_start_time on a reopen is the preceding resolve's timestamp, so requiring it
|
||||
# within the window keeps only reopens that followed the in-window resolve, not an
|
||||
# earlier resolve/reopen cycle on the same conversation.
|
||||
# event_end_time on a reopen is when it actually reopened. Join it to the conversation's own
|
||||
# Captain resolves and keep only reopens at/after one of them, so a human resolve/reopen earlier
|
||||
# in the same window isn't mistaken for a reopen-after-Captain-resolve. (Comparing the reopen's
|
||||
# start time instead would misfire: the inference event is dispatched just after the generic
|
||||
# conversation_resolved that seeds event_start_time, so it can land after the reopen's start.)
|
||||
reopened = account.reporting_events
|
||||
.where(name: 'conversation_opened', conversation_id: resolved_scope.select(:conversation_id))
|
||||
.where(name: 'conversation_opened')
|
||||
.where('reporting_events.value > 0')
|
||||
.where('reporting_events.event_start_time >= ?', range.first)
|
||||
.distinct.count(:conversation_id)
|
||||
.joins("INNER JOIN (#{resolved_scope.to_sql}) resolves " \
|
||||
'ON resolves.conversation_id = reporting_events.conversation_id ' \
|
||||
'AND reporting_events.event_end_time >= resolves.event_end_time')
|
||||
.distinct.count('reporting_events.conversation_id')
|
||||
rate(reopened, resolved_scope.distinct.count(:conversation_id))
|
||||
end
|
||||
|
||||
|
||||
@@ -44,11 +44,11 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range]).metrics
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
|
||||
end
|
||||
|
||||
def summary
|
||||
result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range]))
|
||||
result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]))
|
||||
|
||||
if result[:error]
|
||||
render json: { error: result[:error] }, status: :unprocessable_content
|
||||
|
||||
@@ -35,6 +35,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||
has_many :conversation_facts, class_name: 'Captain::ConversationFact', dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_conversation_facts
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# captain_handed_off_at :datetime
|
||||
# captain_resolved_at :datetime
|
||||
# csat_rating :integer
|
||||
# csat_submitted_at :datetime
|
||||
# first_captain_message_at :datetime
|
||||
# first_human_reply_after_captain_at :datetime
|
||||
# last_captain_message_at :datetime
|
||||
# reopened_after_captain_resolution_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# csat_response_id :bigint
|
||||
# inbox_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_captain_facts_on_account_assistant_first_message (account_id,assistant_id,first_captain_message_at)
|
||||
# idx_captain_facts_on_account_csat_submitted_at (account_id,csat_submitted_at)
|
||||
# idx_captain_facts_on_account_handed_off_at (account_id,captain_handed_off_at)
|
||||
# idx_captain_facts_on_account_resolved_at (account_id,captain_resolved_at)
|
||||
# index_captain_conversation_facts_on_account_id (account_id)
|
||||
# index_captain_conversation_facts_on_conversation_id (conversation_id) UNIQUE
|
||||
#
|
||||
class Captain::ConversationFact < ApplicationRecord
|
||||
self.table_name = 'captain_conversation_facts'
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :conversation
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :inbox
|
||||
belongs_to :csat_response, class_name: 'CsatSurveyResponse', optional: true
|
||||
|
||||
validates :conversation_id, uniqueness: true
|
||||
end
|
||||
@@ -13,6 +13,7 @@ module Enterprise::Concerns::Account
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||
has_many :captain_conversation_facts, dependent: :destroy_async, class_name: 'Captain::ConversationFact'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
|
||||
@@ -7,6 +7,7 @@ module Enterprise::Concerns::Conversation
|
||||
has_many :sla_events, dependent: :destroy_async
|
||||
has_many :calls, dependent: :destroy_async
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
has_one :captain_conversation_fact, class_name: 'Captain::ConversationFact', dependent: :destroy_async
|
||||
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
|
||||
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
module Enterprise::CsatSurveyResponse
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_commit :update_captain_conversation_fact, on: [:create, :update]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_captain_conversation_fact
|
||||
Captain::ConversationFactUpdater.record_csat_response(self)
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,8 @@ module Enterprise::Message
|
||||
has_one :call, class_name: 'Call', foreign_key: :message_id, dependent: :nullify, inverse_of: :message
|
||||
|
||||
scope :with_call, -> { includes(call: [:contact, { inbox: :channel }]) }
|
||||
|
||||
after_create_commit :update_captain_conversation_fact
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +17,10 @@ module Enterprise::Message
|
||||
|
||||
private
|
||||
|
||||
def update_captain_conversation_fact
|
||||
Captain::ConversationFactUpdater.record_message(self)
|
||||
end
|
||||
|
||||
def mark_pending_conversation_as_open_for_human_response
|
||||
return unless captain_pending_conversation?
|
||||
return unless human_response?
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
class Captain::ConversationFactUpdater
|
||||
RESOLVED_EVENT_NAMES = %w[
|
||||
conversation_resolved
|
||||
conversation_bot_resolved
|
||||
conversation_captain_inference_resolved
|
||||
].freeze
|
||||
|
||||
HANDOFF_EVENT_NAMES = %w[
|
||||
conversation_bot_handoff
|
||||
conversation_captain_inference_handoff
|
||||
].freeze
|
||||
|
||||
class << self
|
||||
def record_message(message)
|
||||
return unless message.outgoing?
|
||||
return if message.private?
|
||||
|
||||
if captain_message?(message)
|
||||
record_captain_message(message)
|
||||
elsif human_message?(message)
|
||||
record_human_message(message)
|
||||
end
|
||||
end
|
||||
|
||||
def record_reporting_event(reporting_event)
|
||||
if RESOLVED_EVENT_NAMES.include?(reporting_event.name)
|
||||
record_captain_resolution(reporting_event)
|
||||
elsif HANDOFF_EVENT_NAMES.include?(reporting_event.name)
|
||||
record_captain_handoff(reporting_event)
|
||||
elsif reporting_event.name == 'conversation_opened'
|
||||
record_reopen_after_captain_resolution(reporting_event)
|
||||
end
|
||||
end
|
||||
|
||||
def record_csat_response(csat_response)
|
||||
fact = Captain::ConversationFact.find_by(conversation_id: csat_response.conversation_id)
|
||||
return if fact.blank?
|
||||
|
||||
fact.assign_attributes(
|
||||
csat_response_id: csat_response.id,
|
||||
csat_rating: csat_response.rating,
|
||||
csat_submitted_at: csat_response.created_at
|
||||
)
|
||||
fact.save! if fact.changed?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_message?(message)
|
||||
message.sender_type == 'Captain::Assistant'
|
||||
end
|
||||
|
||||
def human_message?(message)
|
||||
message.sender_type == 'User'
|
||||
end
|
||||
|
||||
def record_captain_message(message)
|
||||
fact = find_or_create_fact!(
|
||||
conversation_id: message.conversation_id,
|
||||
account_id: message.account_id,
|
||||
inbox_id: message.inbox_id,
|
||||
assistant_id: message.sender_id
|
||||
)
|
||||
fact.first_captain_message_at ||= message.created_at
|
||||
fact.last_captain_message_at = latest_time(fact.last_captain_message_at, message.created_at)
|
||||
fact.save! if fact.changed?
|
||||
end
|
||||
|
||||
def record_human_message(message)
|
||||
fact = Captain::ConversationFact.find_by(conversation_id: message.conversation_id)
|
||||
return if fact.blank?
|
||||
return if fact.first_captain_message_at.blank?
|
||||
return if fact.first_human_reply_after_captain_at.present?
|
||||
return if message.created_at <= fact.first_captain_message_at
|
||||
|
||||
fact.update!(first_human_reply_after_captain_at: message.created_at)
|
||||
end
|
||||
|
||||
def record_captain_resolution(reporting_event)
|
||||
return unless captain_actor?(reporting_event)
|
||||
|
||||
fact = find_or_create_fact_from_event!(reporting_event)
|
||||
fact.captain_resolved_at = earliest_time(fact.captain_resolved_at, reporting_event.event_end_time)
|
||||
fact.save! if fact.changed?
|
||||
end
|
||||
|
||||
def record_captain_handoff(reporting_event)
|
||||
return unless captain_actor?(reporting_event)
|
||||
|
||||
fact = find_or_create_fact_from_event!(reporting_event)
|
||||
fact.captain_handed_off_at = earliest_time(fact.captain_handed_off_at, reporting_event.event_end_time)
|
||||
fact.save! if fact.changed?
|
||||
end
|
||||
|
||||
def record_reopen_after_captain_resolution(reporting_event)
|
||||
fact = Captain::ConversationFact.find_by(conversation_id: reporting_event.conversation_id)
|
||||
return if fact.blank?
|
||||
return if fact.captain_resolved_at.blank?
|
||||
return if fact.reopened_after_captain_resolution_at.present?
|
||||
return if reporting_event.event_end_time < fact.captain_resolved_at
|
||||
|
||||
fact.update!(reopened_after_captain_resolution_at: reporting_event.event_end_time)
|
||||
end
|
||||
|
||||
def captain_actor?(reporting_event)
|
||||
reporting_event.actor_type == 'Captain::Assistant' && reporting_event.actor_id.present?
|
||||
end
|
||||
|
||||
def find_or_create_fact_from_event!(reporting_event)
|
||||
find_or_create_fact!(
|
||||
conversation_id: reporting_event.conversation_id,
|
||||
account_id: reporting_event.account_id,
|
||||
inbox_id: reporting_event.inbox_id,
|
||||
assistant_id: reporting_event.actor_id
|
||||
)
|
||||
end
|
||||
|
||||
def find_or_create_fact!(conversation_id:, account_id:, inbox_id:, assistant_id:)
|
||||
Captain::ConversationFact.create_or_find_by!(conversation_id: conversation_id) do |fact|
|
||||
fact.account_id = account_id
|
||||
fact.inbox_id = inbox_id
|
||||
fact.assistant_id = assistant_id
|
||||
end
|
||||
end
|
||||
|
||||
def earliest_time(current_time, candidate_time)
|
||||
[current_time, candidate_time].compact.min
|
||||
end
|
||||
|
||||
def latest_time(current_time, candidate_time)
|
||||
[current_time, candidate_time].compact.max
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,8 @@ json.event_end_time reporting_event.event_end_time
|
||||
json.account_id reporting_event.account_id
|
||||
json.inbox_id reporting_event.inbox_id
|
||||
json.user_id reporting_event.user_id
|
||||
json.actor_type reporting_event.actor_type
|
||||
json.actor_id reporting_event.actor_id
|
||||
json.conversation_id reporting_event.conversation_id
|
||||
json.created_at reporting_event.created_at
|
||||
json.updated_at reporting_event.updated_at
|
||||
|
||||
@@ -52,6 +52,15 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
expect(metrics[:handoff_rate][:current]).to eq(50.0)
|
||||
end
|
||||
|
||||
it 'excludes resolution events that fall outside the current window' do
|
||||
create(:reporting_event, account: account, conversation: current_convo_a,
|
||||
name: 'conversation_captain_inference_resolved', created_at: 60.days.ago)
|
||||
|
||||
metrics = described_class.new(assistant, '30').metrics
|
||||
|
||||
expect(metrics[:auto_resolution_rate][:current]).to eq(0.0)
|
||||
end
|
||||
|
||||
it 'computes conversation depth as public replies per handled conversation' do
|
||||
depth = described_class.new(assistant, '30').metrics[:conversation_depth]
|
||||
|
||||
@@ -69,6 +78,109 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'range handling' do
|
||||
it 'accepts the allowed day and named ranges' do
|
||||
%w[7 30 90 this_month last_month].each do |allowed|
|
||||
expect(described_class.new(assistant, allowed).range).to eq(allowed)
|
||||
end
|
||||
end
|
||||
|
||||
it 'falls back to the default range for values outside the allowed set' do
|
||||
expect(described_class.new(assistant, '365000').range).to eq('30')
|
||||
expect(described_class.new(assistant, 'bogus').range).to eq('30')
|
||||
expect(described_class.new(assistant, nil).range).to eq('30')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#metrics reopen_rate' do
|
||||
# A conversation the assistant handled (messaged) inside the current 30-day window.
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
create(:message, account: account, inbox: inbox, conversation: conversation,
|
||||
sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
|
||||
end
|
||||
|
||||
it 'counts a reopen that happened after the captain resolve' do
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
|
||||
|
||||
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
|
||||
end
|
||||
|
||||
it 'ignores a human resolve/reopen that happened before the captain resolve' do
|
||||
# Earlier resolve/reopen cycle, then Captain resolves later in the same window.
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_opened', value: 120, event_start_time: 20.days.ago, event_end_time: 18.days.ago)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_bot_resolved', event_start_time: 5.days.ago, event_end_time: 5.days.ago)
|
||||
|
||||
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(0.0)
|
||||
end
|
||||
|
||||
it 'counts an evaluated-path reopen when bot_resolved is skipped and the inference event is newer' do
|
||||
# Prior human reply => create_bot_resolved_event skips conversation_bot_resolved, so the cohort
|
||||
# only holds the inference event, which is dispatched a moment after the generic conversation_resolved
|
||||
# that seeds the reopen's event_start_time. The match must use the reopen's actual reopen time.
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_captain_inference_resolved',
|
||||
event_start_time: 6.days.ago, event_end_time: 6.days.ago + 1.second)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 3.days.ago)
|
||||
|
||||
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
|
||||
end
|
||||
|
||||
it 'counts both inference and time-based bot resolves in the denominator' do
|
||||
# conversation: inference-resolved and reopened
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
|
||||
# other: time-based bot-resolved, never reopened
|
||||
other = create(:conversation, account: account, inbox: inbox)
|
||||
create(:message, account: account, inbox: inbox, conversation: other,
|
||||
sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: other,
|
||||
name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
|
||||
|
||||
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(50.0)
|
||||
end
|
||||
|
||||
it 'derives the cohort from handled conversations, not current inbox membership' do
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
|
||||
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
|
||||
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
|
||||
# The assistant is later removed from the inbox; the cohort must still resolve via handled messages.
|
||||
CaptainInbox.where(captain_assistant: assistant).delete_all
|
||||
|
||||
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'timezone anchoring' do
|
||||
# 2026-07-01 03:00 UTC is still 2026-06-30 in any timezone behind UTC by 4h+.
|
||||
it 'anchors the this_month window to the supplied offset, not UTC' do
|
||||
travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
|
||||
utc = described_class.new(assistant, 'this_month').period
|
||||
la = described_class.new(assistant, 'this_month', -7).period
|
||||
|
||||
expect(utc[:starts_on]).to eq(Date.new(2026, 7, 1))
|
||||
expect(la[:starts_on]).to eq(Date.new(2026, 6, 1))
|
||||
expect(la[:ends_on]).to eq(Date.new(2026, 6, 30))
|
||||
end
|
||||
end
|
||||
|
||||
it 'defaults to UTC when no offset is given' do
|
||||
travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
|
||||
expect(described_class.new(assistant, 'this_month').period[:starts_on]).to eq(Date.new(2026, 7, 1))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#metrics knowledge' do
|
||||
before do
|
||||
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
|
||||
|
||||
@@ -252,6 +252,48 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/summary' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:alice) { create(:user, account: account, role: :administrator, name: 'Alice Adams') }
|
||||
let(:bob) { create(:user, account: account, role: :administrator, name: 'Bob Brown') }
|
||||
let(:summary_service) { instance_double(Captain::OverviewSummaryService) }
|
||||
|
||||
def get_summary(user)
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/summary",
|
||||
params: { range: '30' },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
end
|
||||
|
||||
before do
|
||||
# Test env uses a null store; swap in a real store so caching behaviour is observable.
|
||||
allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
|
||||
allow(Captain::OverviewSummaryService).to receive(:new).and_return(summary_service)
|
||||
end
|
||||
|
||||
it 'caches the summary per viewer so one user never receives another user\'s greeting' do
|
||||
allow(summary_service).to receive(:perform).and_return({ message: 'Hi Alice' })
|
||||
|
||||
get_summary(alice)
|
||||
get_summary(alice) # served from Alice's cache, no regeneration
|
||||
get_summary(bob) # distinct cache key, regenerated for Bob
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Captain::OverviewSummaryService).to have_received(:new).twice
|
||||
end
|
||||
|
||||
it 'does not cache failures so a transient error is retried' do
|
||||
allow(summary_service).to receive(:perform).and_return({ error: 'LLM unavailable' })
|
||||
|
||||
get_summary(alice)
|
||||
get_summary(alice)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(json_response[:error]).to eq('LLM unavailable')
|
||||
expect(Captain::OverviewSummaryService).to have_received(:new).twice
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{id}/playground' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:valid_params) do
|
||||
|
||||
@@ -40,6 +40,16 @@ properties:
|
||||
- number
|
||||
- 'null'
|
||||
description: ID of the user/agent
|
||||
actor_type:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
description: Type of the actor that caused the reporting event
|
||||
actor_id:
|
||||
type:
|
||||
- number
|
||||
- 'null'
|
||||
description: ID of the actor that caused the reporting event
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
Reference in New Issue
Block a user