From 24f0e1262dff1fcf522c090dc17ad41c7e2c4aad Mon Sep 17 00:00:00 2001 From: Keegan George Date: Thu, 20 Feb 2025 09:14:10 -0800 Subject: [PATCH] FEATURE: New sentiment analysis visualization report (#1109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## :mag: Overview This update adds a new report page at `admin/reports/sentiment_analysis` where admins can see a sentiment analysis report for the forum grouped by either category or tags. ## ➕ More details The report can breakdown either category or tags into positive/negative/neutral sentiments based on the grouping (category/tag). Clicking on the doughnut visualization will bring up a post list of all the posts that were involved in that classification with further sentiment classifications by post. The report can additionally be sorted in alphabetical order or by size, as well as be filtered by either category/tag based on the grouping. ## 👨🏽‍💻 Technical Details The new admin report is registered via the pluginAPi with `api.registerReportModeComponent` to register the custom sentiment doughnut report. However, when each doughnut visualization is clicked, a new endpoint found at: `/discourse-ai/sentiment/posts` is fetched to showcase posts classified by sentiments based on the respective params. ## 📸 Screenshots ![Screenshot 2025-02-14 at 11 11 35](https://github.com/user-attachments/assets/a63b5ab8-4fb2-477d-bd29-92545f44ff09) --- .../sentiment/sentiment_controller.rb | 79 ++++++++ .../ai_sentiment_post_serializer.rb | 26 +++ .../admin-report-sentiment-analysis.gjs | 184 ++++++++++++++++++ .../discourse/components/doughnut-chart.gjs | 67 +++++++ .../javascripts/initializers/admin-reports.js | 12 +- .../modules/sentiment/common/dashboard.scss | 155 +++++++++++++++ config/locales/client.en.yml | 14 ++ config/locales/server.en.yml | 7 + config/routes.rb | 4 + lib/sentiment/constants.rb | 9 + lib/sentiment/entry_point.rb | 4 + lib/sentiment/sentiment_analysis_report.rb | 165 ++++++++++++++++ lib/sentiment/sentiment_dashboard_report.rb | 4 +- spec/reports/sentiment_analysis_spec.rb | 22 +++ .../sentiment/sentiment_controller_spec.rb | 37 ++++ 15 files changed, 787 insertions(+), 2 deletions(-) create mode 100644 app/controllers/discourse_ai/sentiment/sentiment_controller.rb create mode 100644 app/serializers/ai_sentiment_post_serializer.rb create mode 100644 assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs create mode 100644 assets/javascripts/discourse/components/doughnut-chart.gjs create mode 100644 lib/sentiment/constants.rb create mode 100644 lib/sentiment/sentiment_analysis_report.rb create mode 100644 spec/reports/sentiment_analysis_spec.rb create mode 100644 spec/requests/sentiment/sentiment_controller_spec.rb 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