mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-03-01 06:49:30 +00:00
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:
parent
43cbb7f45f
commit
08377bab35
@ -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
|
||||
|
@ -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
|
||||
|
@ -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|>
|
||||
|
@ -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);
|
||||
|
@ -651,7 +651,8 @@ en:
|
||||
dashboard:
|
||||
title: "Sentiment"
|
||||
sentiment_analysis:
|
||||
score_types:
|
||||
filter_types:
|
||||
all: "All"
|
||||
positive: "Positive"
|
||||
neutral: "Neutral"
|
||||
negative: "Negative"
|
||||
|
@ -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
|
||||
|
@ -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/)
|
||||
|
Loading…
x
Reference in New Issue
Block a user