From b172ef11c40b8365705f1729244a3d4991b48dfa Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Wed, 8 Nov 2023 10:50:37 -0300 Subject: [PATCH] FEATURE: Expose sentiment classifications via the admin dashboard. (#284) This PR adds new reports for displaying information about post sentiments grouped by date and emotions group by TL. Depends on discourse/discourse#24274 --- .../admin/dashboard_controller.rb | 12 +++ .../discourse/admin-discourse-ai-route-map.js | 10 ++ .../admin-sentiment-dashbboard.gjs | 19 ++++ .../controllers/admin-dashboard-sentiment.js | 38 ++++++++ .../templates/admin-dashboard-sentiment.hbs | 39 ++++++++ .../modules/sentiment/common/dashboard.scss | 16 ++++ .../modules/sentiment/desktop/dashboard.scss | 8 ++ .../modules/sentiment/mobile/dashboard.scss | 10 ++ config/locales/client.en.yml | 3 + config/locales/server.en.yml | 28 ++++++ config/routes.rb | 7 +- lib/modules/sentiment/entry_point.rb | 91 +++++++++++++++++++ plugin.rb | 4 + .../classification_result_fabricator.rb | 13 +++ .../lib/modules/sentiment/entry_point_spec.rb | 76 +++++++++++++++- 15 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 app/controllers/discourse_ai/admin/dashboard_controller.rb create mode 100644 assets/javascripts/discourse/admin-discourse-ai-route-map.js create mode 100644 assets/javascripts/discourse/connectors/admin-dashboard-tabs-after/admin-sentiment-dashbboard.gjs create mode 100644 assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js create mode 100644 assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs create mode 100644 assets/stylesheets/modules/sentiment/common/dashboard.scss create mode 100644 assets/stylesheets/modules/sentiment/desktop/dashboard.scss create mode 100644 assets/stylesheets/modules/sentiment/mobile/dashboard.scss create mode 100644 spec/fabricators/classification_result_fabricator.rb diff --git a/app/controllers/discourse_ai/admin/dashboard_controller.rb b/app/controllers/discourse_ai/admin/dashboard_controller.rb new file mode 100644 index 00000000..9fed8f08 --- /dev/null +++ b/app/controllers/discourse_ai/admin/dashboard_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module DiscourseAi + module Admin + class DashboardController < ::Admin::StaffController + requires_plugin DiscourseAi::PLUGIN_NAME + + def sentiment + end + end + end +end diff --git a/assets/javascripts/discourse/admin-discourse-ai-route-map.js b/assets/javascripts/discourse/admin-discourse-ai-route-map.js new file mode 100644 index 00000000..9643cfbc --- /dev/null +++ b/assets/javascripts/discourse/admin-discourse-ai-route-map.js @@ -0,0 +1,10 @@ +export default { + resource: "admin.dashboard", + path: "/dashboard", + map() { + this.route("admin.dashboardSentiment", { + path: "/dashboard/sentiment", + resetNamespace: true, + }); + }, +}; diff --git a/assets/javascripts/discourse/connectors/admin-dashboard-tabs-after/admin-sentiment-dashbboard.gjs b/assets/javascripts/discourse/connectors/admin-dashboard-tabs-after/admin-sentiment-dashbboard.gjs new file mode 100644 index 00000000..1e20043b --- /dev/null +++ b/assets/javascripts/discourse/connectors/admin-dashboard-tabs-after/admin-sentiment-dashbboard.gjs @@ -0,0 +1,19 @@ +import Component from "@glimmer/component"; +import { LinkTo } from "@ember/routing"; +import I18n from "discourse-i18n"; + +const i18n = I18n.t.bind(I18n); + +export default class AISentimentDashboard extends Component { + + + static shouldRender(_outletArgs, helper) { + return helper.siteSettings.ai_sentiment_enabled; + } +} diff --git a/assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js b/assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js new file mode 100644 index 00000000..75d12e54 --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-dashboard-sentiment.js @@ -0,0 +1,38 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import getURL from "discourse-common/lib/get-url"; +import discourseComputed from "discourse-common/utils/decorators"; +import CustomDateRangeModal from "admin/components/modal/custom-date-range"; +import PeriodComputationMixin from "admin/mixins/period-computation"; + +export default class AdminDashboardSentiment extends Controller.extend( + PeriodComputationMixin +) { + @service modal; + + @discourseComputed("startDate", "endDate") + filters(startDate, endDate) { + return { startDate, endDate }; + } + + _reportsForPeriodURL(period) { + return getURL(`/admin/dashboard/sentiment?period=${period}`); + } + + @action + setCustomDateRange(startDate, endDate) { + this.setProperties({ startDate, endDate }); + } + + @action + openCustomDateRangeModal() { + this.modal.show(CustomDateRangeModal, { + model: { + startDate: this.startDate, + endDate: this.endDate, + setCustomDateRange: this.setCustomDateRange, + }, + }); + } +} diff --git a/assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs b/assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs new file mode 100644 index 00000000..7cfb8495 --- /dev/null +++ b/assets/javascripts/discourse/templates/admin-dashboard-sentiment.hbs @@ -0,0 +1,39 @@ +
+
+
+

+ {{i18n "discourse_ai.sentiments.dashboard.title"}} +

+ + + + +
+
+ +
+
+ + + +
+
+
\ No newline at end of file diff --git a/assets/stylesheets/modules/sentiment/common/dashboard.scss b/assets/stylesheets/modules/sentiment/common/dashboard.scss new file mode 100644 index 00000000..56ba6e54 --- /dev/null +++ b/assets/stylesheets/modules/sentiment/common/dashboard.scss @@ -0,0 +1,16 @@ +.dashboard.dashboard-sentiment { + .sentiment { + margin-bottom: 1em; + } + + .navigation-item.sentiment { + border-bottom: 0.4em solid var(--tertiary); + } + + .charts { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-column-gap: 1em; + grid-row-gap: 1em; + } +} diff --git a/assets/stylesheets/modules/sentiment/desktop/dashboard.scss b/assets/stylesheets/modules/sentiment/desktop/dashboard.scss new file mode 100644 index 00000000..278d14d1 --- /dev/null +++ b/assets/stylesheets/modules/sentiment/desktop/dashboard.scss @@ -0,0 +1,8 @@ +.dashboard.dashboard-sentiment .charts { + .overall-sentiment { + grid-column: span 8; + } + .post-emotion { + grid-column: span 4; + } +} diff --git a/assets/stylesheets/modules/sentiment/mobile/dashboard.scss b/assets/stylesheets/modules/sentiment/mobile/dashboard.scss new file mode 100644 index 00000000..2d4e6cee --- /dev/null +++ b/assets/stylesheets/modules/sentiment/mobile/dashboard.scss @@ -0,0 +1,10 @@ +.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 2ab2f7ba..14af7117 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -92,6 +92,9 @@ en: gpt-3: 5-turbo: "GPT-3.5" claude-2: "Claude 2" + sentiments: + dashboard: + title: "Sentiment" review: types: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 1dcf5dc1..a039e468 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -99,6 +99,18 @@ en: prompt_message_length: The message %{idx} is over the 1000 character limit. invalid_prompt_role: The message %{idx} has an invalid role. + reports: + overall_sentiment: + title: "Overall sentiment" + description: "The average percentage of positive and negative sentiments in public posts." + xaxis: "Positive(%)" + yaxis: "Date" + post_emotion: + title: "Post emotion" + description: "The average percentage of emotions present in public posts grouped by the poster's trust level." + xaxis: + yaxis: + discourse_ai: ai_helper: errors: @@ -172,3 +184,19 @@ en: configuration_hint: one: "Configure the `%{setting}` setting first." other: "Configure these settings first: %{settings}" + + sentiment: + reports: + overall_sentiment: + positive: "Positive" + negative: "Negative" + post_emotion: + tl_01: "Trust levels 0-1" + tl_234: "Trust levels 2+" + sadness: "Sadness" + surprise: "Surprise" + neutral: "Neutral" + fear: "Fear" + anger: "Anger" + joy: "Joy" + disgust: "Disgust" diff --git a/config/routes.rb b/config/routes.rb index 04f7a50e..dd4f4c15 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,4 +21,9 @@ DiscourseAi::Engine.routes.draw do end end -Discourse::Application.routes.draw { mount ::DiscourseAi::Engine, at: "discourse-ai" } +Discourse::Application.routes.draw do + mount ::DiscourseAi::Engine, at: "discourse-ai" + + get "admin/dashboard/sentiment" => "discourse_ai/admin/dashboard#sentiment", + :constraints => StaffConstraint.new +end diff --git a/lib/modules/sentiment/entry_point.rb b/lib/modules/sentiment/entry_point.rb index dfc13670..f097e340 100644 --- a/lib/modules/sentiment/entry_point.rb +++ b/lib/modules/sentiment/entry_point.rb @@ -18,6 +18,97 @@ module DiscourseAi plugin.on(:post_created, &sentiment_analysis_cb) plugin.on(:post_edited, &sentiment_analysis_cb) + + plugin.add_report("overall_sentiment") do |report| + report.modes = [:stacked_chart] + + grouped_sentiments = + DB.query(<<~SQL, report_start: report.start_date, report_end: report.end_date) + SELECT + DATE_TRUNC('day', p.created_at)::DATE AS posted_at, + AVG((cr.classification::jsonb->'positive')::integer) AS avg_positive, + -AVG((cr.classification::jsonb->'negative')::integer) AS avg_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.classification_type = 'sentiment' AND + (p.created_at > :report_start AND p.created_at < :report_end) + GROUP BY DATE_TRUNC('day', p.created_at) + SQL + + data_points = %w[positive negative] + + report.data = + data_points.map do |point| + { + req: "sentiment_#{point}", + color: point == "positive" ? report.colors[1] : report.colors[3], + label: I18n.t("discourse_ai.sentiment.reports.overall_sentiment.#{point}"), + data: + grouped_sentiments.map do |gs| + { x: gs.posted_at, y: gs.public_send("avg_#{point}") } + end, + } + end + end + + plugin.add_report("post_emotion") do |report| + report.modes = [:radar] + + grouped_emotions = + DB.query(<<~SQL, report_start: report.start_date, report_end: report.end_date) + SELECT + u.trust_level AS trust_level, + AVG((cr.classification::jsonb->'sadness')::integer) AS avg_sadness, + AVG((cr.classification::jsonb->'surprise')::integer) AS avg_surprise, + AVG((cr.classification::jsonb->'neutral')::integer) AS avg_neutral, + AVG((cr.classification::jsonb->'fear')::integer) AS avg_fear, + AVG((cr.classification::jsonb->'anger')::integer) AS avg_anger, + AVG((cr.classification::jsonb->'joy')::integer) AS avg_joy, + AVG((cr.classification::jsonb->'disgust')::integer) AS avg_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.classification_type = 'emotion' AND + (p.created_at > :report_start AND p.created_at < :report_end) + GROUP BY u.trust_level + SQL + + emotions = %w[sadness surprise neutral fear anger joy disgust] + level_groups = [[0, 1], [2, 3, 4]] + + report.data = + level_groups.each_with_index.map do |lg, idx| + tl_emotion_avgs = grouped_emotions.select { |ge| lg.include?(ge.trust_level) } + + { + req: "emotion_tl_#{lg.join}", + color: report.colors[idx], + label: I18n.t("discourse_ai.sentiment.reports.post_emotion.tl_#{lg.join}"), + data: + emotions.map do |e| + { + x: I18n.t("discourse_ai.sentiment.reports.post_emotion.#{e}"), + y: + tl_emotion_avgs.sum do |tl_emotion_avg| + tl_emotion_avg.public_send("avg_#{e}").to_i + end / tl_emotion_avgs.size, + } + end, + } + end + end end end end diff --git a/plugin.rb b/plugin.rb index f737f7d1..7e78f3c3 100644 --- a/plugin.rb +++ b/plugin.rb @@ -21,6 +21,10 @@ register_asset "stylesheets/modules/ai-bot/common/bot-replies.scss" register_asset "stylesheets/modules/embeddings/common/semantic-related-topics.scss" 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 + module ::DiscourseAi PLUGIN_NAME = "discourse-ai" end diff --git a/spec/fabricators/classification_result_fabricator.rb b/spec/fabricators/classification_result_fabricator.rb new file mode 100644 index 00000000..4c6b619a --- /dev/null +++ b/spec/fabricators/classification_result_fabricator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +Fabricator(:classification_result) { target { Fabricate(:post) } } + +Fabricator(:sentiment_classification, from: :classification_result) do + classification_type "sentiment" + classification { { negative: 72, neutral: 23, positive: 4 } } +end + +Fabricator(:emotion_classification, from: :classification_result) do + classification_type "emotion" + classification { { negative: 72, neutral: 23, positive: 4 } } +end diff --git a/spec/lib/modules/sentiment/entry_point_spec.rb b/spec/lib/modules/sentiment/entry_point_spec.rb index 3ba92471..2b5354ca 100644 --- a/spec/lib/modules/sentiment/entry_point_spec.rb +++ b/spec/lib/modules/sentiment/entry_point_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require "rails_helper" +require_relative "../../../support/sentiment_inference_stubs" -describe DiscourseAi::Sentiment::EntryPoint do +RSpec.describe DiscourseAi::Sentiment::EntryPoint do fab!(:user) { Fabricate(:user) } describe "registering event callbacks" do @@ -51,4 +51,76 @@ describe DiscourseAi::Sentiment::EntryPoint do end end end + + describe "custom reports" do + before { SiteSetting.ai_sentiment_inference_service_api_endpoint = "http://test.com" } + + fab!(:pm) { Fabricate(:private_message_post) } + + fab!(:post_1) { Fabricate(:post) } + fab!(:post_2) { Fabricate(:post) } + + describe "overall_sentiment report" do + let(:positive_classification) { { negative: 2, neutral: 30, positive: 70 } } + let(:negative_classification) { { negative: 60, neutral: 2, positive: 10 } } + + def sentiment_classification(post, classification) + Fabricate(:sentiment_classification, target: post, classification: classification) + end + + it "calculate averages using only public posts" do + sentiment_classification(post_1, positive_classification) + sentiment_classification(post_2, negative_classification) + sentiment_classification(pm, positive_classification) + + expected_positive = + (positive_classification[:positive] + negative_classification[:positive]) / 2 + expected_negative = + -(positive_classification[:negative] + negative_classification[:negative]) / 2 + + report = Report.find("overall_sentiment") + positive_data_point = report.data[0][:data].first[:y].to_i + negative_data_point = report.data[1][:data].first[:y].to_i + + expect(positive_data_point).to eq(expected_positive) + expect(negative_data_point).to eq(expected_negative) + end + end + + describe "post_emotion report" do + let(:emotion_1) do + { sadness: 49, surprise: 23, neutral: 6, fear: 34, anger: 87, joy: 22, disgust: 70 } + end + let(:emotion_2) do + { sadness: 19, surprise: 63, neutral: 45, fear: 44, anger: 27, joy: 62, disgust: 30 } + end + let(:classification_type) { "emotion" } + + def emotion_classification(post, classification) + Fabricate( + :sentiment_classification, + target: post, + classification_type: classification_type, + classification: classification, + ) + end + + it "calculate averages using only public posts" do + post_1.user.update!(trust_level: TrustLevel[0]) + post_2.user.update!(trust_level: TrustLevel[3]) + pm.user.update!(trust_level: TrustLevel[0]) + + emotion_classification(post_1, emotion_1) + emotion_classification(post_2, emotion_2) + emotion_classification(pm, emotion_2) + + report = Report.find("post_emotion") + tl_01_point = report.data[0][:data].first + tl_234_point = report.data[1][:data].first + + expect(tl_01_point[:y]).to eq(emotion_1[tl_01_point[:x].downcase.to_sym]) + expect(tl_234_point[:y]).to eq(emotion_2[tl_234_point[:x].downcase.to_sym]) + end + end + end end