From d93ce0dac9ac99654d26f7f5dd44d677b87f47d0 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Mon, 3 Feb 2025 16:29:23 -0800 Subject: [PATCH 01/21] FEATURE: New sentiment analysis visualization report WIP --- .../admin-report-sentiment-analysis.gjs | 9 +++++++++ assets/javascripts/initializers/admin-reports.js | 5 +++++ config/locales/server.en.yml | 3 +++ lib/sentiment/entry_point.rb | 1 + lib/sentiment/sentiment_analysis_report.rb | 15 +++++++++++++++ 5 files changed, 33 insertions(+) create mode 100644 assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs create mode 100644 lib/sentiment/sentiment_analysis_report.rb diff --git a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs new file mode 100644 index 000000000..168f5c4e3 --- /dev/null +++ b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs @@ -0,0 +1,9 @@ +import Component from "@glimmer/component"; + +export default class AdminReportSentimentAnalysis extends Component { + +} diff --git a/assets/javascripts/initializers/admin-reports.js b/assets/javascripts/initializers/admin-reports.js index d22711d8b..7a1789f52 100644 --- a/assets/javascripts/initializers/admin-reports.js +++ b/assets/javascripts/initializers/admin-reports.js @@ -1,5 +1,6 @@ import { withPluginApi } from "discourse/lib/plugin-api"; import AdminReportEmotion from "discourse/plugins/discourse-ai/discourse/components/admin-report-emotion"; +import AdminReportSentimentAnalysis from "../discourse/components/admin-report-sentiment-analysis"; export default { name: "discourse-ai-admin-reports", @@ -12,6 +13,10 @@ export default { withPluginApi("2.0.1", (api) => { api.registerReportModeComponent("emotion", AdminReportEmotion); + api.registerReportModeComponent( + "sentiment-analysis", + AdminReportSentimentAnalysis + ); }); }, }; diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index e490ef24c..68e5e7315 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -104,6 +104,9 @@ en: flagged_by_nsfw: The AI plugin flagged this after classifying at least one of the attached images as NSFW. reports: + sentiment_analysis: + title: "Sentiment analysis" + description: "todo" overall_sentiment: title: "Overall sentiment" description: 'The chart compares the number of posts classified as either positive or negative. These are calculated when positive or negative scores > the set threshold score. This means neutral posts are not shown. Personal messages (PMs) are also excluded. Classified with "cardiffnlp/twitter-roberta-base-sentiment-latest"' diff --git a/lib/sentiment/entry_point.rb b/lib/sentiment/entry_point.rb index 4735dbd49..2325f81ee 100644 --- a/lib/sentiment/entry_point.rb +++ b/lib/sentiment/entry_point.rb @@ -17,6 +17,7 @@ def inject_into(plugin) EmotionFilterOrder.register!(plugin) EmotionDashboardReport.register!(plugin) SentimentDashboardReport.register!(plugin) + SentimentAnalysisReport.register!(plugin) end end end diff --git a/lib/sentiment/sentiment_analysis_report.rb b/lib/sentiment/sentiment_analysis_report.rb new file mode 100644 index 000000000..d3cf2cf3a --- /dev/null +++ b/lib/sentiment/sentiment_analysis_report.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module DiscourseAi + module Sentiment + class SentimentAnalysisReport + def self.register!(plugin) + plugin.add_report("sentiment_analysis") do |report| + # TODO: Implement the report + # report.modes = [] + # reprot.data = {} + end + end + end + end +end From 49b62dba18b177c5387576aee32dd238195dcb4d Mon Sep 17 00:00:00 2001 From: Keegan George Date: Tue, 4 Feb 2025 16:03:56 -0800 Subject: [PATCH 02/21] DEV: Successfully test adding `dougnut` type chart --- .../admin-report-sentiment-analysis.gjs | 21 ++++++++++++++++++- .../javascripts/initializers/admin-reports.js | 2 +- lib/sentiment/sentiment_analysis_report.rb | 4 ++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs index 168f5c4e3..83407b0df 100644 --- a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs +++ b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs @@ -1,9 +1,28 @@ import Component from "@glimmer/component"; +import Chart from "admin/components/chart"; export default class AdminReportSentimentAnalysis extends Component { + get chartConfig() { + return { + type: "doughnut", + data: { + labels: ["Positive", "Negative", "Neutral"], + datasets: [ + { + data: [300, 50, 100], + backgroundColor: ["#28a745", "#dc3545", "#ffc107"], + }, + ], + }, + }; + } + } diff --git a/assets/javascripts/initializers/admin-reports.js b/assets/javascripts/initializers/admin-reports.js index 7a1789f52..519d33e5f 100644 --- a/assets/javascripts/initializers/admin-reports.js +++ b/assets/javascripts/initializers/admin-reports.js @@ -14,7 +14,7 @@ export default { withPluginApi("2.0.1", (api) => { api.registerReportModeComponent("emotion", AdminReportEmotion); api.registerReportModeComponent( - "sentiment-analysis", + "sentiment_analysis", AdminReportSentimentAnalysis ); }); diff --git a/lib/sentiment/sentiment_analysis_report.rb b/lib/sentiment/sentiment_analysis_report.rb index d3cf2cf3a..f495982c1 100644 --- a/lib/sentiment/sentiment_analysis_report.rb +++ b/lib/sentiment/sentiment_analysis_report.rb @@ -6,8 +6,8 @@ class SentimentAnalysisReport def self.register!(plugin) plugin.add_report("sentiment_analysis") do |report| # TODO: Implement the report - # report.modes = [] - # reprot.data = {} + report.modes = [:sentiment_analysis] + report.data = [300, 50, 100] end end end From 99ac3cf8272101abe64bd8405535da0265c2d741 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Tue, 4 Feb 2025 16:52:15 -0800 Subject: [PATCH 03/21] WIP... --- .../admin-report-sentiment-analysis.gjs | 57 ++++++++++++++++--- .../modules/sentiment/common/dashboard.scss | 24 ++++++++ 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs index 83407b0df..3176992a4 100644 --- a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs +++ b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs @@ -6,23 +6,66 @@ export default class AdminReportSentimentAnalysis extends Component { return { type: "doughnut", data: { - labels: ["Positive", "Negative", "Neutral"], + labels: ["Positive", "Neutral", "Negative"], datasets: [ { data: [300, 50, 100], - backgroundColor: ["#28a745", "#dc3545", "#ffc107"], + backgroundColor: ["#2ecc71", "#95a5a6", "#e74c3c"], }, ], }, + options: { + responsive: true, + plugins: { + legend: { + position: "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()}`; + + // TODO: populate with actual tag / category title + ctx.fillText("member-experience", centerX, centerY); + ctx.save(); + }, + }, + ], }; } } diff --git a/assets/stylesheets/modules/sentiment/common/dashboard.scss b/assets/stylesheets/modules/sentiment/common/dashboard.scss index a80717ea2..4730dde95 100644 --- a/assets/stylesheets/modules/sentiment/common/dashboard.scss +++ b/assets/stylesheets/modules/sentiment/common/dashboard.scss @@ -9,3 +9,27 @@ } } } + +.admin-report.sentiment-analyis { + .filters { + order: 1; + width: 100%; + } + + .main { + order: 2; + } +} + +.admin-report-sentiment-analysis { + margin-top: 1rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 2.5rem; + justify-items: center; + + .admin-report-doughnut { + max-width: 300px; /* Adjust size */ + max-height: 300px; + } +} From 606ae7b9e278f52a61c46c454b0a93f21ede49b7 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Wed, 5 Feb 2025 16:17:30 -0800 Subject: [PATCH 04/21] DEV: First version with real data --- .../admin-report-sentiment-analysis.gjs | 186 ++++++++++++------ .../discourse/components/doughnut-chart.gjs | 65 ++++++ .../modules/sentiment/common/dashboard.scss | 110 ++++++++++- config/locales/client.en.yml | 5 + config/locales/server.en.yml | 2 +- lib/sentiment/entry_point.rb | 3 + lib/sentiment/sentiment_analysis_report.rb | 108 +++++++++- 7 files changed, 411 insertions(+), 68 deletions(-) create mode 100644 assets/javascripts/discourse/components/doughnut-chart.gjs diff --git a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs index 3176992a4..d149235c6 100644 --- a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs +++ b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs @@ -1,71 +1,143 @@ import Component from "@glimmer/component"; -import Chart from "admin/components/chart"; +import { tracked } from "@glimmer/tracking"; +import { fn, hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action, get } from "@ember/object"; +import closeOnClickOutside from "discourse/modifiers/close-on-click-outside"; +import dIcon from "discourse-common/helpers/d-icon"; +import { i18n } from "discourse-i18n"; +import DoughnutChart from "./doughnut-chart"; +import PostList from "discourse/components/post-list"; export default class AdminReportSentimentAnalysis extends Component { - get chartConfig() { - return { - type: "doughnut", - data: { - labels: ["Positive", "Neutral", "Negative"], - datasets: [ - { - data: [300, 50, 100], - backgroundColor: ["#2ecc71", "#95a5a6", "#e74c3c"], - }, + @tracked selectedChart = null; + + get labels() { + return ["Positive", "Neutral", "Negative"]; + } + + get colors() { + return ["#2ecc71", "#95a5a6", "#e74c3c"]; + } + + get transformedData() { + return this.args.model.data.map((data) => { + return { + category_name: data.category_name, + scores: [ + data.overall_scores.positive, + data.overall_scores.neutral, + data.overall_scores.negative, ], - }, - options: { - responsive: true, - plugins: { - legend: { - position: "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"; + // TODO slicing 3 posts for now + posts: data.posts, + }; + }); + } - const { ctx, chartArea } = chart; - const centerX = (chartArea.left + chartArea.right) / 2; - const centerY = (chartArea.top + chartArea.bottom) / 2; + @action + showDetails(data) { + console.log(data); + this.selectedChart = data; + } - ctx.restore(); - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillStyle = cssVarColor.trim(); - ctx.font = `${cssFontSize.trim()} ${cssFontFamily.trim()}`; + sentimentTopScore(post) { + const { positive_score, neutral_score, negative_score } = post; + const maxScore = Math.max(positive_score, neutral_score, negative_score); - // TODO: populate with actual tag / category title - ctx.fillText("member-experience", centerX, centerY); - ctx.save(); - }, - }, - ], - }; + if (maxScore === positive_score) { + return { + id: "positive", + text: i18n( + "discourse_ai.sentiments.sentiment_analysis.score_types.positive" + ), + icon: "face-smile", + }; + } else if (maxScore === neutral_score) { + return { + id: "neutral", + text: i18n( + "discourse_ai.sentiments.sentiment_analysis.score_types.neutral" + ), + icon: "face-meh", + }; + } else { + return { + id: "negative", + text: i18n( + "discourse_ai.sentiments.sentiment_analysis.score_types.negative" + ), + icon: "face-angry", + }; + } } } diff --git a/assets/javascripts/discourse/components/doughnut-chart.gjs b/assets/javascripts/discourse/components/doughnut-chart.gjs new file mode 100644 index 000000000..b1e2c85b7 --- /dev/null +++ b/assets/javascripts/discourse/components/doughnut-chart.gjs @@ -0,0 +1,65 @@ +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(); + }, + }, + ], + }; + } + + +} diff --git a/assets/stylesheets/modules/sentiment/common/dashboard.scss b/assets/stylesheets/modules/sentiment/common/dashboard.scss index 4730dde95..9edb24317 100644 --- a/assets/stylesheets/modules/sentiment/common/dashboard.scss +++ b/assets/stylesheets/modules/sentiment/common/dashboard.scss @@ -10,26 +10,120 @@ } } -.admin-report.sentiment-analyis { +@mixin report-container-box() { + border: 1px solid var(--primary-low); + border-radius: var(--d-border-radius); + padding: 1rem; +} + +.admin-report.sentiment-analysis .body { + display: flex; + flex-flow: row wrap; + gap: 1rem; + .filters { order: 1; - width: 100%; + width: 300px; + @include report-container-box(); + margin-left: 0; + // width: 300px; } .main { + flex: 100%; + + display: flex; order: 2; + gap: 1rem; } } .admin-report-sentiment-analysis { - margin-top: 1rem; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 2.5rem; - justify-items: center; + @include report-container-box(); + flex: 2; + display: flex; + flex-flow: row wrap; + gap: 3rem; .admin-report-doughnut { - max-width: 300px; /* Adjust size */ + max-width: 300px; max-height: 300px; + padding: 0.25rem; + } + + &__chart-wrapper { + transition: transform 0.25s ease, box-shadow 0.25s ease; + border-radius: var(--d-border-radius); + + &:hover { + @include transform(translateY(-1rem)); + box-shadow: var(--shadow-card); + cursor: pointer; + } + } +} + +:root { + --d-sentiment-report-positive-rgb: 46, 204, 112; + --d-sentiment-report-neutral-rgb: 149, 166, 167; + --d-sentiment-report-negative-rgb: 231, 77, 60; +} + +.admin-report-sentiment-analysis-details { + @include report-container-box(); + flex: 1; + display: flex; + flex-flow: column nowrap; + + &__title { + font-size: var(--font-up-2); + } + + &__scores { + display: flex; + flex-flow: column wrap; + align-items: flex-start; + justify-content: flex-start; + gap: 0.25rem; + list-style: none; + margin-left: 0; + background: var(--primary-very-low); + padding: 1rem; + border-radius: var(--d-border-radius); + + .d-icon-face-smile { + color: rgb(var(--d-sentiment-report-positive-rgb)); + } + + .d-icon-face-meh { + color: rgb(var(--d-sentiment-report-neutral-rgb)); + } + + .d-icon-face-angry { + color: rgb(var(--d-sentiment-report-negative-rgb)); + } + } + + &__post-score { + border-radius: var(--d-border-radius); + background: var(--primary-very-low); + margin-top: 0.5rem; + padding: 0.25rem; + font-size: var(--font-down-1); + display: inline-block; + &[data-sentiment-score="positive"] { + color: rgb(var(--d-sentiment-report-positive-rgb)); + background: rgba(var(--d-sentiment-report-positive-rgb), 0.1); + } + + &[data-sentiment-score="neutral"] { + color: rgb(var(--d-sentiment-report-neutral-rgb)); + background: rgba(var(--d-sentiment-report-neutral-rgb), 0.1); + } + + &[data-sentiment-score="negative"] { + color: rgb(var(--d-sentiment-report-negative-rgb)); + background: rgba(var(--d-sentiment-report-negative-rgb), 0.1); + } } } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 300d30c1b..05be971e8 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -633,6 +633,11 @@ en: sentiments: dashboard: title: "Sentiment" + sentiment_analysis: + score_types: + positive: "Positive" + neutral: "Neutral" + negative: "Negative" summarization: chat: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 68e5e7315..e18a05893 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -106,7 +106,7 @@ en: reports: sentiment_analysis: title: "Sentiment analysis" - description: "todo" + description: "This report provides sentiment analysis for posts, grouped by category, with positive, negative, and neutral scores for each post and category." overall_sentiment: title: "Overall sentiment" description: 'The chart compares the number of posts classified as either positive or negative. These are calculated when positive or negative scores > the set threshold score. This means neutral posts are not shown. Personal messages (PMs) are also excluded. Classified with "cardiffnlp/twitter-roberta-base-sentiment-latest"' diff --git a/lib/sentiment/entry_point.rb b/lib/sentiment/entry_point.rb index 2325f81ee..c886675d7 100644 --- a/lib/sentiment/entry_point.rb +++ b/lib/sentiment/entry_point.rb @@ -14,6 +14,9 @@ def inject_into(plugin) plugin.on(:post_created, &sentiment_analysis_cb) plugin.on(:post_edited, &sentiment_analysis_cb) + additional_icons = %w[face-smile face-meh face-angry] + additional_icons.each { |icon| plugin.register_svg_icon(icon) } + EmotionFilterOrder.register!(plugin) EmotionDashboardReport.register!(plugin) SentimentDashboardReport.register!(plugin) diff --git a/lib/sentiment/sentiment_analysis_report.rb b/lib/sentiment/sentiment_analysis_report.rb index f495982c1..16b621bb1 100644 --- a/lib/sentiment/sentiment_analysis_report.rb +++ b/lib/sentiment/sentiment_analysis_report.rb @@ -1,13 +1,117 @@ # frozen_string_literal: true +# TODO: Currently returns all posts, need to add pagination! module DiscourseAi module Sentiment class SentimentAnalysisReport def self.register!(plugin) plugin.add_report("sentiment_analysis") do |report| - # TODO: Implement the report report.modes = [:sentiment_analysis] - report.data = [300, 50, 100] + + sentiment_data = + DB + .query(<<~SQL, report_start: report.start_date, report_end: report.end_date) +WITH topic_tags_cte AS ( + SELECT + tt.topic_id, + string_agg(DISTINCT tags.name, ',') AS tag_names + FROM topic_tags tt + JOIN tags ON tags.id = tt.tag_id + GROUP BY tt.topic_id +) +SELECT + t.id AS topic_id, + t.title, + p.id AS post_id, + p.post_number, + u.username, + LEFT(p.cooked, 300) AS post_excerpt, + c.id AS category_id, + c.name AS category_name, + COALESCE(tt.tag_names, '') AS tag_names, + (cr.classification::jsonb->'positive')::float AS positive_score, + (cr.classification::jsonb->'negative')::float AS negative_score +FROM classification_results cr +JOIN posts p + ON p.id = cr.target_id + AND cr.target_type = 'Post' +JOIN topics t + ON t.id = p.topic_id +JOIN categories c + ON c.id = t.category_id +JOIN users u + ON u.id = p.user_id +LEFT JOIN topic_tags_cte tt + ON tt.topic_id = t.id +WHERE + (p.created_at > :report_start AND p.created_at < :report_end) + AND cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' + AND p.deleted_at IS NULL + AND p.hidden = FALSE + AND t.deleted_at IS NULL + AND t.visible = TRUE + AND t.archetype != 'private_message' + AND c.read_restricted = FALSE +ORDER BY c.name, (cr.classification::jsonb->'negative')::float DESC +SQL + .map do |row| + # Add neutral score and structure data + positive_score = row.positive_score || 0.0 + negative_score = row.negative_score || 0.0 + neutral_score = 1.0 - (positive_score + negative_score) + + { + category_name: row.category_name, + topic_id: row.topic_id, + title: row.title, + post_id: row.post_id, + post_number: row.post_number, + username: row.username, + post_excerpt: row.post_excerpt, + category_id: row.category_id, + tag_names: row.tag_names, + positive_score: positive_score, + negative_score: negative_score, + neutral_score: neutral_score, + } + end + + # Group posts by category + # ! TODO: by tags? + grouped_data = sentiment_data.group_by { |row| row[:category_name] } + report.data = + grouped_data.map do |category_name, posts| + total_positive = posts.sum { |p| p[:positive_score] } + total_negative = posts.sum { |p| p[:negative_score] } + total_neutral = posts.sum { |p| p[:neutral_score] } + count = posts.size.to_f + + { + category_name: category_name, + overall_scores: { + positive: (total_positive / count).round(2), + negative: (total_negative / count).round(2), + neutral: (total_neutral / count).round(2), + }, + posts: + posts.map do |p| + { + topic_id: p[:topic_id], + title: p[:title], + post_id: p[:post_id], + post_number: p[:post_number], + username: p[:username], + excerpt: p[:post_excerpt], + category_id: p[:category_id], + tag_names: p[:tag_names], + positive_score: p[:positive_score].round(2), + negative_score: p[:negative_score].round(2), + neutral_score: p[:neutral_score].round(2), + postUrl: "/t/#{p[:topic_id]}/#{p[:post_number]}", + } + end, + } + end end end end From da7e8a5fb3e32a9a4fc4f615e3fd04425fa163f2 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Wed, 5 Feb 2025 19:26:12 -0800 Subject: [PATCH 05/21] updates --- .../admin-report-sentiment-analysis.gjs | 3 +- .../modules/sentiment/common/dashboard.scss | 5 +- lib/sentiment/sentiment_analysis_report.rb | 143 +++++++++--------- 3 files changed, 79 insertions(+), 72 deletions(-) diff --git a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs index d149235c6..a658f122c 100644 --- a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs +++ b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs @@ -3,11 +3,11 @@ 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 closeOnClickOutside from "discourse/modifiers/close-on-click-outside"; import dIcon from "discourse-common/helpers/d-icon"; import { i18n } from "discourse-i18n"; import DoughnutChart from "./doughnut-chart"; -import PostList from "discourse/components/post-list"; export default class AdminReportSentimentAnalysis extends Component { @tracked selectedChart = null; @@ -29,7 +29,6 @@ export default class AdminReportSentimentAnalysis extends Component { data.overall_scores.neutral, data.overall_scores.negative, ], - // TODO slicing 3 posts for now posts: data.posts, }; }); diff --git a/assets/stylesheets/modules/sentiment/common/dashboard.scss b/assets/stylesheets/modules/sentiment/common/dashboard.scss index 9edb24317..07303dec1 100644 --- a/assets/stylesheets/modules/sentiment/common/dashboard.scss +++ b/assets/stylesheets/modules/sentiment/common/dashboard.scss @@ -31,10 +31,11 @@ .main { flex: 100%; - display: flex; order: 2; gap: 1rem; + align-items: flex-start; + max-height: 100vh; } } @@ -74,6 +75,8 @@ flex: 1; display: flex; flex-flow: column nowrap; + overflow-y: auto; + height: 100%; &__title { font-size: var(--font-up-2); diff --git a/lib/sentiment/sentiment_analysis_report.rb b/lib/sentiment/sentiment_analysis_report.rb index 16b621bb1..6d25af529 100644 --- a/lib/sentiment/sentiment_analysis_report.rb +++ b/lib/sentiment/sentiment_analysis_report.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true -# TODO: Currently returns all posts, need to add pagination! + +# TODO: Currently returns all posts, need to add pagination? module DiscourseAi module Sentiment @@ -7,74 +8,7 @@ class SentimentAnalysisReport def self.register!(plugin) plugin.add_report("sentiment_analysis") do |report| report.modes = [:sentiment_analysis] - - sentiment_data = - DB - .query(<<~SQL, report_start: report.start_date, report_end: report.end_date) -WITH topic_tags_cte AS ( - SELECT - tt.topic_id, - string_agg(DISTINCT tags.name, ',') AS tag_names - FROM topic_tags tt - JOIN tags ON tags.id = tt.tag_id - GROUP BY tt.topic_id -) -SELECT - t.id AS topic_id, - t.title, - p.id AS post_id, - p.post_number, - u.username, - LEFT(p.cooked, 300) AS post_excerpt, - c.id AS category_id, - c.name AS category_name, - COALESCE(tt.tag_names, '') AS tag_names, - (cr.classification::jsonb->'positive')::float AS positive_score, - (cr.classification::jsonb->'negative')::float AS negative_score -FROM classification_results cr -JOIN posts p - ON p.id = cr.target_id - AND cr.target_type = 'Post' -JOIN topics t - ON t.id = p.topic_id -JOIN categories c - ON c.id = t.category_id -JOIN users u - ON u.id = p.user_id -LEFT JOIN topic_tags_cte tt - ON tt.topic_id = t.id -WHERE - (p.created_at > :report_start AND p.created_at < :report_end) - AND cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' - AND p.deleted_at IS NULL - AND p.hidden = FALSE - AND t.deleted_at IS NULL - AND t.visible = TRUE - AND t.archetype != 'private_message' - AND c.read_restricted = FALSE -ORDER BY c.name, (cr.classification::jsonb->'negative')::float DESC -SQL - .map do |row| - # Add neutral score and structure data - positive_score = row.positive_score || 0.0 - negative_score = row.negative_score || 0.0 - neutral_score = 1.0 - (positive_score + negative_score) - - { - category_name: row.category_name, - topic_id: row.topic_id, - title: row.title, - post_id: row.post_id, - post_number: row.post_number, - username: row.username, - post_excerpt: row.post_excerpt, - category_id: row.category_id, - tag_names: row.tag_names, - positive_score: positive_score, - negative_score: negative_score, - neutral_score: neutral_score, - } - end + sentiment_data = DiscourseAi::Sentiment::SentimentAnalysisReport.fetch_data(report) # Group posts by category # ! TODO: by tags? @@ -114,6 +48,77 @@ def self.register!(plugin) end end end + + def self.fetch_data(report) + DB + .query(<<~SQL, report_start: report.start_date, report_end: report.end_date) + WITH topic_tags_cte AS ( + SELECT + tt.topic_id, + string_agg(DISTINCT tags.name, ',') AS tag_names + FROM topic_tags tt + JOIN tags ON tags.id = tt.tag_id + GROUP BY tt.topic_id + ) + SELECT + t.id AS topic_id, + t.title, + p.id AS post_id, + p.post_number, + u.username, + LEFT(p.cooked, 300) AS post_excerpt, + c.id AS category_id, + c.name AS category_name, + COALESCE(tt.tag_names, '') AS tag_names, + (cr.classification::jsonb->'positive')::float AS positive_score, + (cr.classification::jsonb->'negative')::float AS negative_score + FROM classification_results cr + JOIN posts p + ON p.id = cr.target_id + AND cr.target_type = 'Post' + JOIN topics t + ON t.id = p.topic_id + JOIN categories c + ON c.id = t.category_id + JOIN users u + ON u.id = p.user_id + LEFT JOIN topic_tags_cte tt + ON tt.topic_id = t.id + WHERE + p.created_at BETWEEN :report_start AND :report_end + AND cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' + AND p.deleted_at IS NULL + AND p.hidden = FALSE + AND t.deleted_at IS NULL + AND t.visible = TRUE + AND t.archetype != 'private_message' + AND c.read_restricted = FALSE + ORDER BY + c.name, + (cr.classification::jsonb->'negative')::float DESC + SQL + .map do |row| + # Add neutral score and structure data + positive_score = row.positive_score || 0.0 + negative_score = row.negative_score || 0.0 + neutral_score = 1.0 - (positive_score + negative_score) + + { + category_name: row.category_name, + topic_id: row.topic_id, + title: row.title, + post_id: row.post_id, + post_number: row.post_number, + username: row.username, + post_excerpt: row.post_excerpt, + category_id: row.category_id, + tag_names: row.tag_names, + positive_score: positive_score, + negative_score: negative_score, + neutral_score: neutral_score, + } + end + end end end end From eba49a8fbf671078ba96b11734040363c38dd3f5 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Wed, 5 Feb 2025 20:08:04 -0800 Subject: [PATCH 06/21] getting started on filters --- .../admin-report-sentiment-analysis.gjs | 16 ++++++++++------ config/locales/client.en.yml | 5 +++++ config/locales/server.en.yml | 4 ++++ lib/sentiment/sentiment_analysis_report.rb | 17 +++++++++++++++++ 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs index a658f122c..df0187403 100644 --- a/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs +++ b/assets/javascripts/discourse/components/admin-report-sentiment-analysis.gjs @@ -12,10 +12,6 @@ import DoughnutChart from "./doughnut-chart"; export default class AdminReportSentimentAnalysis extends Component { @tracked selectedChart = null; - get labels() { - return ["Positive", "Neutral", "Negative"]; - } - get colors() { return ["#2ecc71", "#95a5a6", "#e74c3c"]; } @@ -71,6 +67,14 @@ export default class AdminReportSentimentAnalysis extends Component { } } + doughnutTitle(data) { + if (data.posts?.length > 0) { + return `${data.category_name} (${data.posts.length})`; + } else { + return data.category_name; + } + } +