From 71c5077228c8caa98191ee590ec7a59f54ade893 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Mon, 4 Dec 2023 18:17:43 -0300 Subject: [PATCH] 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 --- .../user-summary-stat/sentiment.hbs | 8 +++ .../connectors/user-summary-stat/sentiment.js | 26 ++++++++ config/locales/client.en.yml | 5 ++ config/locales/server.en.yml | 1 + config/settings.yml | 3 + lib/sentiment/sentiment_classification.rb | 3 +- lib/tasks/modules/sentiment/backfill.rake | 19 ++++-- plugin.rb | 65 +++++++++++++++++++ 8 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 assets/javascripts/discourse/connectors/user-summary-stat/sentiment.hbs create mode 100644 assets/javascripts/discourse/connectors/user-summary-stat/sentiment.js diff --git a/assets/javascripts/discourse/connectors/user-summary-stat/sentiment.hbs b/assets/javascripts/discourse/connectors/user-summary-stat/sentiment.hbs new file mode 100644 index 00000000..b5fff1fb --- /dev/null +++ b/assets/javascripts/discourse/connectors/user-summary-stat/sentiment.hbs @@ -0,0 +1,8 @@ +
  • + + {{d-icon this.icon}} + + + {{html-safe (i18n "discourse_ai.sentiments.summary.label")}} + +
  • \ No newline at end of file diff --git a/assets/javascripts/discourse/connectors/user-summary-stat/sentiment.js b/assets/javascripts/discourse/connectors/user-summary-stat/sentiment.js new file mode 100644 index 00000000..0a3c6fa3 --- /dev/null +++ b/assets/javascripts/discourse/connectors/user-summary-stat/sentiment.js @@ -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"; + } + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index b199e608..1a4be8fe 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 8216e1e5..cfd20662 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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" diff --git a/config/settings.yml b/config/settings.yml index 00e33c2e..0257eb6a 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -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: diff --git a/lib/sentiment/sentiment_classification.rb b/lib/sentiment/sentiment_classification.rb index 00993d01..6c4c8ef3 100644 --- a/lib/sentiment/sentiment_classification.rb +++ b/lib/sentiment/sentiment_classification.rb @@ -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) diff --git a/lib/tasks/modules/sentiment/backfill.rake b/lib/tasks/modules/sentiment/backfill.rake index 0e14bf52..5942728a 100644 --- a/lib/tasks/modules/sentiment/backfill.rake +++ b/lib/tasks/modules/sentiment/backfill.rake @@ -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 diff --git a/plugin.rb b/plugin.rb index 2dd299ee..79996d0e 100644 --- a/plugin.rb +++ b/plugin.rb @@ -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"