discourse-ai/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs
Keegan George 24f0e1262d
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
![Screenshot 2025-02-14 at 11 11 35](https://github.com/user-attachments/assets/a63b5ab8-4fb2-477d-bd29-92545f44ff09)
2025-02-20 09:14:10 -08:00

185 lines
5.3 KiB
Plaintext

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>
}