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 include Constants
requires_plugin ::DiscourseAi::PLUGIN_NAME requires_plugin ::DiscourseAi::PLUGIN_NAME
DEFAULT_POSTS_LIMIT = 50
MAX_POSTS_LIMIT = 100
def posts def posts
group_by = params.required(:group_by)&.to_sym group_by = params.required(:group_by)&.to_sym
group_value = params.required(:group_value).presence group_value = params.required(:group_value).presence
@ -15,10 +18,13 @@ module DiscourseAi
raise Discourse::InvalidParameters if %i[category tag].exclude?(group_by) 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 case group_by
when :category when :category
grouping_clause = "c.name" grouping_clause = "c.name"
grouping_join = "INNER JOIN categories c ON c.id = t.category_id" grouping_join = "" # categories already joined
when :tag when :tag
grouping_clause = "tags.name" grouping_clause = "tags.name"
grouping_join = grouping_join =
@ -38,6 +44,11 @@ module DiscourseAi
u.username, u.username,
u.name, u.name,
u.uploaded_avatar_id, 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 (CASE
WHEN (cr.classification::jsonb->'positive')::float > :threshold THEN 'positive' WHEN (cr.classification::jsonb->'positive')::float > :threshold THEN 'positive'
WHEN (cr.classification::jsonb->'negative')::float > :threshold THEN 'negative' 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 topics t ON t.id = p.topic_id
INNER JOIN classification_results cr ON cr.target_id = p.id AND cr.target_type = 'Post' 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 users u ON u.id = p.user_id
LEFT JOIN categories c ON c.id = t.category_id
#{grouping_join} #{grouping_join}
WHERE WHERE
#{grouping_clause} = :group_value AND #{grouping_clause} = :group_value AND
@ -56,14 +68,21 @@ module DiscourseAi
((:start_date IS NULL OR p.created_at > :start_date) AND (:end_date IS NULL OR p.created_at < :end_date)) ((: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 AND p.deleted_at IS NULL
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
LIMIT :limit OFFSET :offset
SQL SQL
group_value: group_value, group_value: group_value,
start_date: start_date, start_date: start_date,
end_date: end_date, end_date: end_date,
threshold: threshold, threshold: threshold,
limit: limit + 1,
offset: offset,
) )
has_more = posts.length > limit
posts.pop if has_more
render_json_dump( render_json_dump(
posts:
serialize_data( serialize_data(
posts, posts,
AiSentimentPostSerializer, AiSentimentPostSerializer,
@ -72,6 +91,8 @@ module DiscourseAi
add_excerpt: true, add_excerpt: true,
add_title: true, add_title: true,
), ),
has_more: has_more,
next_offset: has_more ? offset + limit : nil,
) )
end end
end end

View File

@ -10,7 +10,8 @@ class AiSentimentPostSerializer < ApplicationSerializer
:avatar_template, :avatar_template,
:excerpt, :excerpt,
:sentiment, :sentiment,
:truncated :truncated,
:category
def avatar_template def avatar_template
User.avatar_template(object.username, object.uploaded_avatar_id) User.avatar_template(object.username, object.uploaded_avatar_id)
@ -23,4 +24,14 @@ class AiSentimentPostSerializer < ApplicationSerializer
def truncated def truncated
true true
end 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 end

View File

@ -2,7 +2,11 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { fn, hash } from "@ember/helper"; import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier"; 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 PostList from "discourse/components/post-list";
import dIcon from "discourse/helpers/d-icon"; import dIcon from "discourse/helpers/d-icon";
import { ajax } from "discourse/lib/ajax"; 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 { export default class AdminReportSentimentAnalysis extends Component {
@tracked selectedChart = null; @tracked selectedChart = null;
@tracked posts = null; @tracked posts = null;
@tracked hasMorePosts = false;
@tracked nextOffset = 0;
@tracked showingSelectedChart = false;
@tracked activeFilter = "all";
get colors() { setActiveFilter = modifier((element) => {
return ["#2ecc71", "#95a5a6", "#e74c3c"]; 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) { calculateNeutralScore(data) {
return data.total_count - (data.positive_count + data.negative_count); 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() { get currentGroupFilter() {
return this.args.model.available_filters.find( return this.args.model.available_filters.find(
(filter) => filter.id === "group_by" (filter) => filter.id === "group_by"
@ -50,63 +127,111 @@ 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 @action
async showDetails(data) { async showDetails(data) {
this.selectedChart = data; if (this.selectedChart === data) {
try { // Don't do anything if the same chart is clicked again
const posts = await ajax(`/discourse-ai/sentiment/posts`, { return;
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)); 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) { } catch (e) {
popupAjaxError(e); popupAjaxError(e);
} }
} }
sentimentMapping(sentiment) { @action
switch (sentiment) { async fetchMorePosts() {
case "positive": if (!this.hasMorePosts || this.selectedChart === null) {
return { return [];
id: "positive", }
text: i18n(
"discourse_ai.sentiments.sentiment_analysis.score_types.positive" try {
), const response = await this.postRequest();
icon: "face-smile",
}; this.hasMorePosts = response.has_more;
case "neutral": this.nextOffset = response.next_offset;
return { return response.posts.map((post) => Post.create(post));
id: "neutral", } catch (e) {
text: i18n( popupAjaxError(e);
"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) { @action
if (data?.total_score) { backToAllCharts() {
return `${data.title} (${data.total_score})`; this.showingSelectedChart = false;
} else { this.selectedChart = null;
return data.title; this.activeFilter = "all";
}
} }
<template> <template>
{{#unless this.showingSelectedChart}}
<div class="admin-report-sentiment-analysis"> <div class="admin-report-sentiment-analysis">
{{#each this.transformedData as |data|}} {{#each this.transformedData as |data|}}
<div <div
@ -130,40 +255,51 @@ export default class AdminReportSentimentAnalysis extends Component {
</div> </div>
{{/each}} {{/each}}
</div> </div>
{{/unless}}
{{#if this.selectedChart}} {{#if (and this.selectedChart this.showingSelectedChart)}}
<div class="admin-report-sentiment-analysis-details"> <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"> <h3 class="admin-report-sentiment-analysis-details__title">
{{this.selectedChart.title}} {{this.selectedChart.title}}
</h3> </h3>
<ul class="admin-report-sentiment-analysis-details__scores"> <DoughnutChart
<li> @labels={{@model.labels}}
{{dIcon "face-smile" style="color: #2ecc71"}} @colors={{this.colors}}
{{i18n @data={{this.selectedChart.scores}}
"discourse_ai.sentiments.sentiment_analysis.score_types.positive" @doughnutTitle={{this.doughnutTitle this.selectedChart}}
}}: />
{{get this.selectedChart.scores 0}}</li> </div>
<li> <div class="admin-report-sentiment-analysis-details">
{{dIcon "face-meh"}} <HorizontalOverflowNav
{{i18n {{this.setActiveFilter}}
"discourse_ai.sentiments.sentiment_analysis.score_types.neutral" class="admin-report-sentiment-analysis-details__filters"
}}: >
{{get this.selectedChart.scores 1}}</li> {{#each this.postFilters as |filter|}}
<li> <li data-filter-type={{filter.id}}>
{{dIcon "face-angry"}} <DButton
{{i18n @icon={{filter.icon}}
"discourse_ai.sentiments.sentiment_analysis.score_types.negative" @translatedLabel={{filter.text}}
}}: @action={{filter.action}}
{{get this.selectedChart.scores 2}}</li> class="btn-transparent"
</ul> />
</li>
{{/each}}
</HorizontalOverflowNav>
<PostList <PostList
@posts={{this.posts}} @posts={{this.filteredPosts}}
@urlPath="url" @urlPath="url"
@idPath="post_id" @idPath="post_id"
@titlePath="topic_title" @titlePath="topic_title"
@usernamePath="username" @usernamePath="username"
@fetchMorePosts={{this.fetchMorePosts}}
class="admin-report-sentiment-analysis-details__post-list" class="admin-report-sentiment-analysis-details__post-list"
> >
<:abovePostItemExcerpt as |post|> <:abovePostItemExcerpt as |post|>

View File

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

View File

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

View File

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

View File

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