From ecc9c76692bfdeee08d5e16e89c9f34f20f16f16 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Tue, 4 Jun 2019 10:34:07 -0400 Subject: [PATCH] FEATURE: dynamically update the topic heat settings monthly (#7670) The site settings beginning with "topic views heat" and "topic post like heat" are set to defaults when installing Discourse, but there has not been a process or guidance for updating these values based on community activity. This feature will update them once a month. The low, medium, and high settings will be based on the minimums of the 45th, 25th, and 10th percentile topics respectively, so that 45% of topics will have some "heat". Disable automatic changes with the automatic_topic_heat_values setting. --- app/jobs/scheduled/update_heat_settings.rb | 11 ++ app/services/heat_settings_updater.rb | 81 +++++++++++++++ config/locales/server.en.yml | 2 + config/site_settings.yml | 4 +- spec/services/heat_settings_updater_spec.rb | 109 ++++++++++++++++++++ 5 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 app/jobs/scheduled/update_heat_settings.rb create mode 100644 app/services/heat_settings_updater.rb create mode 100644 spec/services/heat_settings_updater_spec.rb diff --git a/app/jobs/scheduled/update_heat_settings.rb b/app/jobs/scheduled/update_heat_settings.rb new file mode 100644 index 00000000000000..9dafbbf118efc8 --- /dev/null +++ b/app/jobs/scheduled/update_heat_settings.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Jobs + class UpdateHeatSettings < Jobs::Scheduled + every 1.month + + def execute(args) + HeatSettingsUpdater.update + end + end +end diff --git a/app/services/heat_settings_updater.rb b/app/services/heat_settings_updater.rb new file mode 100644 index 00000000000000..7d2ece2c053b54 --- /dev/null +++ b/app/services/heat_settings_updater.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class HeatSettingsUpdater + def self.update + return unless SiteSetting.automatic_topic_heat_values + + views_by_percentile = views_thresholds + update_setting(:topic_views_heat_high, views_by_percentile[10]) + update_setting(:topic_views_heat_medium, views_by_percentile[25]) + update_setting(:topic_views_heat_low, views_by_percentile[45]) + + like_ratios_by_percentile = like_ratio_thresholds + update_setting(:topic_post_like_heat_high, like_ratios_by_percentile[10]) + update_setting(:topic_post_like_heat_medium, like_ratios_by_percentile[25]) + update_setting(:topic_post_like_heat_low, like_ratios_by_percentile[45]) + end + + def self.views_thresholds + results = DB.query(<<~SQL) + SELECT ranked.bucket * 5 as percentile, MIN(ranked.views) as views + FROM ( + SELECT NTILE(20) OVER (ORDER BY t.views DESC) AS bucket, t.views + FROM ( + SELECT views + FROM topics + WHERE deleted_at IS NULL + AND archetype <> 'private_message' + AND visible = TRUE + ) t + ) ranked + WHERE bucket <= 9 + GROUP BY bucket + SQL + + results.inject({}) do |h, row| + h[row.percentile] = row.views + h + end + end + + def self.like_ratio_thresholds + results = DB.query(<<~SQL) + SELECT ranked.bucket * 5 as percentile, MIN(ranked.ratio) as like_ratio + FROM ( + SELECT NTILE(20) OVER (ORDER BY t.ratio DESC) AS bucket, t.ratio + FROM ( + SELECT like_count::decimal / posts_count AS ratio + FROM topics + WHERE deleted_at IS NULL + AND archetype <> 'private_message' + AND visible = TRUE + AND posts_count >= 10 + AND like_count > 0 + ORDER BY created_at DESC + LIMIT 1000 + ) t + ) ranked + WHERE bucket <= 9 + GROUP BY bucket + SQL + + results.inject({}) do |h, row| + h[row.percentile] = row.like_ratio + h + end + end + + def self.update_setting(name, new_value) + if new_value.nil? || new_value <= SiteSetting.defaults[name] + if SiteSetting.get(name) != SiteSetting.defaults[name] + SiteSetting.set_and_log(name, SiteSetting.defaults[name]) + end + elsif SiteSetting.get(name) == 0 || + (new_value.to_f / SiteSetting.get(name) - 1.0).abs >= 0.05 + + if SiteSetting.get(name) != new_value + SiteSetting.set_and_log(name, new_value) + end + end + end +end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 61e3e1408038e0..31674a4e22d744 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1715,6 +1715,8 @@ en: title_prettify: "Prevent common title typos and errors, including all caps, lowercase first character, multiple ! and ?, extra . at end, etc." title_remove_extraneous_space: "Remove leading whitespaces in front of the end punctuation." + automatic_topic_heat_values: 'Automatically update the "topic views heat" and "topic post like heat" settings based on site activity.' + topic_views_heat_low: "After this many views, the views field is slightly highlighted." topic_views_heat_medium: "After this many views, the views field is moderately highlighted." topic_views_heat_high: "After this many views, the views field is strongly highlighted." diff --git a/config/site_settings.yml b/config/site_settings.yml index a8091384273b80..19bec8f7c2053e 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1726,6 +1726,8 @@ uncategorized: summary_percent_filter: 20 summary_max_results: 100 + automatic_topic_heat_values: true + # View heat thresholds topic_views_heat_low: client: true @@ -1735,7 +1737,7 @@ uncategorized: default: 2000 topic_views_heat_high: client: true - default: 5000 + default: 3500 # Post/Like heat thresholds topic_post_like_heat_low: diff --git a/spec/services/heat_settings_updater_spec.rb b/spec/services/heat_settings_updater_spec.rb new file mode 100644 index 00000000000000..db32634fcaaeb4 --- /dev/null +++ b/spec/services/heat_settings_updater_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe HeatSettingsUpdater do + describe '#update' do + subject(:update_settings) { HeatSettingsUpdater.update } + + def expect_default_values + [:topic_views_heat, :topic_post_like_heat].each do |prefix| + [:low, :medium, :high].each do |level| + setting_name = "#{prefix}_#{level}" + expect(SiteSetting.get(setting_name)).to eq(SiteSetting.defaults[setting_name]) + end + end + end + + it 'changes nothing on fresh install' do + expect { + update_settings + }.to_not change { UserHistory.count } + expect_default_values + end + + context 'low activity' do + let!(:hottest_topic1) { Fabricate(:topic, views: 3000, posts_count: 10, like_count: 2) } + let!(:hottest_topic2) { Fabricate(:topic, views: 3000, posts_count: 10, like_count: 2) } + let!(:warm_topic1) { Fabricate(:topic, views: 1500, posts_count: 10, like_count: 1) } + let!(:warm_topic2) { Fabricate(:topic, views: 1500, posts_count: 10, like_count: 1) } + let!(:warm_topic3) { Fabricate(:topic, views: 1500, posts_count: 10, like_count: 1) } + let!(:lukewarm_topic1) { Fabricate(:topic, views: 800, posts_count: 10, like_count: 0) } + let!(:lukewarm_topic2) { Fabricate(:topic, views: 800, posts_count: 10, like_count: 0) } + let!(:lukewarm_topic3) { Fabricate(:topic, views: 800, posts_count: 10, like_count: 0) } + let!(:lukewarm_topic4) { Fabricate(:topic, views: 800, posts_count: 10, like_count: 0) } + let!(:cold_topic) { Fabricate(:topic, views: 100, posts_count: 10, like_count: 0) } + + it "doesn't make settings lower than defaults" do + expect { + update_settings + }.to_not change { UserHistory.count } + expect_default_values + end + + it 'can set back down to minimum defaults' do + [:low, :medium, :high].each do |level| + SiteSetting.set("topic_views_heat_#{level}", 20_000) + SiteSetting.set("topic_post_like_heat_#{level}", 5.0) + end + expect { + update_settings + }.to change { UserHistory.count }.by(6) + expect_default_values + end + end + + context 'similar activity' do + let!(:hottest_topic1) { Fabricate(:topic, views: 3530, posts_count: 100, like_count: 201) } + let!(:hottest_topic2) { Fabricate(:topic, views: 3530, posts_count: 100, like_count: 201) } + let!(:warm_topic1) { Fabricate(:topic, views: 2020, posts_count: 100, like_count: 99) } + let!(:warm_topic2) { Fabricate(:topic, views: 2020, posts_count: 100, like_count: 99) } + let!(:warm_topic3) { Fabricate(:topic, views: 2020, posts_count: 100, like_count: 99) } + let!(:lukewarm_topic1) { Fabricate(:topic, views: 1010, posts_count: 100, like_count: 51) } + let!(:lukewarm_topic2) { Fabricate(:topic, views: 1010, posts_count: 100, like_count: 51) } + let!(:lukewarm_topic3) { Fabricate(:topic, views: 1010, posts_count: 100, like_count: 51) } + let!(:lukewarm_topic4) { Fabricate(:topic, views: 1010, posts_count: 100, like_count: 51) } + let!(:cold_topic) { Fabricate(:topic, views: 100, posts_count: 100, like_count: 1) } + + it "doesn't make small changes" do + expect { + update_settings + }.to_not change { UserHistory.count } + expect_default_values + end + end + + context 'increased activity' do + let!(:hottest_topic1) { Fabricate(:topic, views: 10_100, posts_count: 100, like_count: 230) } + let!(:hottest_topic2) { Fabricate(:topic, views: 10_000, posts_count: 100, like_count: 220) } + let!(:warm_topic1) { Fabricate(:topic, views: 4020, posts_count: 100, like_count: 126) } + let!(:warm_topic2) { Fabricate(:topic, views: 4010, posts_count: 100, like_count: 116) } + let!(:warm_topic3) { Fabricate(:topic, views: 4000, posts_count: 100, like_count: 106) } + let!(:lukewarm_topic1) { Fabricate(:topic, views: 2040, posts_count: 100, like_count: 84) } + let!(:lukewarm_topic2) { Fabricate(:topic, views: 2030, posts_count: 100, like_count: 74) } + let!(:lukewarm_topic3) { Fabricate(:topic, views: 2020, posts_count: 100, like_count: 64) } + let!(:lukewarm_topic4) { Fabricate(:topic, views: 2000, posts_count: 100, like_count: 54) } + let!(:cold_topic) { Fabricate(:topic, views: 100, posts_count: 100, like_count: 1) } + + it 'changes settings when difference is significant' do + expect { + update_settings + }.to change { UserHistory.count }.by(6) + expect(SiteSetting.topic_views_heat_high).to eq(10_000) + expect(SiteSetting.topic_views_heat_medium).to eq(4000) + expect(SiteSetting.topic_views_heat_low).to eq(2000) + expect(SiteSetting.topic_post_like_heat_high).to eq(2.2) + expect(SiteSetting.topic_post_like_heat_medium).to eq(1.06) + expect(SiteSetting.topic_post_like_heat_low).to eq(0.54) + end + + it "doesn't change settings when automatic_topic_heat_values is false" do + SiteSetting.automatic_topic_heat_values = false + expect { + update_settings + }.to_not change { UserHistory.count } + expect_default_values + end + end + end +end