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 000000000..6c0941e4c --- /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 000000000..724d2e226 --- /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 6de5b249c..dab637956 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 bdbf3a291..90850b928 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 d086f1368..a80717ea2 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 3dd8d416f..000000000 --- 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 2d4e6cee4..000000000 --- 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 22c5f38fe..1c8da38a1 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 d18a55c8d..4ee92b48b 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -79,13 +79,13 @@ en: ai_embeddings_semantic_related_include_closed_topics: "Include closed topics in semantic search results" ai_embeddings_semantic_search_hyde_model: "Model used to expand keywords to get better results during a semantic search" ai_embeddings_per_post_enabled: Generate embeddings for each post - + ai_summarization_enabled: "Enable the topic summarization module." ai_summarization_model: "Model to use for summarization." 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." @@ -111,14 +111,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 000000000..eb5ed94b3 --- /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 7e9fd1127..562856084 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 000000000..d50762e99 --- /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 106b1dab2..4735dbd49 100644 --- a/lib/sentiment/entry_point.rb +++ b/lib/sentiment/entry_point.rb @@ -15,122 +15,8 @@ def inject_into(plugin) 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 000000000..1f2297df7 --- /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 bb4a320a1..d32780ac6 100644 --- a/plugin.rb +++ b/plugin.rb @@ -32,8 +32,6 @@ 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 3d892d171..8cfc2e137 100644 --- a/spec/lib/modules/sentiment/entry_point_spec.rb +++ b/spec/lib/modules/sentiment/entry_point_spec.rb @@ -88,27 +88,69 @@ def sentiment_classification(post, classification) 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 @@ def strip_emoji_and_downcase(str) 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