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:
parent
30821badf2
commit
b172ef11c4
|
@ -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
|
|
@ -0,0 +1,10 @@
|
|||
export default {
|
||||
resource: "admin.dashboard",
|
||||
path: "/dashboard",
|
||||
map() {
|
||||
this.route("admin.dashboardSentiment", {
|
||||
path: "/dashboard/sentiment",
|
||||
resetNamespace: true,
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
.dashboard.dashboard-sentiment .charts {
|
||||
.overall-sentiment {
|
||||
grid-column: span 8;
|
||||
}
|
||||
.post-emotion {
|
||||
grid-column: span 4;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
.dashboard.dashboard-sentiment {
|
||||
.charts {
|
||||
.overall-sentiment {
|
||||
grid-column: span 12;
|
||||
}
|
||||
.post-emotion {
|
||||
grid-column: span 12;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -92,6 +92,9 @@ en:
|
|||
gpt-3:
|
||||
5-turbo: "GPT-3.5"
|
||||
claude-2: "Claude 2"
|
||||
sentiments:
|
||||
dashboard:
|
||||
title: "Sentiment"
|
||||
|
||||
review:
|
||||
types:
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue