FEATURE: Emotion activity metrics table (#916)

This commit is contained in:
Rafael dos Santos Silva 2024-11-19 10:01:10 -03:00 committed by GitHub
parent b10be23533
commit 48d08dedd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 389 additions and 207 deletions

View File

@ -0,0 +1,35 @@
<div class="cell title">
{{#if this.model.icon}}
{{d-icon this.model.icon}}
{{/if}}
<a href="{{this.filterURL}}{{this.model.type}}">{{this.model.title}}</a>
</div>
<div class="cell value today-count">{{number this.model.todayCount}}</div>
<div
class="cell value yesterday-count {{this.model.yesterdayTrend}}"
title={{this.model.yesterdayCountTitle}}
>
{{number this.model.yesterdayCount}}
{{d-icon this.model.yesterdayTrendIcon}}
</div>
<div
class="cell value sevendays-count {{this.model.sevenDaysTrend}}"
title={{this.model.sevenDaysCountTitle}}
>
{{number this.model.lastSevenDaysCount}}
{{d-icon this.model.sevenDaysTrendIcon}}
</div>
<div
class="cell value thirty-days-count {{this.model.thirtyDaysTrend}}"
title={{this.model.thirtyDaysCountTitle}}
>
{{number this.model.lastThirtyDaysCount}}
{{#if this.model.canDisplayTrendIcon}}
{{d-icon this.model.thirtyDaysTrendIcon}}
{{/if}}
</div>

View File

@ -0,0 +1,12 @@
import Component from "@ember/component";
import { attributeBindings, classNames } from "@ember-decorators/component";
import getURL from "discourse-common/lib/get-url";
@classNames("admin-report-counters")
@attributeBindings("model.description:title")
export default class AdminReportEmotion extends Component {
get filterURL() {
let aMonthAgo = moment().subtract(1, "month").format("YYYY-MM-DD");
return getURL(`/filter?q=activity-after%3A${aMonthAgo}%20order%3A`);
}
}

View File

@ -1,9 +1,37 @@
import { computed } from "@ember/object";
import AdminDashboardTabController from "admin/controllers/admin-dashboard-tab";
export default class AdminDashboardSentiment extends AdminDashboardTabController {
@computed("startDate", "endDate")
get filters() {
return { startDate: this.startDate, endDate: this.endDate };
get emotions() {
const emotions = [
"admiration",
"amusement",
"anger",
"annoyance",
"approval",
"caring",
"confusion",
"curiosity",
"desire",
"disappointment",
"disapproval",
"disgust",
"embarrassment",
"excitement",
"fear",
"gratitude",
"grief",
"joy",
"love",
"nervousness",
"neutral",
"optimism",
"pride",
"realization",
"relief",
"remorse",
"sadness",
"surprise",
];
return emotions;
}
}

View File

@ -22,12 +22,43 @@
@filters={{this.filters}}
@showHeader={{true}}
/>
<AdminReport
@dataSourceName="post_emotion"
@filters={{this.filters}}
@showHeader={{true}}
/>
<div class="admin-report activity-metrics">
<div class="header">
<ul class="breadcrumb">
<li class="item report">
<LinkTo @route="adminReports" class="report-url">
{{i18n "admin.dashboard.emotion"}}
</LinkTo>
</li>
</ul>
</div>
<div class="report-body">
<div class="counters-list">
<div class="counters-header">
<div class="counters-cell"></div>
<div class="counters-cell">{{i18n
"admin.dashboard.reports.today"
}}</div>
<div class="counters-cell">{{i18n
"admin.dashboard.reports.yesterday"
}}</div>
<div class="counters-cell">{{i18n
"admin.dashboard.reports.last_7_days"
}}</div>
<div class="counters-cell">{{i18n
"admin.dashboard.reports.last_30_days"
}}</div>
</div>
{{#each this.emotions as |metric|}}
<AdminReport
@showHeader={{false}}
@forcedModes="emotion"
@dataSourceName="emotion_{{metric}}"
/>
{{/each}}
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -4,5 +4,8 @@
grid-template-columns: repeat(12, 1fr);
grid-column-gap: 1em;
grid-row-gap: 1em;
.admin-report {
grid-column: span 12;
}
}
}

View File

@ -1,8 +0,0 @@
.dashboard.dashboard-sentiment .charts {
.overall-sentiment {
grid-column: span 6;
}
.post-emotion {
grid-column: span 6;
}
}

View File

@ -1,10 +0,0 @@
.dashboard.dashboard-sentiment {
.charts {
.overall-sentiment {
grid-column: span 12;
}
.post-emotion {
grid-column: span 12;
}
}
}

View File

@ -11,6 +11,8 @@ en:
site_settings:
categories:
discourse_ai: "Discourse AI"
dashboard:
emotion: "Emotion"
js:
discourse_automation:
scriptables:

View File

@ -86,7 +86,7 @@ en:
ai_custom_summarization_allowed_groups: "Groups allowed to use create new summaries."
ai_pm_summarization_allowed_groups: "Groups allowed to create and view summaries in PMs."
ai_summarize_max_hot_topics_gists_per_batch: "After updating topics in the hot list, we'll generate brief summaries of the first N ones. (Disabled when 0)"
ai_hot_topic_gists_allowed_groups: "Groups allowed to see gists in the hot topics list."
ai_hot_topic_gists_allowed_groups: "Groups allowed to see gists in the hot topics list."
ai_summary_backfill_maximum_topics_per_hour: "Number of topic summaries to backfill per hour."
ai_bot_enabled: "Enable the AI Bot module."
@ -112,14 +112,65 @@ en:
reports:
overall_sentiment:
title: "Overall sentiment"
description: "The chart compares the number of posts classified as either positive or negative. These are calculated when positive or negative scores > the set threshold score. This means neutral posts are not shown. Private messages (PMs) are also excluded. Classified with \"cardiffnlp/twitter-roberta-base-sentiment-latest\""
description: 'The chart compares the number of posts classified as either positive or negative. These are calculated when positive or negative scores > the set threshold score. This means neutral posts are not shown. Private messages (PMs) are also excluded. Classified with "cardiffnlp/twitter-roberta-base-sentiment-latest"'
xaxis: "Positive(%)"
yaxis: "Date"
post_emotion:
title: "Post emotion"
description: "Number of posts classified with one of the following emotions, grouped by poster's trust level. Posts that are not positive or negative and considered neutral, are not shown. Private messages (PMs) are also excluded. Classified with \"j-hartmann/emotion-english-roberta-large\""
xaxis:
yaxis:
emotion_admiration:
title: Admiration
emotion_amusement:
title: Amusement
emotion_anger:
title: Anger
emotion_annoyance:
title: Annoyance
emotion_approval:
title: Approval
emotion_caring:
title: Caring
emotion_confusion:
title: Confusion
emotion_curiosity:
title: Curiosity
emotion_desire:
title: Desire
emotion_disappointment:
title: Disappointment
emotion_disapproval:
title: Disapproval
emotion_disgust:
title: Disgust
emotion_embarrassment:
title: Embarrassment
emotion_excitement:
title: Excitement
emotion_fear:
title: Fear
emotion_gratitude:
title: Gratitude
emotion_grief:
title: Grief
emotion_joy:
title: Joy
emotion_love:
title: Love
emotion_nervousness:
title: Nervousness
emotion_neutral:
title: Neutral
emotion_optimism:
title: Optimism
emotion_pride:
title: Pride
emotion_realization:
title: Realization
emotion_relief:
title: Relief
emotion_remorse:
title: Remorse
emotion_sadness:
title: Sadness
emotion_surprise:
title: Surprise
discourse_ai:
unknown_model: "Unknown AI model"

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
module DiscourseAi
module Sentiment
class EmotionDashboardReport
def self.register!(plugin)
Emotions::LIST.each do |emotion|
plugin.add_report("emotion_#{emotion}") do |report|
query_results = DiscourseAi::Sentiment::EmotionDashboardReport.fetch_data
report.data = query_results.pop(30).map { |row| { x: row.day, y: row.send(emotion) } }
report.prev30Days =
query_results.take(30).map { |row| { x: row.day, y: row.send(emotion) } }
end
end
def self.fetch_data
DB.query(<<~SQL, end: Time.now.tomorrow.midnight, start: 60.days.ago.midnight)
SELECT
posts.created_at::DATE AS day,
#{
DiscourseAi::Sentiment::Emotions::LIST
.map do |emotion|
"COUNT(*) FILTER (WHERE (classification_results.classification::jsonb->'#{emotion}')::float > 0.1) AS #{emotion}"
end
.join(",\n ")
}
FROM
classification_results
INNER JOIN
posts ON posts.id = classification_results.target_id AND
posts.deleted_at IS NULL AND
posts.created_at BETWEEN :start AND :end
INNER JOIN
topics ON topics.id = posts.topic_id AND
topics.archetype = 'regular' AND
topics.deleted_at IS NULL
WHERE
classification_results.target_type = 'Post' AND
classification_results.model_used = 'SamLowe/roberta-base-go_emotions'
GROUP BY 1
ORDER BY 1 ASC
SQL
end
end
end
end
end

View File

@ -4,38 +4,7 @@ module DiscourseAi
module Sentiment
class EmotionFilterOrder
def self.register!(plugin)
emotions = %w[
admiration
amusement
anger
annoyance
approval
caring
confusion
curiosity
desire
disappointment
disapproval
disgust
embarrassment
excitement
fear
gratitude
grief
joy
love
nervousness
neutral
optimism
pride
realization
relief
remorse
sadness
surprise
]
emotions.each do |emotion|
Emotions::LIST.each do |emotion|
filter_order_emotion = ->(scope, order_direction) do
emotion_clause = <<~SQL
SUM(

38
lib/sentiment/emotions.rb Normal file
View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
module DiscourseAi
module Sentiment
class Emotions
LIST = %w[
admiration
amusement
anger
annoyance
approval
caring
confusion
curiosity
desire
disappointment
disapproval
disgust
embarrassment
excitement
fear
gratitude
grief
joy
love
nervousness
neutral
optimism
pride
realization
relief
remorse
sadness
surprise
]
end
end
end

View File

@ -15,122 +15,8 @@ module DiscourseAi
plugin.on(:post_edited, &sentiment_analysis_cb)
EmotionFilterOrder.register!(plugin)
plugin.add_report("overall_sentiment") do |report|
report.modes = [:stacked_chart]
threshold = 0.6
sentiment_count_sql = Proc.new { |sentiment| <<~SQL }
COUNT(
CASE WHEN (cr.classification::jsonb->'#{sentiment}')::float > :threshold THEN 1 ELSE NULL END
) AS #{sentiment}_count
SQL
grouped_sentiments =
DB.query(
<<~SQL,
SELECT
DATE_TRUNC('day', p.created_at)::DATE AS posted_at,
#{sentiment_count_sql.call("positive")},
-#{sentiment_count_sql.call("negative")}
FROM
classification_results AS cr
INNER JOIN posts p ON p.id = cr.target_id AND cr.target_type = 'Post'
INNER JOIN topics t ON t.id = p.topic_id
INNER JOIN categories c ON c.id = t.category_id
WHERE
t.archetype = 'regular' AND
p.user_id > 0 AND
cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' AND
(p.created_at > :report_start AND p.created_at < :report_end)
GROUP BY DATE_TRUNC('day', p.created_at)
SQL
report_start: report.start_date,
report_end: report.end_date,
threshold: threshold,
)
data_points = %w[positive negative]
return report if grouped_sentiments.empty?
report.data =
data_points.map do |point|
{
req: "sentiment_#{point}",
color: point == "positive" ? report.colors[:lime] : report.colors[:purple],
label: I18n.t("discourse_ai.sentiment.reports.overall_sentiment.#{point}"),
data:
grouped_sentiments.map do |gs|
{ x: gs.posted_at, y: gs.public_send("#{point}_count") }
end,
}
end
end
plugin.add_report("post_emotion") do |report|
report.modes = [:stacked_line_chart]
threshold = 0.3
emotion_count_clause = Proc.new { |emotion| <<~SQL }
COUNT(
CASE WHEN (cr.classification::jsonb->'#{emotion}')::float > :threshold THEN 1 ELSE NULL END
) AS #{emotion}_count
SQL
grouped_emotions =
DB.query(
<<~SQL,
SELECT
DATE_TRUNC('day', p.created_at)::DATE AS posted_at,
#{emotion_count_clause.call("sadness")},
#{emotion_count_clause.call("surprise")},
#{emotion_count_clause.call("fear")},
#{emotion_count_clause.call("anger")},
#{emotion_count_clause.call("joy")},
#{emotion_count_clause.call("disgust")}
FROM
classification_results AS cr
INNER JOIN posts p ON p.id = cr.target_id AND cr.target_type = 'Post'
INNER JOIN users u ON p.user_id = u.id
INNER JOIN topics t ON t.id = p.topic_id
INNER JOIN categories c ON c.id = t.category_id
WHERE
t.archetype = 'regular' AND
p.user_id > 0 AND
cr.model_used = 'j-hartmann/emotion-english-distilroberta-base' AND
(p.created_at > :report_start AND p.created_at < :report_end)
GROUP BY DATE_TRUNC('day', p.created_at)
SQL
report_start: report.start_date,
report_end: report.end_date,
threshold: threshold,
)
return report if grouped_emotions.empty?
emotions = [
{ name: "sadness", color: report.colors[:turquoise] },
{ name: "disgust", color: report.colors[:lime] },
{ name: "fear", color: report.colors[:purple] },
{ name: "anger", color: report.colors[:magenta] },
{ name: "joy", color: report.colors[:yellow] },
{ name: "surprise", color: report.colors[:brown] },
]
report.data =
emotions.map do |emotion|
{
req: "emotion_#{emotion[:name]}",
color: emotion[:color],
label: I18n.t("discourse_ai.sentiment.reports.post_emotion.#{emotion[:name]}"),
data:
grouped_emotions.map do |ge|
{ x: ge.posted_at, y: ge.public_send("#{emotion[:name]}_count") }
end,
}
end
end
EmotionDashboardReport.register!(plugin)
SentimentDashboardReport.register!(plugin)
end
end
end

View File

@ -0,0 +1,61 @@
# frozen_string_literal: true
module DiscourseAi
module Sentiment
class SentimentDashboardReport
def self.register!(plugin)
plugin.add_report("overall_sentiment") do |report|
report.modes = [:stacked_chart]
threshold = 0.6
sentiment_count_sql = Proc.new { |sentiment| <<~SQL }
COUNT(
CASE WHEN (cr.classification::jsonb->'#{sentiment}')::float > :threshold THEN 1 ELSE NULL END
) AS #{sentiment}_count
SQL
grouped_sentiments =
DB.query(
<<~SQL,
SELECT
DATE_TRUNC('day', p.created_at)::DATE AS posted_at,
#{sentiment_count_sql.call("positive")},
-#{sentiment_count_sql.call("negative")}
FROM
classification_results AS cr
INNER JOIN posts p ON p.id = cr.target_id AND cr.target_type = 'Post'
INNER JOIN topics t ON t.id = p.topic_id
INNER JOIN categories c ON c.id = t.category_id
WHERE
t.archetype = 'regular' AND
p.user_id > 0 AND
cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' AND
(p.created_at > :report_start AND p.created_at < :report_end)
GROUP BY DATE_TRUNC('day', p.created_at)
SQL
report_start: report.start_date,
report_end: report.end_date,
threshold: threshold,
)
data_points = %w[positive negative]
return report if grouped_sentiments.empty?
report.data =
data_points.map do |point|
{
req: "sentiment_#{point}",
color: point == "positive" ? report.colors[:lime] : report.colors[:purple],
label: I18n.t("discourse_ai.sentiment.reports.overall_sentiment.#{point}"),
data:
grouped_sentiments.map do |gs|
{ x: gs.posted_at, y: gs.public_send("#{point}_count") }
end,
}
end
end
end
end
end
end

View File

@ -32,8 +32,6 @@ register_asset "stylesheets/modules/embeddings/common/semantic-related-topics.sc
register_asset "stylesheets/modules/embeddings/common/semantic-search.scss"
register_asset "stylesheets/modules/sentiment/common/dashboard.scss"
register_asset "stylesheets/modules/sentiment/desktop/dashboard.scss", :desktop
register_asset "stylesheets/modules/sentiment/mobile/dashboard.scss", :mobile
register_asset "stylesheets/modules/llms/common/ai-llms-editor.scss"

View File

@ -88,27 +88,69 @@ RSpec.describe DiscourseAi::Sentiment::EntryPoint do
describe "post_emotion report" do
let(:emotion_1) do
{
sadness: 0.49,
surprise: 0.23,
neutral: 0.6,
fear: 0.34,
anger: 0.87,
joy: 0.22,
disgust: 0.70,
love: 0.9444406,
admiration: 0.013724019,
surprise: 0.010188869,
excitement: 0.007888741,
curiosity: 0.006301749,
joy: 0.004060776,
confusion: 0.0028238264,
approval: 0.0018160914,
realization: 0.001174849,
neutral: 0.0008561869,
amusement: 0.00075853954,
disapproval: 0.0006987994,
disappointment: 0.0006166883,
anger: 0.0006000542,
annoyance: 0.0005615011,
desire: 0.00046368592,
fear: 0.00045117878,
sadness: 0.00041727215,
gratitude: 0.00041727215,
optimism: 0.00037112957,
disgust: 0.00035552034,
nervousness: 0.00022954118,
embarrassment: 0.0002049572,
caring: 0.00017737568,
remorse: 0.00011407586,
grief: 0.0001006716,
pride: 0.00009681493,
relief: 0.00008919009,
}
end
let(:emotion_2) do
{
sadness: 0.19,
surprise: 0.63,
neutral: 0.45,
fear: 0.44,
anger: 0.27,
joy: 0.62,
disgust: 0.30,
love: 0.8444406,
admiration: 0.113724019,
surprise: 0.010188869,
excitement: 0.007888741,
curiosity: 0.006301749,
joy: 0.004060776,
confusion: 0.0028238264,
approval: 0.0018160914,
realization: 0.001174849,
neutral: 0.0008561869,
amusement: 0.00075853954,
disapproval: 0.0006987994,
disappointment: 0.0006166883,
anger: 0.0006000542,
annoyance: 0.0005615011,
desire: 0.00046368592,
fear: 0.00045117878,
sadness: 0.00041727215,
gratitude: 0.00041727215,
optimism: 0.00037112957,
disgust: 0.00035552034,
nervousness: 0.00022954118,
embarrassment: 0.0002049572,
caring: 0.00017737568,
remorse: 0.00011407586,
grief: 0.0001006716,
pride: 0.00009681493,
relief: 0.00008919009,
}
end
let(:model_used) { "j-hartmann/emotion-english-distilroberta-base" }
let(:model_used) { "SamLowe/roberta-base-go_emotions" }
def emotion_classification(post, classification)
Fabricate(
@ -125,22 +167,19 @@ RSpec.describe DiscourseAi::Sentiment::EntryPoint do
end
it "calculate averages using only public posts" do
threshold = 0.30
threshold = 0.10
emotion_classification(post_1, emotion_1)
emotion_classification(post_2, emotion_2)
emotion_classification(pm, emotion_2)
report = Report.find("post_emotion")
report = Report.find("emotion_love")
data_point = report.data
data_point.each do |point|
emotion = strip_emoji_and_downcase(point[:label])
expected =
(emotion_1[emotion.to_sym] > threshold ? 1 : 0) +
(emotion_2[emotion.to_sym] > threshold ? 1 : 0)
expect(point[:data][0][:y]).to eq(expected)
expected = (emotion_1[:love] > threshold ? 1 : 0) + (emotion_2[:love] > threshold ? 1 : 0)
expect(point[:y]).to eq(expected)
end
end
end