Skip to content
72 changes: 72 additions & 0 deletions app/services/discourse_rewind/action/ai_usage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

# AI usage statistics from discourse-ai plugin
# Shows total usage, favorite features, token consumption, etc.
module DiscourseRewind
module Action
class AiUsage < BaseReport
def call
return if !enabled?

base_query = AiApiAuditLog.where(user_id: user.id).where(created_at: date)

# Get aggregated stats in a single query
stats =
base_query.select(
"COUNT(*) as total_requests",
"COALESCE(SUM(request_tokens), 0) as total_request_tokens",
"COALESCE(SUM(response_tokens), 0) as total_response_tokens",
"COUNT(CASE WHEN response_tokens > 0 THEN 1 END) as successful_requests",
).take

return if stats.total_requests == 0

total_tokens = stats.total_request_tokens + stats.total_response_tokens
success_rate =
(
if stats.total_requests > 0
(stats.successful_requests.to_f / stats.total_requests * 100).round(1)
else
0
end
)

# Most used features (top 5)
feature_usage =
base_query
.group(:feature_name)
.order("COUNT(*) DESC")
.limit(5)
.pluck(:feature_name, Arel.sql("COUNT(*)"))
.to_h

# Most used AI model (top 5)
model_usage =
base_query
.where.not(language_model: nil)
.group(:language_model)
.order("COUNT(*) DESC")
.limit(5)
.pluck(:language_model, Arel.sql("COUNT(*)"))
.to_h

{
data: {
total_requests: stats.total_requests,
total_tokens: total_tokens,
request_tokens: stats.total_request_tokens,
response_tokens: stats.total_response_tokens,
feature_usage: feature_usage,
model_usage: model_usage,
success_rate: success_rate,
},
identifier: "ai-usage",
}
end

def enabled?
defined?(AiApiAuditLog) && SiteSetting.discourse_ai_enabled
end
end
end
end
62 changes: 62 additions & 0 deletions app/services/discourse_rewind/action/assignments.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

# Assignment statistics using discourse-assign plugin data
# Shows how many assignments, completed, pending, etc.
module DiscourseRewind
module Action
class Assignments < BaseReport
def call
return if !enabled?

# Assignments made to the user
assignments_scope =
Assignment
.where(assigned_to_id: user.id, assigned_to_type: "User")
.where(created_at: date)

total_assigned = assignments_scope.count

# Completed assignments (topics that were assigned and then closed or unassigned)
completed_count =
assignments_scope
.joins(:topic)
.where(
"topics.closed = true OR assignments.active = false OR assignments.updated_at > assignments.created_at",
)
.distinct
.count

# Currently pending (still open and assigned)
pending_count =
Assignment
.where(assigned_to_id: user.id, assigned_to_type: "User", active: true)
.joins(:topic)
.where(topics: { closed: false })
.count

# Assignments made by the user to others
assigned_by_user =
Assignment
.where(assigned_by_user_id: user.id)
.where(created_at: date)
.count

{
data: {
total_assigned: total_assigned,
completed: completed_count,
pending: pending_count,
assigned_by_user: assigned_by_user,
completion_rate:
total_assigned > 0 ? (completed_count.to_f / total_assigned * 100).round(1) : 0,
},
identifier: "assignments",
}
end

def enabled?
defined?(Assignment) && SiteSetting.assign_enabled
end
end
end
end
82 changes: 82 additions & 0 deletions app/services/discourse_rewind/action/chat_usage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# frozen_string_literal: true

# Chat usage statistics
# Shows message counts, favorite channels, DM activity, etc.
module DiscourseRewind
module Action
class ChatUsage < BaseReport
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing with this report, is that it may be pretty underwhelming depending on a site's chat_channel_retention_days setting. It defaults to 90 days, so will only be the last 3 months for public channels.

For DMs it's better chat_dm_retention_days is infinite by default, but people can set it lower.

So yeah not sure what the strategy of dealing with this would be, it just might be confusing to say this is your year of messages + favourite channels when someone might have set retention to 2 weeks.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the design from @awesomerobot can say, "Recent Chat Activity" ?

def call
return if !enabled?

messages =
Chat::Message.where(user_id: user.id).where(created_at: date).where(deleted_at: nil)

total_messages = messages.count
return if total_messages == 0

# Get favorite channels (public channels)
channel_usage =
messages
.joins(:chat_channel)
.where(chat_channels: { type: "CategoryChannel" })
.group("chat_channels.id", "chat_channels.name")
.count
.sort_by { |_, count| -count }
.first(5)
.map do |(id, name), count|
{ channel_id: id, channel_name: name, message_count: count }
end

# DM statistics
dm_message_count =
messages.joins(:chat_channel).where(chat_channels: { type: "DirectMessageChannel" }).count

# Unique DM conversations
unique_dm_channels =
messages
.joins(:chat_channel)
.where(chat_channels: { type: "DirectMessageChannel" })
.distinct
.count(:chat_channel_id)

# Messages with reactions received
messages_with_reactions =
Chat::MessageReaction
.joins(:chat_message)
.where(chat_messages: { user_id: user.id })
.where(chat_messages: { created_at: date })
.distinct
.count(:chat_message_id)

# Total reactions received
total_reactions_received =
Chat::MessageReaction
.joins(:chat_message)
.where(chat_messages: { user_id: user.id })
.where(chat_messages: { created_at: date })
.count

# Average message length
avg_message_length =
messages.where("LENGTH(message) > 0").average("LENGTH(message)")&.to_f&.round(1) || 0

{
data: {
total_messages: total_messages,
favorite_channels: channel_usage,
dm_message_count: dm_message_count,
unique_dm_channels: unique_dm_channels,
messages_with_reactions: messages_with_reactions,
total_reactions_received: total_reactions_received,
avg_message_length: avg_message_length,
},
identifier: "chat-usage",
}
end

def enabled?
SiteSetting.chat_enabled
end
end
end
end
122 changes: 122 additions & 0 deletions app/services/discourse_rewind/action/favorite_gifs.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# frozen_string_literal: true

# Find the user's most used GIFs in posts and chat
# Ranks by usage count and engagement (likes/reactions)
module DiscourseRewind
module Action
class FavoriteGifs < BaseReport
GIF_URL_PATTERN =
%r{
https?://[^\s]+\.(?:gif|gifv)
|
https?://(?!(?:developers|support|blog)\.) (?:[^/\s]+\.)?giphy\.com/(?!dashboard\b)[^\s]+
|
https?://(?!(?:support)\.) (?:[^/\s]+\.)?tenor\.com/(?!gifapi\b)[^\s]+
}ix
MAX_RESULTS = 5

def call
gif_data = {}

# Get GIFs from posts
post_gifs = extract_gifs_from_posts
post_gifs.each do |url, data|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
gif_data[url][:usage_count] += data[:count]
gif_data[url][:likes] += data[:likes]
end

# Get GIFs from chat messages if chat is enabled
if SiteSetting.chat_enabled
chat_gifs = extract_gifs_from_chat
chat_gifs.each do |url, data|
gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 }
gif_data[url][:usage_count] += data[:count]
gif_data[url][:reactions] += data[:reactions]
end
end

return if gif_data.empty?

# Sort by engagement score (usage * 10 + likes + reactions)
sorted_gifs =
gif_data
.values
.sort_by { |gif| -(gif[:usage_count] * 10 + gif[:likes] + gif[:reactions]) }
.first(MAX_RESULTS)

{
data: {
favorite_gifs: sorted_gifs,
total_gif_usage: gif_data.values.sum { |g| g[:usage_count] },
},
identifier: "favorite-gifs",
}
end

private

def extract_gifs_from_posts
gif_usage = {}

posts =
Post
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("raw ~* ?", gif_sql_pattern)
.select(:id, :raw, :like_count)

posts.each do |post|
gif_urls = post.raw.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
gif_urls.each do |url|
gif_usage[url] ||= { count: 0, likes: 0 }
gif_usage[url][:count] += 1
gif_usage[url][:likes] += post.like_count || 0
end
end

gif_usage
end

def extract_gifs_from_chat
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about chat potentially not having much meaningful data

gif_usage = {}

messages =
Chat::Message
.where(user_id: user.id)
.where(created_at: date)
.where(deleted_at: nil)
.where("message ~* ?", gif_sql_pattern)
.select(:id, :message)

messages.each do |message|
gif_urls =
message.message.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) }
gif_urls.each do |url|
gif_usage[url] ||= { count: 0, reactions: 0 }
gif_usage[url][:count] += 1

# Count reactions on this message
reaction_count = Chat::MessageReaction.where(chat_message_id: message.id).count
gif_usage[url][:reactions] += reaction_count
end
end

gif_usage
end

def gif_sql_pattern
@gif_sql_pattern ||= GIF_URL_PATTERN.source.gsub(/\s+/, "")
end

def content_gif_url?(url)
return true if url.match?(/\.(gif|gifv)(?:\?|$)/i)
return true if url.match?(%r{giphy\.com/(?:gifs?|media|embed|stickers|clips)}i)
return true if url.match?(%r{tenor\.com/(?:view|watch|embed|gif)}i)

false
end
end
end
end
Loading
Loading