Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions app/controllers/discourse_ai/sentiment/sentiment_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true

module DiscourseAi
module Sentiment
class SentimentController < ::Admin::StaffController
include Constants
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login

def posts
group_by = params[:group_by]&.to_sym
group_value = params[:group_value].presence
start_date = params[:start_date].presence
end_date = params[:end_date]
threshold = SENTIMENT_THRESHOLD

if %i[category tag].exclude?(group_by) || group_value.blank?
raise Discourse::InvalidParameters
end

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"
else
raise Discourse::InvalidParameters
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))
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
26 changes: 26 additions & 0 deletions app/serializers/ai_sentiment_post_serializer.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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;
}
}

<template>
<div class="admin-report-sentiment-analysis">
{{#each this.transformedData as |data|}}
<div
class="admin-report-sentiment-analysis__chart-wrapper"
role="button"
{{on "click" (fn this.showDetails data)}}
{{closeOnClickOutside
(fn (mut this.selectedChart) null)
(hash
targetSelector=".admin-report-sentiment-analysis-details"
secondaryTargetSelector=".admin-report-sentiment-analysis"
)
}}
>
<DoughnutChart
@labels={{@model.labels}}
@colors={{this.colors}}
@data={{data.scores}}
@doughnutTitle={{this.doughnutTitle data}}
/>
</div>
{{/each}}
</div>

{{#if this.selectedChart}}
<div class="admin-report-sentiment-analysis-details">
<h3 class="admin-report-sentiment-analysis-details__title">
{{this.selectedChart.title}}
</h3>

<ul class="admin-report-sentiment-analysis-details__scores">
<li>
{{dIcon "face-smile" style="color: #2ecc71"}}
{{i18n
"discourse_ai.sentiments.sentiment_analysis.score_types.positive"
}}:
{{get this.selectedChart.scores 0}}</li>
<li>
{{dIcon "face-meh"}}
{{i18n
"discourse_ai.sentiments.sentiment_analysis.score_types.neutral"
}}:
{{get this.selectedChart.scores 1}}</li>
<li>
{{dIcon "face-angry"}}
{{i18n
"discourse_ai.sentiments.sentiment_analysis.score_types.negative"
}}:
{{get this.selectedChart.scores 2}}</li>
</ul>

<PostList
@posts={{this.posts}}
@urlPath="url"
@idPath="post_id"
@titlePath="topic_title"
@usernamePath="username"
class="admin-report-sentiment-analysis-details__post-list"
>
<:abovePostItemExcerpt as |post|>
{{#let (this.sentimentMapping post.sentiment) as |sentiment|}}
<span
class="admin-report-sentiment-analysis-details__post-score"
data-sentiment-score={{sentiment.id}}
>
{{dIcon sentiment.icon}}
{{sentiment.text}}
</span>
{{/let}}
</:abovePostItemExcerpt>
</PostList>
</div>
{{/if}}
</template>
}
67 changes: 67 additions & 0 deletions assets/javascripts/discourse/components/doughnut-chart.gjs
Original file line number Diff line number Diff line change
@@ -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();
},
},
],
};
}

<template>
{{#if this.config}}
<Chart @chartConfig={{this.config}} class="admin-report-doughnut" />
{{/if}}
</template>
}
12 changes: 11 additions & 1 deletion assets/javascripts/initializers/admin-reports.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
});
},
};
Loading