FEATURE: User sentiment on profile summary page (#329)

* FEATURE: User sentiment on profile summary page

This introduces a new user stat in a user profile summary page.

It will show either neutral/positive/negative according to the dominant
sentiment in the user last interactions.

The user-stat widget is only rendered for staff.


Co-authored-by: Keegan George <kgeorge13@gmail.com>
This commit is contained in:
Rafael dos Santos Silva 2023-12-04 18:17:43 -03:00 committed by GitHub
parent c8cd38cdda
commit 71c5077228
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 123 additions and 7 deletions

View File

@ -0,0 +1,8 @@
<li class="user-summary-stat-outlet sentiment">
<span class="value" title={{i18n "discourse_ai.sentiments.summary.title"}}>
{{d-icon this.icon}}
</span>
<span class="label">
{{html-safe (i18n "discourse_ai.sentiments.summary.label")}}
</span>
</li>

View File

@ -0,0 +1,26 @@
import Component from "@glimmer/component";
export default class Sentiment extends Component {
static shouldRender(outletArgs, helper) {
return (
helper.siteSettings.ai_sentiment_enabled &&
helper.siteSettings.ai_sentiment_show_sentiment_public_profile &&
outletArgs.model.sentiment &&
helper.currentUser &&
helper.currentUser.staff
);
}
get icon() {
switch (this.args.outletArgs.model.sentiment) {
case "positive":
return "smile";
case "negative":
return "frown";
case "neutral":
return "meh";
default:
return "meh";
}
}
}

View File

@ -129,6 +129,11 @@ en:
sentiments:
dashboard:
title: "Sentiment"
summary:
label: "sentiment"
title: "Experimental AI-powered sentiment analysis of this person's most recent posts."
review:
types:

View File

@ -25,6 +25,7 @@ en:
ai_sentiment_inference_service_api_endpoint: "URL where the API is running for the sentiment module"
ai_sentiment_inference_service_api_key: "API key for the sentiment API"
ai_sentiment_models: "Models to use for inference. Sentiment classifies post on the positive/neutral/negative space. Emotion classifies on the anger/disgust/fear/joy/neutral/sadness/surprise space."
ai_sentiment_show_sentiment_public_profile: "Make a user dominant sentiment visible on their public profile."
ai_nsfw_detection_enabled: "Enable the NSFW module."
ai_nsfw_inference_service_api_endpoint: "URL where the API is running for the NSFW module"

View File

@ -66,6 +66,9 @@ discourse_ai:
choices:
- sentiment
- emotion
ai_sentiment_show_sentiment_public_profile:
default: true
client: true
ai_nsfw_detection_enabled: false
ai_nsfw_inference_service_api_endpoint:

View File

@ -28,7 +28,8 @@ module DiscourseAi
end
def request(target_to_classify)
target_content = content_of(target_to_classify)
target_content =
DiscourseAi::Tokenizer::BertTokenizer.truncate(content_of(target_to_classify), 500)
available_models.reduce({}) do |memo, model|
memo[model] = request_with(model, target_content)

View File

@ -6,9 +6,12 @@ task "ai:sentiment:backfill", [:start_post] => [:environment] do |_, args|
Post
.joins("INNER JOIN topics ON topics.id = posts.topic_id")
.joins(
"LEFT JOIN classification_results ON classification_results.target_id = posts.id AND classification_results.target_type = 'Post'",
)
.joins(<<~SQL)
LEFT JOIN classification_results ON
classification_results.target_id = posts.id AND
classification_results.model_used = 'sentiment' AND
classification_results.target_type = 'Post'
SQL
.where("classification_results.target_id IS NULL")
.where("posts.id >= ?", args[:start_post].to_i || 0)
.where("category_id IN (?)", public_categories)
@ -16,8 +19,12 @@ task "ai:sentiment:backfill", [:start_post] => [:environment] do |_, args|
.order("posts.id ASC")
.find_each do |post|
print "."
DiscourseAi::PostClassificator.new(
DiscourseAi::Sentiment::SentimentClassification.new,
).classify!(post)
begin
DiscourseAi::PostClassificator.new(
DiscourseAi::Sentiment::SentimentClassification.new,
).classify!(post)
rescue => e
puts "Error: #{e.message}"
end
end
end

View File

@ -33,6 +33,10 @@ Rails.autoloaders.main.push_dir(File.join(__dir__, "lib"), namespace: ::Discours
require_relative "lib/engine"
register_svg_icon "smile"
register_svg_icon "frown"
register_svg_icon "meh"
after_initialize do
# do not autoload this cause we may have no namespace
require_relative "discourse_automation/llm_triage"
@ -56,6 +60,67 @@ after_initialize do
ModelAccuracy.adjust_model_accuracy(new_status, reviewable)
end
require_dependency "user_summary"
class ::UserSummary
def sentiment
neutral, positive, negative = DB.query_single(<<~SQL, user_id: @user.id)
WITH last_interactions_classified AS (
SELECT
1 AS total,
CASE WHEN (classification::jsonb->'positive')::integer >= 60 THEN 1 ELSE 0 END AS positive,
CASE WHEN (classification::jsonb->'negative')::integer >= 60 THEN 1 ELSE 0 END AS negative
FROM
classification_results AS cr
INNER JOIN
posts AS p ON
p.id = cr.target_id AND
cr.target_type = 'Post'
INNER JOIN topics AS t ON
t.id = p.topic_id
INNER JOIN categories AS c ON
c.id = t.category_id
WHERE
model_used = 'sentiment' AND
p.user_id = :user_id
ORDER BY
p.created_at DESC
LIMIT
100
)
SELECT
SUM(total) - SUM(positive) - SUM(negative) AS neutral,
SUM(positive) AS positive,
SUM(negative) AS negative
FROM
last_interactions_classified
SQL
neutral = neutral || 0
positive = positive || 0
negative = negative || 0
return nil if neutral + positive + negative < 5
case [neutral / 5, positive, negative].max
when positive
:positive
when negative
:negative
else
:neutral
end
end
end
require_dependency "user_summary_serializer"
class ::UserSummarySerializer
attributes :sentiment
def sentiment
object.sentiment.to_s
end
end
if Rails.env.test?
require_relative "spec/support/openai_completions_inference_stubs"
require_relative "spec/support/anthropic_completion_stubs"