diff --git a/app/controllers/discourse_ai/sentiment/sentiment_controller.rb b/app/controllers/discourse_ai/sentiment/sentiment_controller.rb new file mode 100644 index 00000000..6fbe3fd2 --- /dev/null +++ b/app/controllers/discourse_ai/sentiment/sentiment_controller.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module DiscourseAi + module Sentiment + class SentimentController < ::Admin::StaffController + include Constants + requires_plugin ::DiscourseAi::PLUGIN_NAME + + def posts + group_by = params.required(:group_by)&.to_sym + group_value = params.required(:group_value).presence + start_date = params[:start_date].presence + end_date = params[:end_date] + threshold = SENTIMENT_THRESHOLD + + raise Discourse::InvalidParameters if %i[category tag].exclude?(group_by) + + case group_by + when :category + grouping_clause = "c.name" + grouping_join = "INNER JOIN categories c ON c.id = t.category_id" + when :tag + grouping_clause = "tags.name" + grouping_join = + "INNER JOIN topic_tags tt ON tt.topic_id = p.topic_id INNER JOIN tags ON tags.id = tt.tag_id" + end + + posts = + DB.query( + <<~SQL, + SELECT + p.id AS post_id, + p.topic_id, + t.title AS topic_title, + p.cooked as post_cooked, + p.user_id, + p.post_number, + u.username, + u.name, + u.uploaded_avatar_id, + (CASE + WHEN (cr.classification::jsonb->'positive')::float > :threshold THEN 'positive' + WHEN (cr.classification::jsonb->'negative')::float > :threshold THEN 'negative' + ELSE 'neutral' + END) AS sentiment + FROM posts p + INNER JOIN topics t ON t.id = p.topic_id + INNER JOIN classification_results cr ON cr.target_id = p.id AND cr.target_type = 'Post' + LEFT JOIN users u ON u.id = p.user_id + #{grouping_join} + WHERE + #{grouping_clause} = :group_value AND + t.archetype = 'regular' AND + p.user_id > 0 AND + cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' AND + ((:start_date IS NULL OR p.created_at > :start_date) AND (:end_date IS NULL OR p.created_at < :end_date)) + AND p.deleted_at IS NULL + ORDER BY p.created_at DESC + SQL + group_value: group_value, + start_date: start_date, + end_date: end_date, + threshold: threshold, + ) + + render_json_dump( + serialize_data( + posts, + AiSentimentPostSerializer, + scope: guardian, + add_raw: true, + add_excerpt: true, + add_title: true, + ), + ) + end + end + end +end diff --git a/app/serializers/ai_sentiment_post_serializer.rb b/app/serializers/ai_sentiment_post_serializer.rb new file mode 100644 index 00000000..264f6df2 --- /dev/null +++ b/app/serializers/ai_sentiment_post_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class AiSentimentPostSerializer < ApplicationSerializer + attributes :post_id, + :topic_id, + :topic_title, + :post_number, + :username, + :name, + :avatar_template, + :excerpt, + :sentiment, + :truncated + + def avatar_template + User.avatar_template(object.username, object.uploaded_avatar_id) + end + + def excerpt + Post.excerpt(object.post_cooked) + end + + def truncated + true + end +end diff --git a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs new file mode 100644 index 00000000..77e29a9b --- /dev/null +++ b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs @@ -0,0 +1,184 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { fn, hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action, get } from "@ember/object"; +import PostList from "discourse/components/post-list"; +import dIcon from "discourse/helpers/d-icon"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import Post from "discourse/models/post"; +import closeOnClickOutside from "discourse/modifiers/close-on-click-outside"; +import { i18n } from "discourse-i18n"; +import DoughnutChart from "discourse/plugins/discourse-ai/discourse/components/doughnut-chart"; + +export default class AdminReportSentimentAnalysis extends Component { + @tracked selectedChart = null; + @tracked posts = null; + + get colors() { + return ["#2ecc71", "#95a5a6", "#e74c3c"]; + } + + calculateNeutralScore(data) { + return data.total_count - (data.positive_count + data.negative_count); + } + + get currentGroupFilter() { + return this.args.model.available_filters.find( + (filter) => filter.id === "group_by" + ).default; + } + + get currentSortFilter() { + return this.args.model.available_filters.find( + (filter) => filter.id === "sort_by" + ).default; + } + + get transformedData() { + return this.args.model.data.map((data) => { + return { + title: data.category_name || data.tag_name, + scores: [ + data.positive_count, + this.calculateNeutralScore(data), + data.negative_count, + ], + total_score: data.total_count, + }; + }); + } + + @action + async showDetails(data) { + this.selectedChart = data; + try { + const posts = await ajax(`/discourse-ai/sentiment/posts`, { + data: { + group_by: this.currentGroupFilter, + group_value: data.title, + start_date: this.args.model.start_date, + end_date: this.args.model.end_date, + }, + }); + + this.posts = posts.map((post) => Post.create(post)); + } catch (e) { + popupAjaxError(e); + } + } + + sentimentMapping(sentiment) { + switch (sentiment) { + case "positive": + return { + id: "positive", + text: i18n( + "discourse_ai.sentiments.sentiment_analysis.score_types.positive" + ), + icon: "face-smile", + }; + case "neutral": + return { + id: "neutral", + text: i18n( + "discourse_ai.sentiments.sentiment_analysis.score_types.neutral" + ), + icon: "face-meh", + }; + case "negative": + return { + id: "negative", + text: i18n( + "discourse_ai.sentiments.sentiment_analysis.score_types.negative" + ), + icon: "face-angry", + }; + } + } + + doughnutTitle(data) { + if (data?.total_score) { + return `${data.title} (${data.total_score})`; + } else { + return data.title; + } + } + + +} diff --git a/assets/javascripts/discourse/components/doughnut-chart.gjs b/assets/javascripts/discourse/components/doughnut-chart.gjs new file mode 100644 index 00000000..a9327d48 --- /dev/null +++ b/assets/javascripts/discourse/components/doughnut-chart.gjs @@ -0,0 +1,67 @@ +import Component from "@glimmer/component"; +import Chart from "admin/components/chart"; + +export default class DoughnutChart extends Component { + get config() { + const doughnutTitle = this.args.doughnutTitle || ""; + + return { + type: "doughnut", + data: { + labels: this.args.labels, + datasets: [ + { + data: this.args.data, + backgroundColor: this.args.colors, + }, + ], + }, + options: { + responsive: true, + plugins: { + legend: { + position: this.args.legendPosition || "bottom", + }, + }, + }, + plugins: [ + { + id: "centerText", + afterDraw: function (chart) { + const cssVarColor = + getComputedStyle(document.documentElement).getPropertyValue( + "--primary" + ) || "#000"; + const cssFontSize = + getComputedStyle(document.documentElement).getPropertyValue( + "--font-down-2" + ) || "1.3em"; + const cssFontFamily = + getComputedStyle(document.documentElement).getPropertyValue( + "--font-family" + ) || "sans-serif"; + + const { ctx, chartArea } = chart; + const centerX = (chartArea.left + chartArea.right) / 2; + const centerY = (chartArea.top + chartArea.bottom) / 2; + + ctx.restore(); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = cssVarColor.trim(); + ctx.font = `${cssFontSize.trim()} ${cssFontFamily.trim()}`; + + ctx.fillText(doughnutTitle, centerX, centerY); + ctx.save(); + }, + }, + ], + }; + } + + +} diff --git a/assets/javascripts/initializers/admin-reports.js b/assets/javascripts/initializers/admin-reports.js index d22711d8..1dd1a45e 100644 --- a/assets/javascripts/initializers/admin-reports.js +++ b/assets/javascripts/initializers/admin-reports.js @@ -10,8 +10,18 @@ export default { return; } - withPluginApi("2.0.1", (api) => { + // We need to import dynamically with CommonJS require because + // using ESM import in an initializer would cause the component to be imported globally + // and cause errors for non-admin users since the component is only available to admins + const AdminReportSentimentAnalysis = + require("discourse/plugins/discourse-ai/discourse/components/admin-report-sentiment-analysis").default; + + withPluginApi((api) => { api.registerReportModeComponent("emotion", AdminReportEmotion); + api.registerReportModeComponent( + "sentiment_analysis", + AdminReportSentimentAnalysis + ); }); }, }; diff --git a/assets/stylesheets/modules/sentiment/common/dashboard.scss b/assets/stylesheets/modules/sentiment/common/dashboard.scss index a80717ea..1b217b86 100644 --- a/assets/stylesheets/modules/sentiment/common/dashboard.scss +++ b/assets/stylesheets/modules/sentiment/common/dashboard.scss @@ -9,3 +9,158 @@ } } } + +@mixin report-container-box() { + border: 1px solid var(--primary-low); + border-radius: var(--d-border-radius); + padding: 1rem; +} + +.admin-report.sentiment-analysis .body { + display: flex; + flex-flow: row wrap; + gap: 1rem; + + .filters { + @include report-container-box(); + order: 1; + width: 100%; + margin-left: 0; + flex-flow: row wrap; + align-items: flex-start; + justify-content: flex-start; + gap: 0.5rem; + + .control { + min-width: 200px; + } + + .control:nth-of-type(-n + 4) { + flex: 1; + } + + .control:nth-of-type(n + 6) { + flex-basis: 49%; + align-self: flex-end; + } + + // Hides tag selector when showing subcategories selector + .control:nth-of-type(6):nth-last-of-type(3) { + display: none; + } + } + + .main { + flex: 100%; + display: flex; + order: 2; + gap: 1rem; + align-items: flex-start; + max-height: 100vh; + } +} + +.admin-report-sentiment-analysis { + @include report-container-box(); + flex: 2; + display: flex; + flex-flow: row wrap; + gap: 3rem; + + .admin-report-doughnut { + max-width: 300px; + max-height: 300px; + padding: 0.25rem; + } + + &__chart-wrapper { + transition: transform 0.25s ease, box-shadow 0.25s ease; + border-radius: var(--d-border-radius); + + &:hover { + transform: translateY(-1rem); + box-shadow: var(--shadow-card); + cursor: pointer; + } + } +} + +:root { + --d-sentiment-report-positive-rgb: 46, 204, 112; + --d-sentiment-report-neutral-rgb: 149, 166, 167; + --d-sentiment-report-negative-rgb: 231, 77, 60; +} + +.admin-report-sentiment-analysis-details { + @include report-container-box(); + flex: 1; + display: flex; + flex-flow: column nowrap; + overflow-y: auto; + height: 100%; + + &__title { + font-size: var(--font-up-2); + } + + &__scores { + display: flex; + flex-flow: column wrap; + align-items: flex-start; + justify-content: flex-start; + gap: 0.25rem; + list-style: none; + margin-left: 0; + background: var(--primary-very-low); + padding: 1rem; + border-radius: var(--d-border-radius); + + .d-icon-face-smile { + color: rgb(var(--d-sentiment-report-positive-rgb)); + } + + .d-icon-face-meh { + color: rgb(var(--d-sentiment-report-neutral-rgb)); + } + + .d-icon-face-angry { + color: rgb(var(--d-sentiment-report-negative-rgb)); + } + } + + &__post-score { + border-radius: var(--d-border-radius); + background: var(--primary-very-low); + margin-top: 0.5rem; + padding: 0.25rem; + font-size: var(--font-down-1); + display: inline-block; + &[data-sentiment-score="positive"] { + color: rgb(var(--d-sentiment-report-positive-rgb)); + background: rgba(var(--d-sentiment-report-positive-rgb), 0.1); + } + + &[data-sentiment-score="neutral"] { + color: rgb(var(--d-sentiment-report-neutral-rgb)); + background: rgba(var(--d-sentiment-report-neutral-rgb), 0.1); + } + + &[data-sentiment-score="negative"] { + color: rgb(var(--d-sentiment-report-negative-rgb)); + background: rgba(var(--d-sentiment-report-negative-rgb), 0.1); + } + } + + &__post-list { + .avatar-wrapper, + .avatar-link { + width: calc(48px * 0.75); + height: calc(48px * 0.75); + } + + img.avatar { + width: 100%; + height: 100%; + } + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 96a2d00f..6a9c91bd 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -15,6 +15,15 @@ en: emotion: title: "Emotion" description: "The table lists a count of posts classified with a determined emotion. Classified with the model 'SamLowe/roberta-base-go_emotions'." + reports: + filters: + group_by: + label: "Group by" + sort_by: + label: "Sort by" + tag: + label: "Tag" + js: discourse_automation: scriptables: @@ -641,6 +650,11 @@ en: sentiments: dashboard: title: "Sentiment" + sentiment_analysis: + score_types: + positive: "Positive" + neutral: "Neutral" + negative: "Negative" summarization: chat: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 5ae4f01f..9f8f9275 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -104,6 +104,9 @@ en: flagged_by_nsfw: The AI plugin flagged this after classifying at least one of the attached images as NSFW. reports: + sentiment_analysis: + title: "Sentiment analysis" + description: "This report provides sentiment analysis for posts, grouped by category, with positive, negative, and neutral scores for each post and category." 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. Personal messages (PMs) are also excluded. Classified with "cardiffnlp/twitter-roberta-base-sentiment-latest"' @@ -431,6 +434,10 @@ en: anger: "Anger 😡" joy: "Joy 😀" disgust: "Disgust 🤢" + sentiment_analysis: + positive: "Positive" + negative: "Negative" + neutral: "Neutral" llm: configuration: diff --git a/config/routes.rb b/config/routes.rb index 44dd0988..6004fe91 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -43,6 +43,10 @@ DiscourseAi::Engine.routes.draw do get "/t/:topic_id" => "summary#show", :constraints => { topic_id: /\d+/ } get "/channels/:channel_id" => "chat_summary#show" end + + scope module: :sentiment, path: "/sentiment", defaults: { format: :json } do + get "/posts" => "sentiment#posts", :constraints => StaffConstraint.new + end end Discourse::Application.routes.draw do diff --git a/lib/sentiment/constants.rb b/lib/sentiment/constants.rb new file mode 100644 index 00000000..42d21db5 --- /dev/null +++ b/lib/sentiment/constants.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module DiscourseAi + module Sentiment + module Constants + SENTIMENT_THRESHOLD = 0.6 + end + end +end diff --git a/lib/sentiment/entry_point.rb b/lib/sentiment/entry_point.rb index 4735dbd4..c886675d 100644 --- a/lib/sentiment/entry_point.rb +++ b/lib/sentiment/entry_point.rb @@ -14,9 +14,13 @@ module DiscourseAi plugin.on(:post_created, &sentiment_analysis_cb) plugin.on(:post_edited, &sentiment_analysis_cb) + additional_icons = %w[face-smile face-meh face-angry] + additional_icons.each { |icon| plugin.register_svg_icon(icon) } + EmotionFilterOrder.register!(plugin) EmotionDashboardReport.register!(plugin) SentimentDashboardReport.register!(plugin) + SentimentAnalysisReport.register!(plugin) end end end diff --git a/lib/sentiment/sentiment_analysis_report.rb b/lib/sentiment/sentiment_analysis_report.rb new file mode 100644 index 00000000..17ad72cb --- /dev/null +++ b/lib/sentiment/sentiment_analysis_report.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +module DiscourseAi + module Sentiment + class SentimentAnalysisReport + include Constants + GROUP_BY_FILTER_DEFAULT = :category + SORT_BY_FILTER_DEFAULT = :size + + def self.register!(plugin) + plugin.add_report("sentiment_analysis") do |report| + report.modes = [:sentiment_analysis] + + group_by_filter = report.filters.dig(:group_by) || GROUP_BY_FILTER_DEFAULT + report.add_filter( + "group_by", + type: "list", + default: group_by_filter, + choices: [{ id: "category", name: "Category" }, { id: "tag", name: "Tag" }], + allow_any: false, + auto_insert_none_item: false, + ) + + size_filter = report.filters.dig(:sort_by) || SORT_BY_FILTER_DEFAULT + report.add_filter( + "sort_by", + type: "list", + default: size_filter, + choices: [{ id: "size", name: "Size" }, { id: "alphabetical", name: "Alphabetical" }], + allow_any: false, + auto_insert_none_item: false, + ) + + report.add_category_filter(disabled: group_by_filter.to_sym == :tag) + + tag_filter = report.filters.dig(:tag) || "any" + tag_choices = + Tag + .all + .map { |tag| { id: tag.name, name: tag.name } } + .unshift({ id: "any", name: "Any" }) + report.add_filter( + "tag", + type: "list", + default: tag_filter, + choices: tag_choices, + allow_any: false, + auto_insert_none_item: false, + disabled: group_by_filter.to_sym == :category, + ) + + sentiment_data = DiscourseAi::Sentiment::SentimentAnalysisReport.fetch_data(report) + + report.data = sentiment_data + report.labels = [ + I18n.t("discourse_ai.sentiment.reports.sentiment_analysis.positive"), + I18n.t("discourse_ai.sentiment.reports.sentiment_analysis.neutral"), + I18n.t("discourse_ai.sentiment.reports.sentiment_analysis.negative"), + ] + end + end + + def self.fetch_data(report) + threshold = SENTIMENT_THRESHOLD + + grouping = (report.filters.dig(:group_by) || GROUP_BY_FILTER_DEFAULT).to_sym + sorting = (report.filters.dig(:sort_by) || SORT_BY_FILTER_DEFAULT).to_sym + category_filter = report.filters.dig(:category) + tag_filter = report.filters.dig(:tag) + + sentiment_count_sql = Proc.new { |sentiment| <<~SQL } + COUNT( + CASE WHEN (cr.classification::jsonb->'#{sentiment}')::float > :threshold THEN 1 ELSE NULL END + ) + SQL + + grouping_clause = + case grouping + when :category + <<~SQL + c.name AS category_name, + SQL + when :tag + <<~SQL + tags.name AS tag_name, + SQL + else + raise Discourse::InvalidParameters + end + + grouping_join = + case grouping + when :category + <<~SQL + INNER JOIN categories c ON c.id = t.category_id + SQL + when :tag + <<~SQL + INNER JOIN topic_tags tt ON tt.topic_id = p.topic_id + INNER JOIN tags ON tags.id = tt.tag_id + SQL + else + raise Discourse::InvalidParameters + end + + order_by_clause = + case sorting + when :size + "ORDER BY total_count DESC" + when :alphabetical + "ORDER BY 1 ASC" + else + raise Discourse::InvalidParameters + end + + where_clause = + case grouping + when :category + if category_filter.nil? + "" + else + "AND c.id = :category_filter" + end + when :tag + if tag_filter.nil? || tag_filter == "any" + "" + else + "AND tags.name = :tag_filter" + end + end + + grouped_sentiments = + DB.query( + <<~SQL, + SELECT + #{grouping_clause} + #{sentiment_count_sql.call("positive")} AS positive_count, + #{sentiment_count_sql.call("negative")} AS negative_count, + COUNT(*) AS total_count + 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 + #{grouping_join} + 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) + #{where_clause} + GROUP BY 1 + #{order_by_clause} + SQL + report_start: report.start_date, + report_end: report.end_date, + threshold: threshold, + category_filter: category_filter, + tag_filter: tag_filter, + ) + + grouped_sentiments + end + end + end +end diff --git a/lib/sentiment/sentiment_dashboard_report.rb b/lib/sentiment/sentiment_dashboard_report.rb index 3101126a..19d04eb0 100644 --- a/lib/sentiment/sentiment_dashboard_report.rb +++ b/lib/sentiment/sentiment_dashboard_report.rb @@ -3,10 +3,12 @@ module DiscourseAi module Sentiment class SentimentDashboardReport + include Constants + def self.register!(plugin) plugin.add_report("overall_sentiment") do |report| report.modes = [:stacked_chart] - threshold = 0.6 + threshold = SENTIMENT_THRESHOLD sentiment_count_sql = Proc.new { |sentiment| <<~SQL } COUNT( diff --git a/spec/reports/sentiment_analysis_spec.rb b/spec/reports/sentiment_analysis_spec.rb new file mode 100644 index 00000000..d251877c --- /dev/null +++ b/spec/reports/sentiment_analysis_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseAi::Sentiment::SentimentAnalysisReport do + fab!(:admin) + fab!(:category) + fab!(:topic) { Fabricate(:topic, category: category) } + fab!(:post) { Fabricate(:post, user: admin, topic: topic) } + fab!(:post_2) { Fabricate(:post, user: admin, topic: topic) } + fab!(:classification_result) { Fabricate(:classification_result, target: post) } + + before { SiteSetting.ai_sentiment_enabled = true } + + it "contains the correct filters" do + report = Report.find("sentiment_analysis") + expect(report.available_filters).to include("group_by", "sort_by", "category", "tag") + end + + it "contains the correct labels" do + report = Report.find("sentiment_analysis") + expect(report.labels).to eq(%w[Positive Neutral Negative]) + end +end diff --git a/spec/requests/sentiment/sentiment_controller_spec.rb b/spec/requests/sentiment/sentiment_controller_spec.rb new file mode 100644 index 00000000..a875e84d --- /dev/null +++ b/spec/requests/sentiment/sentiment_controller_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseAi::Sentiment::SentimentController do + describe "#posts" do + fab!(:admin) + fab!(:category) + fab!(:topic) { Fabricate(:topic, category: category) } + fab!(:post) { Fabricate(:post, user: admin, topic: topic) } + fab!(:post_2) { Fabricate(:post, user: admin, topic: topic) } + fab!(:classification_result) { Fabricate(:classification_result, target: post) } + + before do + SiteSetting.ai_sentiment_enabled = true + sign_in(admin) + end + + it "returns a posts based on params" do + post.reload + classification_result.reload + + get "/discourse-ai/sentiment/posts", + params: { + group_by: "category", + group_value: category.name, + threshold: 0.0, + } + + expect(response).to be_successful + + posts = JSON.parse(response.body) + posts.each do |post| + expect(post).to have_key("sentiment") + expect(post["sentiment"]).to match(/positive|negative|neutral/) + end + end + end +end