FEATURE: dynamically update the topic heat settings monthly (#7670)

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.

diff --git a/app/jobs/scheduled/update_heat_settings.rb b/app/jobs/scheduled/update_heat_settings.rb
new file mode 100644
index 0000000..9dafbbf
--- /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 0000000..7d2ece2
--- /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 61e3e14..31674a4 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 a809138..19bec8f 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 0000000..db32634
--- /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) }

[... diff too long, it was truncated ...]

GitHub sha: ecc9c766

1 Like