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 {
+
+
+
+ {{i18n "discourse_ai.sentiments.dashboard.title"}}
+
+
+
+
+ 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