DEV: Sentiment analysis report follow-up updates (#1145)

* DEV: make include subcategories checkbox operational

* DEV: add pagination for post requests

* WIP: selected chart UX improvements

* DEV: Functional sentiment filters

* DEV: Reset filters after going back

* DEV: Add category colors, improve UX

* DEV: Update spec
This commit is contained in:
Keegan George 2025-02-23 21:21:10 -08:00 committed by GitHub
parent 43cbb7f45f
commit 08377bab35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 312 additions and 108 deletions

View File

@ -6,6 +6,9 @@ module DiscourseAi
include Constants
requires_plugin ::DiscourseAi::PLUGIN_NAME
DEFAULT_POSTS_LIMIT = 50
MAX_POSTS_LIMIT = 100
def posts
group_by = params.required(:group_by)&.to_sym
group_value = params.required(:group_value).presence
@ -15,10 +18,13 @@ module DiscourseAi
raise Discourse::InvalidParameters if %i[category tag].exclude?(group_by)
limit = fetch_limit_from_params(default: DEFAULT_POSTS_LIMIT, max: MAX_POSTS_LIMIT)
offset = params[:offset].to_i || 0
case group_by
when :category
grouping_clause = "c.name"
grouping_join = "INNER JOIN categories c ON c.id = t.category_id"
grouping_join = "" # categories already joined
when :tag
grouping_clause = "tags.name"
grouping_join =
@ -38,6 +44,11 @@ module DiscourseAi
u.username,
u.name,
u.uploaded_avatar_id,
c.id AS category_id,
c.name AS category_name,
c.color AS category_color,
c.slug AS category_slug,
c.description AS category_description,
(CASE
WHEN (cr.classification::jsonb->'positive')::float > :threshold THEN 'positive'
WHEN (cr.classification::jsonb->'negative')::float > :threshold THEN 'negative'
@ -47,6 +58,7 @@ module DiscourseAi
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
LEFT JOIN categories c ON c.id = t.category_id
#{grouping_join}
WHERE
#{grouping_clause} = :group_value AND
@ -56,22 +68,31 @@ module DiscourseAi
((: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
LIMIT :limit OFFSET :offset
SQL
group_value: group_value,
start_date: start_date,
end_date: end_date,
threshold: threshold,
limit: limit + 1,
offset: offset,
)
has_more = posts.length > limit
posts.pop if has_more
render_json_dump(
serialize_data(
posts,
AiSentimentPostSerializer,
scope: guardian,
add_raw: true,
add_excerpt: true,
add_title: true,
),
posts:
serialize_data(
posts,
AiSentimentPostSerializer,
scope: guardian,
add_raw: true,
add_excerpt: true,
add_title: true,
),
has_more: has_more,
next_offset: has_more ? offset + limit : nil,
)
end
end

View File

@ -10,7 +10,8 @@ class AiSentimentPostSerializer < ApplicationSerializer
:avatar_template,
:excerpt,
:sentiment,
:truncated
:truncated,
:category
def avatar_template
User.avatar_template(object.username, object.uploaded_avatar_id)
@ -23,4 +24,14 @@ class AiSentimentPostSerializer < ApplicationSerializer
def truncated
true
end
def category
{
id: object.category_id,
name: object.category_name,
color: object.category_color,
slug: object.category_slug,
description: object.category_description,
}
end
end

View File

@ -2,7 +2,11 @@ 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 { action } from "@ember/object";
import { modifier } from "ember-modifier";
import { and } from "truth-helpers";
import DButton from "discourse/components/d-button";
import HorizontalOverflowNav from "discourse/components/horizontal-overflow-nav";
import PostList from "discourse/components/post-list";
import dIcon from "discourse/helpers/d-icon";
import { ajax } from "discourse/lib/ajax";
@ -15,15 +19,88 @@ import DoughnutChart from "discourse/plugins/discourse-ai/discourse/components/d
export default class AdminReportSentimentAnalysis extends Component {
@tracked selectedChart = null;
@tracked posts = null;
@tracked hasMorePosts = false;
@tracked nextOffset = 0;
@tracked showingSelectedChart = false;
@tracked activeFilter = "all";
get colors() {
return ["#2ecc71", "#95a5a6", "#e74c3c"];
setActiveFilter = modifier((element) => {
this.clearActiveFilters(element);
element
.querySelector(`li[data-filter-type="${this.activeFilter}"] button`)
.classList.add("active");
});
clearActiveFilters(element) {
const filterButtons = element.querySelectorAll("li button");
for (let button of filterButtons) {
button.classList.remove("active");
}
}
calculateNeutralScore(data) {
return data.total_count - (data.positive_count + data.negative_count);
}
sentimentMapping(sentiment) {
switch (sentiment) {
case "positive":
return {
id: "positive",
text: i18n(
"discourse_ai.sentiments.sentiment_analysis.filter_types.positive"
),
icon: "face-smile",
};
case "neutral":
return {
id: "neutral",
text: i18n(
"discourse_ai.sentiments.sentiment_analysis.filter_types.neutral"
),
icon: "face-meh",
};
case "negative":
return {
id: "negative",
text: i18n(
"discourse_ai.sentiments.sentiment_analysis.filter_types.negative"
),
icon: "face-angry",
};
}
}
doughnutTitle(data) {
const MAX_TITLE_LENGTH = 18;
const title = data?.title || "";
const score = data?.total_score ? ` (${data.total_score})` : "";
if (title.length + score.length > MAX_TITLE_LENGTH) {
return (
title.substring(0, MAX_TITLE_LENGTH - score.length) + "..." + score
);
}
return title + score;
}
async postRequest() {
return await ajax("/discourse-ai/sentiment/posts", {
data: {
group_by: this.currentGroupFilter,
group_value: this.selectedChart?.title,
start_date: this.args.model.start_date,
end_date: this.args.model.end_date,
offset: this.nextOffset,
},
});
}
get colors() {
return ["#2ecc71", "#95a5a6", "#e74c3c"];
}
get currentGroupFilter() {
return this.args.model.available_filters.find(
(filter) => filter.id === "group_by"
@ -50,120 +127,179 @@ export default class AdminReportSentimentAnalysis extends Component {
});
}
get filteredPosts() {
if (!this.posts || !this.posts.length) {
return [];
}
return this.posts.filter((post) => {
if (this.activeFilter === "all") {
return true;
}
return post.sentiment === this.activeFilter;
});
}
get postFilters() {
return [
{
id: "all",
text: `${i18n(
"discourse_ai.sentiments.sentiment_analysis.filter_types.all"
)} (${this.selectedChart.total_score})`,
icon: "bars-staggered",
action: () => {
this.activeFilter = "all";
},
},
{
id: "positive",
text: `${i18n(
"discourse_ai.sentiments.sentiment_analysis.filter_types.positive"
)} (${this.selectedChart.scores[0]})`,
icon: "face-smile",
action: () => {
this.activeFilter = "positive";
},
},
{
id: "neutral",
text: `${i18n(
"discourse_ai.sentiments.sentiment_analysis.filter_types.neutral"
)} (${this.selectedChart.scores[1]})`,
icon: "face-meh",
action: () => {
this.activeFilter = "neutral";
},
},
{
id: "negative",
text: `${i18n(
"discourse_ai.sentiments.sentiment_analysis.filter_types.negative"
)} (${this.selectedChart.scores[2]})`,
icon: "face-angry",
action: () => {
this.activeFilter = "negative";
},
},
];
}
@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,
},
});
if (this.selectedChart === data) {
// Don't do anything if the same chart is clicked again
return;
}
this.posts = posts.map((post) => Post.create(post));
this.selectedChart = data;
this.showingSelectedChart = true;
try {
const response = await this.postRequest();
this.posts = response.posts.map((post) => Post.create(post));
this.hasMorePosts = response.has_more;
this.nextOffset = response.next_offset;
} 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",
};
@action
async fetchMorePosts() {
if (!this.hasMorePosts || this.selectedChart === null) {
return [];
}
try {
const response = await this.postRequest();
this.hasMorePosts = response.has_more;
this.nextOffset = response.next_offset;
return response.posts.map((post) => Post.create(post));
} catch (e) {
popupAjaxError(e);
}
}
doughnutTitle(data) {
if (data?.total_score) {
return `${data.title} (${data.total_score})`;
} else {
return data.title;
}
@action
backToAllCharts() {
this.showingSelectedChart = false;
this.selectedChart = null;
this.activeFilter = "all";
}
<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>
{{#unless this.showingSelectedChart}}
<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>
{{/unless}}
{{#if this.selectedChart}}
<div class="admin-report-sentiment-analysis-details">
{{#if (and this.selectedChart this.showingSelectedChart)}}
<div class="admin-report-sentiment-analysis__selected-chart">
<DButton
@label="back_button"
@icon="chevron-left"
class="btn-flat"
@action={{this.backToAllCharts}}
/>
<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>
<DoughnutChart
@labels={{@model.labels}}
@colors={{this.colors}}
@data={{this.selectedChart.scores}}
@doughnutTitle={{this.doughnutTitle this.selectedChart}}
/>
</div>
<div class="admin-report-sentiment-analysis-details">
<HorizontalOverflowNav
{{this.setActiveFilter}}
class="admin-report-sentiment-analysis-details__filters"
>
{{#each this.postFilters as |filter|}}
<li data-filter-type={{filter.id}}>
<DButton
@icon={{filter.icon}}
@translatedLabel={{filter.text}}
@action={{filter.action}}
class="btn-transparent"
/>
</li>
{{/each}}
</HorizontalOverflowNav>
<PostList
@posts={{this.posts}}
@posts={{this.filteredPosts}}
@urlPath="url"
@idPath="post_id"
@titlePath="topic_title"
@usernamePath="username"
@fetchMorePosts={{this.fetchMorePosts}}
class="admin-report-sentiment-analysis-details__post-list"
>
<:abovePostItemExcerpt as |post|>

View File

@ -53,6 +53,7 @@
.main {
flex: 100%;
display: flex;
flex-flow: row wrap;
order: 2;
gap: 1rem;
align-items: flex-start;
@ -83,6 +84,12 @@
cursor: pointer;
}
}
&__selected-chart {
border: 1px solid var(--primary-low);
border-radius: var(--d-border-radius);
padding: 1rem;
}
}
:root {
@ -93,14 +100,34 @@
.admin-report-sentiment-analysis-details {
@include report-container-box();
flex: 1;
flex: 1 1 300px;
min-width: 300px;
display: flex;
flex-flow: column nowrap;
overflow-y: auto;
height: 100%;
&__filters {
border-bottom: 1px solid var(--primary-low);
margin-bottom: 1rem;
@include breakpoint("mobile-extra-large") {
.d-button-label {
display: none;
}
}
}
&__title {
font-size: var(--font-up-2);
margin: 0 auto;
text-align: center;
margin-bottom: 1rem;
margin-top: 0.3rem;
padding-top: 2rem;
padding-bottom: 1rem;
border-top: 1px solid var(--primary-low);
}
&__scores {
@ -152,6 +179,7 @@
}
&__post-list {
margin-top: 1rem;
.avatar-wrapper,
.avatar-link {
width: calc(48px * 0.75);

View File

@ -651,7 +651,8 @@ en:
dashboard:
title: "Sentiment"
sentiment_analysis:
score_types:
filter_types:
all: "All"
positive: "Positive"
neutral: "Neutral"
negative: "Negative"

View File

@ -31,7 +31,8 @@ module DiscourseAi
auto_insert_none_item: false,
)
report.add_category_filter(disabled: group_by_filter.to_sym == :tag)
category_id, include_subcategories =
report.add_category_filter(disabled: group_by_filter.to_sym == :tag)
tag_filter = report.filters.dig(:tag) || "any"
tag_choices =
@ -49,7 +50,8 @@ module DiscourseAi
disabled: group_by_filter.to_sym == :category,
)
sentiment_data = DiscourseAi::Sentiment::SentimentAnalysisReport.fetch_data(report)
opts = { category_id: category_id, include_subcategories: include_subcategories }
sentiment_data = DiscourseAi::Sentiment::SentimentAnalysisReport.fetch_data(report, opts)
report.data = sentiment_data
report.labels = [
@ -60,7 +62,7 @@ module DiscourseAi
end
end
def self.fetch_data(report)
def self.fetch_data(report, opts)
threshold = SENTIMENT_THRESHOLD
grouping = (report.filters.dig(:group_by) || GROUP_BY_FILTER_DEFAULT).to_sym
@ -118,6 +120,10 @@ module DiscourseAi
when :category
if category_filter.nil?
""
elsif opts[:include_subcategories]
<<~SQL
AND (c.id = :category_filter OR c.parent_category_id = :category_filter)
SQL
else
"AND c.id = :category_filter"
end

View File

@ -27,7 +27,8 @@ RSpec.describe DiscourseAi::Sentiment::SentimentController do
expect(response).to be_successful
posts = JSON.parse(response.body)
post_response = JSON.parse(response.body)
posts = post_response["posts"]
posts.each do |post|
expect(post).to have_key("sentiment")
expect(post["sentiment"]).to match(/positive|negative|neutral/)