FEATURE: Expose sentiment classifications via the admin dashboard. (#284)

This PR adds new reports for displaying information about post sentiments grouped by date and emotions group by TL.

Depends on discourse/discourse#24274
This commit is contained in:
Roman Rizzi 2023-11-08 10:50:37 -03:00 committed by GitHub
parent 30821badf2
commit b172ef11c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 371 additions and 3 deletions

View File

@ -0,0 +1,12 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class DashboardController < ::Admin::StaffController
requires_plugin DiscourseAi::PLUGIN_NAME
def sentiment
end
end
end
end

View File

@ -0,0 +1,10 @@
export default {
resource: "admin.dashboard",
path: "/dashboard",
map() {
this.route("admin.dashboardSentiment", {
path: "/dashboard/sentiment",
resetNamespace: true,
});
},
};

View File

@ -0,0 +1,19 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import I18n from "discourse-i18n";
const i18n = I18n.t.bind(I18n);
export default class AISentimentDashboard extends Component {
<template>
<li class="navigation-item sentiment">
<LinkTo @route="admin.dashboardSentiment" class="navigation-link">
{{i18n "discourse_ai.sentiments.dashboard.title"}}
</LinkTo>
</li>
</template>
static shouldRender(_outletArgs, helper) {
return helper.siteSettings.ai_sentiment_enabled;
}
}

View File

@ -0,0 +1,38 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import getURL from "discourse-common/lib/get-url";
import discourseComputed from "discourse-common/utils/decorators";
import CustomDateRangeModal from "admin/components/modal/custom-date-range";
import PeriodComputationMixin from "admin/mixins/period-computation";
export default class AdminDashboardSentiment extends Controller.extend(
PeriodComputationMixin
) {
@service modal;
@discourseComputed("startDate", "endDate")
filters(startDate, endDate) {
return { startDate, endDate };
}
_reportsForPeriodURL(period) {
return getURL(`/admin/dashboard/sentiment?period=${period}`);
}
@action
setCustomDateRange(startDate, endDate) {
this.setProperties({ startDate, endDate });
}
@action
openCustomDateRangeModal() {
this.modal.show(CustomDateRangeModal, {
model: {
startDate: this.startDate,
endDate: this.endDate,
setCustomDateRange: this.setCustomDateRange,
},
});
}
}

View File

@ -0,0 +1,39 @@
<div class="sentiment section">
<div class="period-section">
<div class="section-title">
<h2>
{{i18n "discourse_ai.sentiments.dashboard.title"}}
</h2>
<span>
<PeriodChooser
@period={{this.period}}
@action={{action "changePeriod"}}
@content={{this.availablePeriods}}
@fullDay={{false}}
/>
<DButton
@icon="cog"
class="custom-date-range-button"
@action={{this.openCustomDateRangeModal}}
@title="admin.dashboard.custom_date_range"
/>
</span>
</div>
</div>
<div class="section-body">
<div class="charts">
<AdminReport
@dataSourceName="overall_sentiment"
@filters={{this.filters}}
@showHeader={{true}}
/>
<AdminReport
@dataSourceName="post_emotion"
@filters={{this.filters}}
@showHeader={{true}}
/>
</div>
</div>
</div>

View File

@ -0,0 +1,16 @@
.dashboard.dashboard-sentiment {
.sentiment {
margin-bottom: 1em;
}
.navigation-item.sentiment {
border-bottom: 0.4em solid var(--tertiary);
}
.charts {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-column-gap: 1em;
grid-row-gap: 1em;
}
}

View File

@ -0,0 +1,8 @@
.dashboard.dashboard-sentiment .charts {
.overall-sentiment {
grid-column: span 8;
}
.post-emotion {
grid-column: span 4;
}
}

View File

@ -0,0 +1,10 @@
.dashboard.dashboard-sentiment {
.charts {
.overall-sentiment {
grid-column: span 12;
}
.post-emotion {
grid-column: span 12;
}
}
}

View File

@ -92,6 +92,9 @@ en:
gpt-3:
5-turbo: "GPT-3.5"
claude-2: "Claude 2"
sentiments:
dashboard:
title: "Sentiment"
review:
types:

View File

@ -99,6 +99,18 @@ en:
prompt_message_length: The message %{idx} is over the 1000 character limit.
invalid_prompt_role: The message %{idx} has an invalid role.
reports:
overall_sentiment:
title: "Overall sentiment"
description: "The average percentage of positive and negative sentiments in public posts."
xaxis: "Positive(%)"
yaxis: "Date"
post_emotion:
title: "Post emotion"
description: "The average percentage of emotions present in public posts grouped by the poster's trust level."
xaxis:
yaxis:
discourse_ai:
ai_helper:
errors:
@ -172,3 +184,19 @@ en:
configuration_hint:
one: "Configure the `%{setting}` setting first."
other: "Configure these settings first: %{settings}"
sentiment:
reports:
overall_sentiment:
positive: "Positive"
negative: "Negative"
post_emotion:
tl_01: "Trust levels 0-1"
tl_234: "Trust levels 2+"
sadness: "Sadness"
surprise: "Surprise"
neutral: "Neutral"
fear: "Fear"
anger: "Anger"
joy: "Joy"
disgust: "Disgust"

View File

@ -21,4 +21,9 @@ DiscourseAi::Engine.routes.draw do
end
end
Discourse::Application.routes.draw { mount ::DiscourseAi::Engine, at: "discourse-ai" }
Discourse::Application.routes.draw do
mount ::DiscourseAi::Engine, at: "discourse-ai"
get "admin/dashboard/sentiment" => "discourse_ai/admin/dashboard#sentiment",
:constraints => StaffConstraint.new
end

View File

@ -18,6 +18,97 @@ module DiscourseAi
plugin.on(:post_created, &sentiment_analysis_cb)
plugin.on(:post_edited, &sentiment_analysis_cb)
plugin.add_report("overall_sentiment") do |report|
report.modes = [:stacked_chart]
grouped_sentiments =
DB.query(<<~SQL, report_start: report.start_date, report_end: report.end_date)
SELECT
DATE_TRUNC('day', p.created_at)::DATE AS posted_at,
AVG((cr.classification::jsonb->'positive')::integer) AS avg_positive,
-AVG((cr.classification::jsonb->'negative')::integer) AS avg_negative
FROM
classification_results AS cr
INNER JOIN posts p ON p.id = cr.target_id AND cr.target_type = 'Post'
INNER JOIN topics t ON t.id = p.topic_id
INNER JOIN categories c ON c.id = t.category_id
WHERE
t.archetype = 'regular' AND
p.user_id > 0 AND
cr.classification_type = 'sentiment' AND
(p.created_at > :report_start AND p.created_at < :report_end)
GROUP BY DATE_TRUNC('day', p.created_at)
SQL
data_points = %w[positive negative]
report.data =
data_points.map do |point|
{
req: "sentiment_#{point}",
color: point == "positive" ? report.colors[1] : report.colors[3],
label: I18n.t("discourse_ai.sentiment.reports.overall_sentiment.#{point}"),
data:
grouped_sentiments.map do |gs|
{ x: gs.posted_at, y: gs.public_send("avg_#{point}") }
end,
}
end
end
plugin.add_report("post_emotion") do |report|
report.modes = [:radar]
grouped_emotions =
DB.query(<<~SQL, report_start: report.start_date, report_end: report.end_date)
SELECT
u.trust_level AS trust_level,
AVG((cr.classification::jsonb->'sadness')::integer) AS avg_sadness,
AVG((cr.classification::jsonb->'surprise')::integer) AS avg_surprise,
AVG((cr.classification::jsonb->'neutral')::integer) AS avg_neutral,
AVG((cr.classification::jsonb->'fear')::integer) AS avg_fear,
AVG((cr.classification::jsonb->'anger')::integer) AS avg_anger,
AVG((cr.classification::jsonb->'joy')::integer) AS avg_joy,
AVG((cr.classification::jsonb->'disgust')::integer) AS avg_disgust
FROM
classification_results AS cr
INNER JOIN posts p ON p.id = cr.target_id AND cr.target_type = 'Post'
INNER JOIN users u ON p.user_id = u.id
INNER JOIN topics t ON t.id = p.topic_id
INNER JOIN categories c ON c.id = t.category_id
WHERE
t.archetype = 'regular' AND
p.user_id > 0 AND
cr.classification_type = 'emotion' AND
(p.created_at > :report_start AND p.created_at < :report_end)
GROUP BY u.trust_level
SQL
emotions = %w[sadness surprise neutral fear anger joy disgust]
level_groups = [[0, 1], [2, 3, 4]]
report.data =
level_groups.each_with_index.map do |lg, idx|
tl_emotion_avgs = grouped_emotions.select { |ge| lg.include?(ge.trust_level) }
{
req: "emotion_tl_#{lg.join}",
color: report.colors[idx],
label: I18n.t("discourse_ai.sentiment.reports.post_emotion.tl_#{lg.join}"),
data:
emotions.map do |e|
{
x: I18n.t("discourse_ai.sentiment.reports.post_emotion.#{e}"),
y:
tl_emotion_avgs.sum do |tl_emotion_avg|
tl_emotion_avg.public_send("avg_#{e}").to_i
end / tl_emotion_avgs.size,
}
end,
}
end
end
end
end
end

View File

@ -21,6 +21,10 @@ register_asset "stylesheets/modules/ai-bot/common/bot-replies.scss"
register_asset "stylesheets/modules/embeddings/common/semantic-related-topics.scss"
register_asset "stylesheets/modules/embeddings/common/semantic-search.scss"
register_asset "stylesheets/modules/sentiment/common/dashboard.scss"
register_asset "stylesheets/modules/sentiment/desktop/dashboard.scss", :desktop
register_asset "stylesheets/modules/sentiment/mobile/dashboard.scss", :mobile
module ::DiscourseAi
PLUGIN_NAME = "discourse-ai"
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
Fabricator(:classification_result) { target { Fabricate(:post) } }
Fabricator(:sentiment_classification, from: :classification_result) do
classification_type "sentiment"
classification { { negative: 72, neutral: 23, positive: 4 } }
end
Fabricator(:emotion_classification, from: :classification_result) do
classification_type "emotion"
classification { { negative: 72, neutral: 23, positive: 4 } }
end

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true
require "rails_helper"
require_relative "../../../support/sentiment_inference_stubs"
describe DiscourseAi::Sentiment::EntryPoint do
RSpec.describe DiscourseAi::Sentiment::EntryPoint do
fab!(:user) { Fabricate(:user) }
describe "registering event callbacks" do
@ -51,4 +51,76 @@ describe DiscourseAi::Sentiment::EntryPoint do
end
end
end
describe "custom reports" do
before { SiteSetting.ai_sentiment_inference_service_api_endpoint = "http://test.com" }
fab!(:pm) { Fabricate(:private_message_post) }
fab!(:post_1) { Fabricate(:post) }
fab!(:post_2) { Fabricate(:post) }
describe "overall_sentiment report" do
let(:positive_classification) { { negative: 2, neutral: 30, positive: 70 } }
let(:negative_classification) { { negative: 60, neutral: 2, positive: 10 } }
def sentiment_classification(post, classification)
Fabricate(:sentiment_classification, target: post, classification: classification)
end
it "calculate averages using only public posts" do
sentiment_classification(post_1, positive_classification)
sentiment_classification(post_2, negative_classification)
sentiment_classification(pm, positive_classification)
expected_positive =
(positive_classification[:positive] + negative_classification[:positive]) / 2
expected_negative =
-(positive_classification[:negative] + negative_classification[:negative]) / 2
report = Report.find("overall_sentiment")
positive_data_point = report.data[0][:data].first[:y].to_i
negative_data_point = report.data[1][:data].first[:y].to_i
expect(positive_data_point).to eq(expected_positive)
expect(negative_data_point).to eq(expected_negative)
end
end
describe "post_emotion report" do
let(:emotion_1) do
{ sadness: 49, surprise: 23, neutral: 6, fear: 34, anger: 87, joy: 22, disgust: 70 }
end
let(:emotion_2) do
{ sadness: 19, surprise: 63, neutral: 45, fear: 44, anger: 27, joy: 62, disgust: 30 }
end
let(:classification_type) { "emotion" }
def emotion_classification(post, classification)
Fabricate(
:sentiment_classification,
target: post,
classification_type: classification_type,
classification: classification,
)
end
it "calculate averages using only public posts" do
post_1.user.update!(trust_level: TrustLevel[0])
post_2.user.update!(trust_level: TrustLevel[3])
pm.user.update!(trust_level: TrustLevel[0])
emotion_classification(post_1, emotion_1)
emotion_classification(post_2, emotion_2)
emotion_classification(pm, emotion_2)
report = Report.find("post_emotion")
tl_01_point = report.data[0][:data].first
tl_234_point = report.data[1][:data].first
expect(tl_01_point[:y]).to eq(emotion_1[tl_01_point[:x].downcase.to_sym])
expect(tl_234_point[:y]).to eq(emotion_2[tl_234_point[:x].downcase.to_sym])
end
end
end
end