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:
|
gpt-3:
|
||||||
5-turbo: "GPT-3.5"
|
5-turbo: "GPT-3.5"
|
||||||
claude-2: "Claude 2"
|
claude-2: "Claude 2"
|
||||||
|
sentiments:
|
||||||
|
dashboard:
|
||||||
|
title: "Sentiment"
|
||||||
|
|
||||||
review:
|
review:
|
||||||
types:
|
types:
|
||||||
|
|
|
@ -99,6 +99,18 @@ en:
|
||||||
prompt_message_length: The message %{idx} is over the 1000 character limit.
|
prompt_message_length: The message %{idx} is over the 1000 character limit.
|
||||||
invalid_prompt_role: The message %{idx} has an invalid role.
|
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:
|
discourse_ai:
|
||||||
ai_helper:
|
ai_helper:
|
||||||
errors:
|
errors:
|
||||||
|
@ -172,3 +184,19 @@ en:
|
||||||
configuration_hint:
|
configuration_hint:
|
||||||
one: "Configure the `%{setting}` setting first."
|
one: "Configure the `%{setting}` setting first."
|
||||||
other: "Configure these settings first: %{settings}"
|
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
|
||||||
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_created, &sentiment_analysis_cb)
|
||||||
plugin.on(:post_edited, &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
|
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-related-topics.scss"
|
||||||
register_asset "stylesheets/modules/embeddings/common/semantic-search.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
|
module ::DiscourseAi
|
||||||
PLUGIN_NAME = "discourse-ai"
|
PLUGIN_NAME = "discourse-ai"
|
||||||
end
|
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
|
# 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) }
|
fab!(:user) { Fabricate(:user) }
|
||||||
|
|
||||||
describe "registering event callbacks" do
|
describe "registering event callbacks" do
|
||||||
|
@ -51,4 +51,76 @@ describe DiscourseAi::Sentiment::EntryPoint do
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
Loading…
Reference in New Issue