mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 20:36:39 +08:00
FEATURE: experiment with hot sort order (#25274)
This introduces a new experimental hot sort ordering. It attempts to float top conversations by first prioritizing a topics with lots of recent activity (likes and users responding) The schedule that updates hot topics is disabled unless the hidden site setting: `experimental_hot_topics` is enabled. You can control "decay" with `hot_topic_gravity` and `recency` with `hot_topics_recent_days` Data is stored in the new `topic_hot_scores` table and you can check it out on the `/hot` route once enabled. --------- Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
This commit is contained in:
parent
1a752148a9
commit
ebd3971533
|
@ -261,6 +261,14 @@ class ListController < ApplicationController
|
||||||
render "list", formats: [:rss]
|
render "list", formats: [:rss]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def hot_feed
|
||||||
|
discourse_expires_in 1.minute
|
||||||
|
|
||||||
|
@topic_list = TopicQuery.new(nil).list_hot
|
||||||
|
|
||||||
|
render "list", formats: [:rss]
|
||||||
|
end
|
||||||
|
|
||||||
def category_feed
|
def category_feed
|
||||||
guardian.ensure_can_see!(@category)
|
guardian.ensure_can_see!(@category)
|
||||||
discourse_expires_in 1.minute
|
discourse_expires_in 1.minute
|
||||||
|
|
11
app/jobs/scheduled/update_topic_hot_scores.rb
Normal file
11
app/jobs/scheduled/update_topic_hot_scores.rb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
class UpdateTopicHotScores < ::Jobs::Scheduled
|
||||||
|
every 10.minutes
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
TopicHotScore.update_scores if SiteSetting.experimental_hot_topics
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
140
app/models/topic_hot_score.rb
Normal file
140
app/models/topic_hot_score.rb
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TopicHotScore < ActiveRecord::Base
|
||||||
|
belongs_to :topic
|
||||||
|
|
||||||
|
DEFAULT_BATCH_SIZE = 1000
|
||||||
|
|
||||||
|
def self.update_scores(max = DEFAULT_BATCH_SIZE)
|
||||||
|
# score is
|
||||||
|
# (total likes - 1) / (age in hours + 2) ^ gravity
|
||||||
|
|
||||||
|
# 1. insert a new record if one does not exist (up to batch size)
|
||||||
|
# 2. update recently created (up to batch size)
|
||||||
|
# 3. update all top scoring topics (up to batch size)
|
||||||
|
|
||||||
|
now = Time.zone.now
|
||||||
|
|
||||||
|
args = {
|
||||||
|
now: now,
|
||||||
|
gravity: SiteSetting.hot_topics_gravity,
|
||||||
|
max: max,
|
||||||
|
private_message: Archetype.private_message,
|
||||||
|
recent_cutoff: now - SiteSetting.hot_topics_recent_days.days,
|
||||||
|
}
|
||||||
|
|
||||||
|
# insert up to BATCH_SIZE records that are missing from table
|
||||||
|
DB.exec(<<~SQL, args)
|
||||||
|
INSERT INTO topic_hot_scores (
|
||||||
|
topic_id,
|
||||||
|
score,
|
||||||
|
recent_likes,
|
||||||
|
recent_posters,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
topics.id,
|
||||||
|
0.0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
:now,
|
||||||
|
:now
|
||||||
|
|
||||||
|
FROM topics
|
||||||
|
LEFT OUTER JOIN topic_hot_scores ON topic_hot_scores.topic_id = topics.id
|
||||||
|
WHERE topic_hot_scores.topic_id IS NULL
|
||||||
|
AND topics.deleted_at IS NULL
|
||||||
|
AND topics.archetype <> :private_message
|
||||||
|
AND topics.created_at <= :now
|
||||||
|
ORDER BY topics.bumped_at desc
|
||||||
|
LIMIT :max
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# update recent counts for batch
|
||||||
|
DB.exec(<<~SQL, args)
|
||||||
|
UPDATE topic_hot_scores thsOrig
|
||||||
|
SET
|
||||||
|
recent_likes = COALESCE(new_values.likes_count, 0),
|
||||||
|
recent_posters = COALESCE(new_values.unique_participants, 0),
|
||||||
|
recent_first_bumped_at = COALESCE(new_values.first_bumped_at, ths.recent_first_bumped_at)
|
||||||
|
FROM
|
||||||
|
topic_hot_scores ths
|
||||||
|
LEFT OUTER JOIN
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
t.id AS topic_id,
|
||||||
|
COUNT(DISTINCT p.user_id) AS unique_participants,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM post_actions pa
|
||||||
|
JOIN posts p2 ON p2.id = pa.post_id
|
||||||
|
WHERE p2.topic_id = t.id
|
||||||
|
AND pa.post_action_type_id = 2 -- action_type for 'like'
|
||||||
|
AND pa.created_at >= :recent_cutoff
|
||||||
|
AND pa.deleted_at IS NULL
|
||||||
|
) AS likes_count,
|
||||||
|
MIN(p.created_at) AS first_bumped_at
|
||||||
|
FROM
|
||||||
|
topics t
|
||||||
|
JOIN
|
||||||
|
posts p ON t.id = p.topic_id
|
||||||
|
WHERE
|
||||||
|
p.created_at >= :recent_cutoff
|
||||||
|
AND t.archetype <> 'private_message'
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
AND t.created_at <= :now
|
||||||
|
AND t.bumped_at >= :recent_cutoff
|
||||||
|
AND p.created_at < :now
|
||||||
|
AND p.created_at >= :recent_cutoff
|
||||||
|
GROUP BY
|
||||||
|
t.id
|
||||||
|
) AS new_values
|
||||||
|
ON ths.topic_id = new_values.topic_id
|
||||||
|
|
||||||
|
WHERE thsOrig.topic_id = ths.topic_id
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# update up to BATCH_SIZE records that are out of date based on age
|
||||||
|
# we need an extra index for this
|
||||||
|
DB.exec(<<~SQL, args)
|
||||||
|
UPDATE topic_hot_scores ths
|
||||||
|
SET score = topics.like_count /
|
||||||
|
(EXTRACT(EPOCH FROM (:now - topics.created_at)) / 3600 + 2) ^ :gravity
|
||||||
|
+
|
||||||
|
CASE WHEN ths.recent_first_bumped_at IS NULL THEN 0 ELSE
|
||||||
|
(ths.recent_likes + ths.recent_posters) /
|
||||||
|
(EXTRACT(EPOCH FROM (:now - recent_first_bumped_at)) / 3600 + 2) ^ :gravity
|
||||||
|
END
|
||||||
|
,
|
||||||
|
updated_at = :now
|
||||||
|
|
||||||
|
FROM topics
|
||||||
|
WHERE topics.id IN (
|
||||||
|
SELECT topic_id FROM topic_hot_scores
|
||||||
|
ORDER BY score DESC, recent_first_bumped_at DESC NULLS LAST
|
||||||
|
LIMIT :max
|
||||||
|
) AND ths.topic_id = topics.id
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: topic_hot_scores
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# topic_id :integer not null
|
||||||
|
# score :float default(0.0), not null
|
||||||
|
# recent_likes :integer default(0), not null
|
||||||
|
# recent_posters :integer default(0), not null
|
||||||
|
# recent_first_bumped_at :datetime
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_topic_hot_scores_on_score_and_topic_id (score,topic_id) UNIQUE
|
||||||
|
# index_topic_hot_scores_on_topic_id (topic_id) UNIQUE
|
||||||
|
#
|
|
@ -2971,6 +2971,7 @@ en:
|
||||||
category: "There are no more %{category} topics."
|
category: "There are no more %{category} topics."
|
||||||
tag: "There are no more %{tag} topics."
|
tag: "There are no more %{tag} topics."
|
||||||
top: "There are no more top topics."
|
top: "There are no more top topics."
|
||||||
|
hot: "There are no more hot topics."
|
||||||
bookmarks: "There are no more bookmarked topics."
|
bookmarks: "There are no more bookmarked topics."
|
||||||
filter: "There are no more topics."
|
filter: "There are no more topics."
|
||||||
|
|
||||||
|
@ -4059,6 +4060,10 @@ en:
|
||||||
title: "Unseen"
|
title: "Unseen"
|
||||||
lower_title: "unseen"
|
lower_title: "unseen"
|
||||||
help: "new topics and topics you are currently watching or tracking with unread posts"
|
help: "new topics and topics you are currently watching or tracking with unread posts"
|
||||||
|
hot:
|
||||||
|
title: "Hot"
|
||||||
|
lower_title: "hot"
|
||||||
|
help: "top recent topics"
|
||||||
new:
|
new:
|
||||||
lower_title_with_count:
|
lower_title_with_count:
|
||||||
one: "%{count} new"
|
one: "%{count} new"
|
||||||
|
|
|
@ -1217,6 +1217,7 @@ Discourse::Application.routes.draw do
|
||||||
|
|
||||||
get "latest.rss" => "list#latest_feed", :format => :rss
|
get "latest.rss" => "list#latest_feed", :format => :rss
|
||||||
get "top.rss" => "list#top_feed", :format => :rss
|
get "top.rss" => "list#top_feed", :format => :rss
|
||||||
|
get "hot.rss" => "list#hot_feed", :format => :rss
|
||||||
|
|
||||||
Discourse.filters.each { |filter| get "#{filter}" => "list##{filter}" }
|
Discourse.filters.each { |filter| get "#{filter}" => "list##{filter}" }
|
||||||
|
|
||||||
|
|
|
@ -3111,3 +3111,12 @@ dashboard:
|
||||||
verbose_user_stat_count_logging:
|
verbose_user_stat_count_logging:
|
||||||
hidden: true
|
hidden: true
|
||||||
default: false
|
default: false
|
||||||
|
experimental_hot_topics:
|
||||||
|
hidden: true
|
||||||
|
default: false
|
||||||
|
hot_topics_gravity:
|
||||||
|
hidden: true
|
||||||
|
default: 1.8
|
||||||
|
hot_topics_recent_days:
|
||||||
|
hidden: true
|
||||||
|
default: 7
|
||||||
|
|
17
db/migrate/20240116043702_create_topic_hot_scores.rb
Normal file
17
db/migrate/20240116043702_create_topic_hot_scores.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateTopicHotScores < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
create_table :topic_hot_scores do |t|
|
||||||
|
t.integer :topic_id, null: false
|
||||||
|
t.float :score, null: false, default: 0.0
|
||||||
|
t.integer :recent_likes, null: false, default: 0
|
||||||
|
t.integer :recent_posters, null: false, default: 0
|
||||||
|
t.datetime :recent_first_bumped_at
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :topic_hot_scores, :topic_id, unique: true
|
||||||
|
add_index :topic_hot_scores, %i[score topic_id], unique: true
|
||||||
|
end
|
||||||
|
end
|
|
@ -313,11 +313,11 @@ module Discourse
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.filters
|
def self.filters
|
||||||
@filters ||= %i[latest unread new unseen top read posted bookmarks]
|
@filters ||= %i[latest unread new unseen top read posted bookmarks hot]
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.anonymous_filters
|
def self.anonymous_filters
|
||||||
@anonymous_filters ||= %i[latest top categories]
|
@anonymous_filters ||= %i[latest top categories hot]
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.top_menu_items
|
def self.top_menu_items
|
||||||
|
|
|
@ -338,6 +338,14 @@ class TopicQuery
|
||||||
create_list(:bookmarks) { |l| l.where("tu.bookmarked") }
|
create_list(:bookmarks) { |l| l.where("tu.bookmarked") }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def list_hot
|
||||||
|
create_list(:hot, unordered: true) do |topics|
|
||||||
|
topics.joins("JOIN topic_hot_scores on topics.id = topic_hot_scores.topic_id").order(
|
||||||
|
"topic_hot_scores.score DESC",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def list_top_for(period)
|
def list_top_for(period)
|
||||||
score_column = TopTopic.score_column_for_period(period)
|
score_column = TopTopic.score_column_for_period(period)
|
||||||
create_list(:top, unordered: true) do |topics|
|
create_list(:top, unordered: true) do |topics|
|
||||||
|
|
58
spec/models/topic_hot_scores_spec.rb
Normal file
58
spec/models/topic_hot_scores_spec.rb
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.describe TopicHotScore do
|
||||||
|
describe ".update_scores" do
|
||||||
|
fab!(:user)
|
||||||
|
fab!(:user2) { Fabricate(:user) }
|
||||||
|
|
||||||
|
it "can correctly update like counts and post counts and account for activity" do
|
||||||
|
freeze_time
|
||||||
|
|
||||||
|
TopicHotScore.create!(topic_id: -1, score: 0.0, recent_likes: 99, recent_posters: 0)
|
||||||
|
|
||||||
|
old_post = Fabricate(:post, created_at: 10.months.ago)
|
||||||
|
topic = old_post.topic
|
||||||
|
|
||||||
|
new_reply = Fabricate(:post, user: user, topic: topic, created_at: 4.hours.ago)
|
||||||
|
newer_reply = Fabricate(:post, user: user2, topic: topic, created_at: 1.hour.ago)
|
||||||
|
Fabricate(:post, user: user2, topic: topic, created_at: 1.minute.ago)
|
||||||
|
|
||||||
|
freeze_time(1.year.ago)
|
||||||
|
PostActionCreator.like(user, old_post)
|
||||||
|
freeze_time(1.year.from_now)
|
||||||
|
|
||||||
|
PostActionCreator.like(user2, new_reply)
|
||||||
|
PostActionCreator.like(user, newer_reply)
|
||||||
|
|
||||||
|
TopicHotScore.update_scores
|
||||||
|
|
||||||
|
hot_scoring = TopicHotScore.find_by(topic_id: topic.id)
|
||||||
|
|
||||||
|
expect(hot_scoring.recent_likes).to eq(2)
|
||||||
|
expect(hot_scoring.recent_posters).to eq(2)
|
||||||
|
expect(hot_scoring.recent_first_bumped_at).to eq_time(new_reply.created_at)
|
||||||
|
expect(hot_scoring.score).to be_within(0.001).of(1.020)
|
||||||
|
|
||||||
|
expect(TopicHotScore.find_by(topic_id: -1).recent_likes).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "can correctly set scores for topics" do
|
||||||
|
freeze_time
|
||||||
|
|
||||||
|
topic1 = Fabricate(:topic, like_count: 3, created_at: 1.hour.ago)
|
||||||
|
topic2 = Fabricate(:topic, like_count: 10, created_at: 3.hour.ago)
|
||||||
|
|
||||||
|
TopicHotScore.update_scores
|
||||||
|
|
||||||
|
expect(TopicHotScore.find_by(topic_id: topic1.id).score).to be_within(0.001).of(0.415)
|
||||||
|
expect(TopicHotScore.find_by(topic_id: topic2.id).score).to be_within(0.001).of(0.551)
|
||||||
|
|
||||||
|
freeze_time(2.hours.from_now)
|
||||||
|
|
||||||
|
TopicHotScore.update_scores
|
||||||
|
|
||||||
|
expect(TopicHotScore.find_by(topic_id: topic1.id).score).to be_within(0.001).of(0.165)
|
||||||
|
expect(TopicHotScore.find_by(topic_id: topic2.id).score).to be_within(0.001).of(0.301)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue
Block a user