Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 24f0e12

Browse files
authored
FEATURE: New sentiment analysis visualization report (#1109)
## 🔍 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)
1 parent 1f9f330 commit 24f0e12

File tree

15 files changed

+787
-2
lines changed

15 files changed

+787
-2
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module Sentiment
5+
class SentimentController < ::Admin::StaffController
6+
include Constants
7+
requires_plugin ::DiscourseAi::PLUGIN_NAME
8+
9+
def posts
10+
group_by = params.required(:group_by)&.to_sym
11+
group_value = params.required(:group_value).presence
12+
start_date = params[:start_date].presence
13+
end_date = params[:end_date]
14+
threshold = SENTIMENT_THRESHOLD
15+
16+
raise Discourse::InvalidParameters if %i[category tag].exclude?(group_by)
17+
18+
case group_by
19+
when :category
20+
grouping_clause = "c.name"
21+
grouping_join = "INNER JOIN categories c ON c.id = t.category_id"
22+
when :tag
23+
grouping_clause = "tags.name"
24+
grouping_join =
25+
"INNER JOIN topic_tags tt ON tt.topic_id = p.topic_id INNER JOIN tags ON tags.id = tt.tag_id"
26+
end
27+
28+
posts =
29+
DB.query(
30+
<<~SQL,
31+
SELECT
32+
p.id AS post_id,
33+
p.topic_id,
34+
t.title AS topic_title,
35+
p.cooked as post_cooked,
36+
p.user_id,
37+
p.post_number,
38+
u.username,
39+
u.name,
40+
u.uploaded_avatar_id,
41+
(CASE
42+
WHEN (cr.classification::jsonb->'positive')::float > :threshold THEN 'positive'
43+
WHEN (cr.classification::jsonb->'negative')::float > :threshold THEN 'negative'
44+
ELSE 'neutral'
45+
END) AS sentiment
46+
FROM posts p
47+
INNER JOIN topics t ON t.id = p.topic_id
48+
INNER JOIN classification_results cr ON cr.target_id = p.id AND cr.target_type = 'Post'
49+
LEFT JOIN users u ON u.id = p.user_id
50+
#{grouping_join}
51+
WHERE
52+
#{grouping_clause} = :group_value AND
53+
t.archetype = 'regular' AND
54+
p.user_id > 0 AND
55+
cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' AND
56+
((:start_date IS NULL OR p.created_at > :start_date) AND (:end_date IS NULL OR p.created_at < :end_date))
57+
AND p.deleted_at IS NULL
58+
ORDER BY p.created_at DESC
59+
SQL
60+
group_value: group_value,
61+
start_date: start_date,
62+
end_date: end_date,
63+
threshold: threshold,
64+
)
65+
66+
render_json_dump(
67+
serialize_data(
68+
posts,
69+
AiSentimentPostSerializer,
70+
scope: guardian,
71+
add_raw: true,
72+
add_excerpt: true,
73+
add_title: true,
74+
),
75+
)
76+
end
77+
end
78+
end
79+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
class AiSentimentPostSerializer < ApplicationSerializer
4+
attributes :post_id,
5+
:topic_id,
6+
:topic_title,
7+
:post_number,
8+
:username,
9+
:name,
10+
:avatar_template,
11+
:excerpt,
12+
:sentiment,
13+
:truncated
14+
15+
def avatar_template
16+
User.avatar_template(object.username, object.uploaded_avatar_id)
17+
end
18+
19+
def excerpt
20+
Post.excerpt(object.post_cooked)
21+
end
22+
23+
def truncated
24+
true
25+
end
26+
end
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import Component from "@glimmer/component";
2+
import { tracked } from "@glimmer/tracking";
3+
import { fn, hash } from "@ember/helper";
4+
import { on } from "@ember/modifier";
5+
import { action, get } from "@ember/object";
6+
import PostList from "discourse/components/post-list";
7+
import dIcon from "discourse/helpers/d-icon";
8+
import { ajax } from "discourse/lib/ajax";
9+
import { popupAjaxError } from "discourse/lib/ajax-error";
10+
import Post from "discourse/models/post";
11+
import closeOnClickOutside from "discourse/modifiers/close-on-click-outside";
12+
import { i18n } from "discourse-i18n";
13+
import DoughnutChart from "discourse/plugins/discourse-ai/discourse/components/doughnut-chart";
14+
15+
export default class AdminReportSentimentAnalysis extends Component {
16+
@tracked selectedChart = null;
17+
@tracked posts = null;
18+
19+
get colors() {
20+
return ["#2ecc71", "#95a5a6", "#e74c3c"];
21+
}
22+
23+
calculateNeutralScore(data) {
24+
return data.total_count - (data.positive_count + data.negative_count);
25+
}
26+
27+
get currentGroupFilter() {
28+
return this.args.model.available_filters.find(
29+
(filter) => filter.id === "group_by"
30+
).default;
31+
}
32+
33+
get currentSortFilter() {
34+
return this.args.model.available_filters.find(
35+
(filter) => filter.id === "sort_by"
36+
).default;
37+
}
38+
39+
get transformedData() {
40+
return this.args.model.data.map((data) => {
41+
return {
42+
title: data.category_name || data.tag_name,
43+
scores: [
44+
data.positive_count,
45+
this.calculateNeutralScore(data),
46+
data.negative_count,
47+
],
48+
total_score: data.total_count,
49+
};
50+
});
51+
}
52+
53+
@action
54+
async showDetails(data) {
55+
this.selectedChart = data;
56+
try {
57+
const posts = await ajax(`/discourse-ai/sentiment/posts`, {
58+
data: {
59+
group_by: this.currentGroupFilter,
60+
group_value: data.title,
61+
start_date: this.args.model.start_date,
62+
end_date: this.args.model.end_date,
63+
},
64+
});
65+
66+
this.posts = posts.map((post) => Post.create(post));
67+
} catch (e) {
68+
popupAjaxError(e);
69+
}
70+
}
71+
72+
sentimentMapping(sentiment) {
73+
switch (sentiment) {
74+
case "positive":
75+
return {
76+
id: "positive",
77+
text: i18n(
78+
"discourse_ai.sentiments.sentiment_analysis.score_types.positive"
79+
),
80+
icon: "face-smile",
81+
};
82+
case "neutral":
83+
return {
84+
id: "neutral",
85+
text: i18n(
86+
"discourse_ai.sentiments.sentiment_analysis.score_types.neutral"
87+
),
88+
icon: "face-meh",
89+
};
90+
case "negative":
91+
return {
92+
id: "negative",
93+
text: i18n(
94+
"discourse_ai.sentiments.sentiment_analysis.score_types.negative"
95+
),
96+
icon: "face-angry",
97+
};
98+
}
99+
}
100+
101+
doughnutTitle(data) {
102+
if (data?.total_score) {
103+
return `${data.title} (${data.total_score})`;
104+
} else {
105+
return data.title;
106+
}
107+
}
108+
109+
<template>
110+
<div class="admin-report-sentiment-analysis">
111+
{{#each this.transformedData as |data|}}
112+
<div
113+
class="admin-report-sentiment-analysis__chart-wrapper"
114+
role="button"
115+
{{on "click" (fn this.showDetails data)}}
116+
{{closeOnClickOutside
117+
(fn (mut this.selectedChart) null)
118+
(hash
119+
targetSelector=".admin-report-sentiment-analysis-details"
120+
secondaryTargetSelector=".admin-report-sentiment-analysis"
121+
)
122+
}}
123+
>
124+
<DoughnutChart
125+
@labels={{@model.labels}}
126+
@colors={{this.colors}}
127+
@data={{data.scores}}
128+
@doughnutTitle={{this.doughnutTitle data}}
129+
/>
130+
</div>
131+
{{/each}}
132+
</div>
133+
134+
{{#if this.selectedChart}}
135+
<div class="admin-report-sentiment-analysis-details">
136+
<h3 class="admin-report-sentiment-analysis-details__title">
137+
{{this.selectedChart.title}}
138+
</h3>
139+
140+
<ul class="admin-report-sentiment-analysis-details__scores">
141+
<li>
142+
{{dIcon "face-smile" style="color: #2ecc71"}}
143+
{{i18n
144+
"discourse_ai.sentiments.sentiment_analysis.score_types.positive"
145+
}}:
146+
{{get this.selectedChart.scores 0}}</li>
147+
<li>
148+
{{dIcon "face-meh"}}
149+
{{i18n
150+
"discourse_ai.sentiments.sentiment_analysis.score_types.neutral"
151+
}}:
152+
{{get this.selectedChart.scores 1}}</li>
153+
<li>
154+
{{dIcon "face-angry"}}
155+
{{i18n
156+
"discourse_ai.sentiments.sentiment_analysis.score_types.negative"
157+
}}:
158+
{{get this.selectedChart.scores 2}}</li>
159+
</ul>
160+
161+
<PostList
162+
@posts={{this.posts}}
163+
@urlPath="url"
164+
@idPath="post_id"
165+
@titlePath="topic_title"
166+
@usernamePath="username"
167+
class="admin-report-sentiment-analysis-details__post-list"
168+
>
169+
<:abovePostItemExcerpt as |post|>
170+
{{#let (this.sentimentMapping post.sentiment) as |sentiment|}}
171+
<span
172+
class="admin-report-sentiment-analysis-details__post-score"
173+
data-sentiment-score={{sentiment.id}}
174+
>
175+
{{dIcon sentiment.icon}}
176+
{{sentiment.text}}
177+
</span>
178+
{{/let}}
179+
</:abovePostItemExcerpt>
180+
</PostList>
181+
</div>
182+
{{/if}}
183+
</template>
184+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Component from "@glimmer/component";
2+
import Chart from "admin/components/chart";
3+
4+
export default class DoughnutChart extends Component {
5+
get config() {
6+
const doughnutTitle = this.args.doughnutTitle || "";
7+
8+
return {
9+
type: "doughnut",
10+
data: {
11+
labels: this.args.labels,
12+
datasets: [
13+
{
14+
data: this.args.data,
15+
backgroundColor: this.args.colors,
16+
},
17+
],
18+
},
19+
options: {
20+
responsive: true,
21+
plugins: {
22+
legend: {
23+
position: this.args.legendPosition || "bottom",
24+
},
25+
},
26+
},
27+
plugins: [
28+
{
29+
id: "centerText",
30+
afterDraw: function (chart) {
31+
const cssVarColor =
32+
getComputedStyle(document.documentElement).getPropertyValue(
33+
"--primary"
34+
) || "#000";
35+
const cssFontSize =
36+
getComputedStyle(document.documentElement).getPropertyValue(
37+
"--font-down-2"
38+
) || "1.3em";
39+
const cssFontFamily =
40+
getComputedStyle(document.documentElement).getPropertyValue(
41+
"--font-family"
42+
) || "sans-serif";
43+
44+
const { ctx, chartArea } = chart;
45+
const centerX = (chartArea.left + chartArea.right) / 2;
46+
const centerY = (chartArea.top + chartArea.bottom) / 2;
47+
48+
ctx.restore();
49+
ctx.textAlign = "center";
50+
ctx.textBaseline = "middle";
51+
ctx.fillStyle = cssVarColor.trim();
52+
ctx.font = `${cssFontSize.trim()} ${cssFontFamily.trim()}`;
53+
54+
ctx.fillText(doughnutTitle, centerX, centerY);
55+
ctx.save();
56+
},
57+
},
58+
],
59+
};
60+
}
61+
62+
<template>
63+
{{#if this.config}}
64+
<Chart @chartConfig={{this.config}} class="admin-report-doughnut" />
65+
{{/if}}
66+
</template>
67+
}

assets/javascripts/initializers/admin-reports.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,18 @@ export default {
1010
return;
1111
}
1212

13-
withPluginApi("2.0.1", (api) => {
13+
// We need to import dynamically with CommonJS require because
14+
// using ESM import in an initializer would cause the component to be imported globally
15+
// and cause errors for non-admin users since the component is only available to admins
16+
const AdminReportSentimentAnalysis =
17+
require("discourse/plugins/discourse-ai/discourse/components/admin-report-sentiment-analysis").default;
18+
19+
withPluginApi((api) => {
1420
api.registerReportModeComponent("emotion", AdminReportEmotion);
21+
api.registerReportModeComponent(
22+
"sentiment_analysis",
23+
AdminReportSentimentAnalysis
24+
);
1525
});
1626
},
1727
};

0 commit comments

Comments
 (0)