mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-03-06 17:30:20 +00:00
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 
This commit is contained in:
parent
1f9f330ce2
commit
24f0e1262d
@ -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
|
26
app/serializers/ai_sentiment_post_serializer.rb
Normal file
26
app/serializers/ai_sentiment_post_serializer.rb
Normal file
@ -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
|
@ -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
assets/javascripts/discourse/components/doughnut-chart.gjs
Normal file
67
assets/javascripts/discourse/components/doughnut-chart.gjs
Normal file
@ -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>
|
||||||
|
}
|
@ -10,8 +10,18 @@ export default {
|
|||||||
return;
|
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("emotion", AdminReportEmotion);
|
||||||
|
api.registerReportModeComponent(
|
||||||
|
"sentiment_analysis",
|
||||||
|
AdminReportSentimentAnalysis
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -15,6 +15,15 @@ en:
|
|||||||
emotion:
|
emotion:
|
||||||
title: "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'."
|
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:
|
js:
|
||||||
discourse_automation:
|
discourse_automation:
|
||||||
scriptables:
|
scriptables:
|
||||||
@ -641,6 +650,11 @@ en:
|
|||||||
sentiments:
|
sentiments:
|
||||||
dashboard:
|
dashboard:
|
||||||
title: "Sentiment"
|
title: "Sentiment"
|
||||||
|
sentiment_analysis:
|
||||||
|
score_types:
|
||||||
|
positive: "Positive"
|
||||||
|
neutral: "Neutral"
|
||||||
|
negative: "Negative"
|
||||||
|
|
||||||
summarization:
|
summarization:
|
||||||
chat:
|
chat:
|
||||||
|
@ -104,6 +104,9 @@ en:
|
|||||||
flagged_by_nsfw: The AI plugin flagged this after classifying at least one of the attached images as NSFW.
|
flagged_by_nsfw: The AI plugin flagged this after classifying at least one of the attached images as NSFW.
|
||||||
|
|
||||||
reports:
|
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:
|
overall_sentiment:
|
||||||
title: "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"'
|
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 😡"
|
anger: "Anger 😡"
|
||||||
joy: "Joy 😀"
|
joy: "Joy 😀"
|
||||||
disgust: "Disgust 🤢"
|
disgust: "Disgust 🤢"
|
||||||
|
sentiment_analysis:
|
||||||
|
positive: "Positive"
|
||||||
|
negative: "Negative"
|
||||||
|
neutral: "Neutral"
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
configuration:
|
configuration:
|
||||||
|
@ -43,6 +43,10 @@ DiscourseAi::Engine.routes.draw do
|
|||||||
get "/t/:topic_id" => "summary#show", :constraints => { topic_id: /\d+/ }
|
get "/t/:topic_id" => "summary#show", :constraints => { topic_id: /\d+/ }
|
||||||
get "/channels/:channel_id" => "chat_summary#show"
|
get "/channels/:channel_id" => "chat_summary#show"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
scope module: :sentiment, path: "/sentiment", defaults: { format: :json } do
|
||||||
|
get "/posts" => "sentiment#posts", :constraints => StaffConstraint.new
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Discourse::Application.routes.draw do
|
Discourse::Application.routes.draw do
|
||||||
|
9
lib/sentiment/constants.rb
Normal file
9
lib/sentiment/constants.rb
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module DiscourseAi
|
||||||
|
module Sentiment
|
||||||
|
module Constants
|
||||||
|
SENTIMENT_THRESHOLD = 0.6
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -14,9 +14,13 @@ module DiscourseAi
|
|||||||
plugin.on(:post_created, &sentiment_analysis_cb)
|
plugin.on(:post_created, &sentiment_analysis_cb)
|
||||||
plugin.on(:post_edited, &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)
|
EmotionFilterOrder.register!(plugin)
|
||||||
EmotionDashboardReport.register!(plugin)
|
EmotionDashboardReport.register!(plugin)
|
||||||
SentimentDashboardReport.register!(plugin)
|
SentimentDashboardReport.register!(plugin)
|
||||||
|
SentimentAnalysisReport.register!(plugin)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
165
lib/sentiment/sentiment_analysis_report.rb
Normal file
165
lib/sentiment/sentiment_analysis_report.rb
Normal file
@ -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
|
@ -3,10 +3,12 @@
|
|||||||
module DiscourseAi
|
module DiscourseAi
|
||||||
module Sentiment
|
module Sentiment
|
||||||
class SentimentDashboardReport
|
class SentimentDashboardReport
|
||||||
|
include Constants
|
||||||
|
|
||||||
def self.register!(plugin)
|
def self.register!(plugin)
|
||||||
plugin.add_report("overall_sentiment") do |report|
|
plugin.add_report("overall_sentiment") do |report|
|
||||||
report.modes = [:stacked_chart]
|
report.modes = [:stacked_chart]
|
||||||
threshold = 0.6
|
threshold = SENTIMENT_THRESHOLD
|
||||||
|
|
||||||
sentiment_count_sql = Proc.new { |sentiment| <<~SQL }
|
sentiment_count_sql = Proc.new { |sentiment| <<~SQL }
|
||||||
COUNT(
|
COUNT(
|
||||||
|
22
spec/reports/sentiment_analysis_spec.rb
Normal file
22
spec/reports/sentiment_analysis_spec.rb
Normal file
@ -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
|
37
spec/requests/sentiment/sentiment_controller_spec.rb
Normal file
37
spec/requests/sentiment/sentiment_controller_spec.rb
Normal file
@ -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
|
Loading…
x
Reference in New Issue
Block a user