diff --git a/assets/javascripts/discourse/components/admin-report-emotion.hbs b/assets/javascripts/discourse/components/admin-report-emotion.hbs new file mode 100644 index 00000000..6c0941e4 --- /dev/null +++ b/assets/javascripts/discourse/components/admin-report-emotion.hbs @@ -0,0 +1,35 @@ +
+ {{#if this.model.icon}} + {{d-icon this.model.icon}} + {{/if}} + {{this.model.title}} +
+ +
{{number this.model.todayCount}}
+ +
+ {{number this.model.yesterdayCount}} + {{d-icon this.model.yesterdayTrendIcon}} +
+ +
+ {{number this.model.lastSevenDaysCount}} + {{d-icon this.model.sevenDaysTrendIcon}} +
+ +
+ {{number this.model.lastThirtyDaysCount}} + + {{#if this.model.canDisplayTrendIcon}} + {{d-icon this.model.thirtyDaysTrendIcon}} + {{/if}} +
\ No newline at end of file diff --git a/assets/javascripts/discourse/components/admin-report-emotion.js b/assets/javascripts/discourse/components/admin-report-emotion.js new file mode 100644 index 00000000..724d2e22 --- /dev/null +++ b/assets/javascripts/discourse/components/admin-report-emotion.js @@ -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`); + } +} diff --git a/assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js b/assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js index 6de5b249..dab63795 100644 --- a/assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js +++ b/assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js @@ -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; } } diff --git a/assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs b/assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs index bdbf3a29..90850b92 100644 --- a/assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs +++ b/assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs @@ -22,12 +22,43 @@ @filters={{this.filters}} @showHeader={{true}} /> - - +
+
+ +
+
+
+
+
+
{{i18n + "admin.dashboard.reports.today" + }}
+
{{i18n + "admin.dashboard.reports.yesterday" + }}
+
{{i18n + "admin.dashboard.reports.last_7_days" + }}
+
{{i18n + "admin.dashboard.reports.last_30_days" + }}
+
+ {{#each this.emotions as |metric|}} + + {{/each}} +
+
+
\ No newline at end of file diff --git a/assets/stylesheets/modules/sentiment/common/dashboard.scss b/assets/stylesheets/modules/sentiment/common/dashboard.scss index d086f136..a80717ea 100644 --- a/assets/stylesheets/modules/sentiment/common/dashboard.scss +++ b/assets/stylesheets/modules/sentiment/common/dashboard.scss @@ -4,5 +4,8 @@ grid-template-columns: repeat(12, 1fr); grid-column-gap: 1em; grid-row-gap: 1em; + .admin-report { + grid-column: span 12; + } } } diff --git a/assets/stylesheets/modules/sentiment/desktop/dashboard.scss b/assets/stylesheets/modules/sentiment/desktop/dashboard.scss deleted file mode 100644 index 3dd8d416..00000000 --- a/assets/stylesheets/modules/sentiment/desktop/dashboard.scss +++ /dev/null @@ -1,8 +0,0 @@ -.dashboard.dashboard-sentiment .charts { - .overall-sentiment { - grid-column: span 6; - } - .post-emotion { - grid-column: span 6; - } -} diff --git a/assets/stylesheets/modules/sentiment/mobile/dashboard.scss b/assets/stylesheets/modules/sentiment/mobile/dashboard.scss deleted file mode 100644 index 2d4e6cee..00000000 --- a/assets/stylesheets/modules/sentiment/mobile/dashboard.scss +++ /dev/null @@ -1,10 +0,0 @@ -.dashboard.dashboard-sentiment { - .charts { - .overall-sentiment { - grid-column: span 12; - } - .post-emotion { - grid-column: span 12; - } - } -} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 483c3fd4..54d02b03 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -11,6 +11,8 @@ en: site_settings: categories: discourse_ai: "Discourse AI" + dashboard: + emotion: "Emotion" js: discourse_automation: scriptables: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index ebe172a3..2377b7bc 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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" diff --git a/lib/sentiment/emotion_dashboard_report.rb b/lib/sentiment/emotion_dashboard_report.rb new file mode 100644 index 00000000..eb5ed94b --- /dev/null +++ b/lib/sentiment/emotion_dashboard_report.rb @@ -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 diff --git a/lib/sentiment/emotion_filter_order.rb b/lib/sentiment/emotion_filter_order.rb index 7e9fd112..56285608 100644 --- a/lib/sentiment/emotion_filter_order.rb +++ b/lib/sentiment/emotion_filter_order.rb @@ -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( diff --git a/lib/sentiment/emotions.rb b/lib/sentiment/emotions.rb new file mode 100644 index 00000000..d50762e9 --- /dev/null +++ b/lib/sentiment/emotions.rb @@ -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 diff --git a/lib/sentiment/entry_point.rb b/lib/sentiment/entry_point.rb index 106b1dab..4735dbd4 100644 --- a/lib/sentiment/entry_point.rb +++ b/lib/sentiment/entry_point.rb @@ -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 diff --git a/lib/sentiment/sentiment_dashboard_report.rb b/lib/sentiment/sentiment_dashboard_report.rb new file mode 100644 index 00000000..1f2297df --- /dev/null +++ b/lib/sentiment/sentiment_dashboard_report.rb @@ -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 diff --git a/plugin.rb b/plugin.rb index 4f58560b..2a0d9c65 100644 --- a/plugin.rb +++ b/plugin.rb @@ -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" diff --git a/spec/lib/modules/sentiment/entry_point_spec.rb b/spec/lib/modules/sentiment/entry_point_spec.rb index 3d892d17..8cfc2e13 100644 --- a/spec/lib/modules/sentiment/entry_point_spec.rb +++ b/spec/lib/modules/sentiment/entry_point_spec.rb @@ -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