This repository was archived by the owner on Jul 22, 2025. It is now read-only.
generated from discourse/discourse-plugin-skeleton
-
Notifications
You must be signed in to change notification settings - Fork 40
FEATURE: New sentiment analysis visualization report #1109
Merged
Merged
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
d93ce0d
FEATURE: New sentiment analysis visualization report
keegangeorge 49b62db
DEV: Successfully test adding `dougnut` type chart
keegangeorge 99ac3cf
WIP...
keegangeorge 606ae7b
DEV: First version with real data
keegangeorge da7e8a5
updates
keegangeorge eba49a8
getting started on filters
keegangeorge feaa02f
Refactor sentiment analysis query
xfalcox 4325813
FIX: Filtering and client-side
keegangeorge 5ef594a
DEV: Add group_by
keegangeorge 576affc
DEV: Add size filter
keegangeorge c71e0e7
DEV: Sentiment posts endpoint
keegangeorge c0b3a18
FIX: Icon
keegangeorge 2c19684
FIX: Filter defaults
keegangeorge b7e707a
DEV: Conditionally disable/enable category/tag filters
keegangeorge 8915871
DEV: Add staff constraint and cleanup css
keegangeorge f847d34
Merge branch 'main' into sentiment-analysis
keegangeorge d9ec52c
wip
keegangeorge 19d93d5
fix import
keegangeorge 91b4521
FIX: mixin bug in specs
keegangeorge d28bdfc
FIX: component import error for non-admins
keegangeorge 43e2faf
DEV: Complete specs
keegangeorge 933d952
DEV: Apply feedback from review
keegangeorge File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
83 changes: 83 additions & 0 deletions
83
app/controllers/discourse_ai/sentiment/sentiment_controller.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module DiscourseAi | ||
| module Sentiment | ||
| class SentimentController < ::Admin::StaffController | ||
| include Constants | ||
| requires_plugin ::DiscourseAi::PLUGIN_NAME | ||
| requires_login | ||
|
|
||
| def posts | ||
| group_by = params[:group_by]&.to_sym | ||
| group_value = params[:group_value].presence | ||
keegangeorge marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| start_date = params[:start_date].presence | ||
| end_date = params[:end_date] | ||
| threshold = SENTIMENT_THRESHOLD | ||
|
|
||
| if %i[category tag].exclude?(group_by) || group_value.blank? | ||
keegangeorge marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| raise Discourse::InvalidParameters | ||
| end | ||
|
|
||
| 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" | ||
| else | ||
| raise Discourse::InvalidParameters | ||
keegangeorge marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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)) | ||
keegangeorge marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
184 changes: 184 additions & 0 deletions
184
assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 changes: 67 additions & 0 deletions
67
assets/javascripts/discourse/components/doughnut-chart.gjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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> | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.