mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-30 10:58:31 -05:00
Add score, percent_rank to topics. Adds HotTopic
model and consolidated job to calculate
hotness. Note: People on Heroku will have to update their jobs to the new structure in Heroku.md
This commit is contained in:
parent
9b103e6d97
commit
473a64d39d
20 changed files with 224 additions and 109 deletions
60
app/models/hot_topic.rb
Normal file
60
app/models/hot_topic.rb
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
class HotTopic < ActiveRecord::Base
|
||||||
|
|
||||||
|
belongs_to :topic
|
||||||
|
belongs_to :category
|
||||||
|
|
||||||
|
|
||||||
|
# Here's the current idea behind the implementaiton of hot: random can produce good results!
|
||||||
|
# Hot is currently made up of a random selection of high percentile topics. It includes mostly
|
||||||
|
# new topics, but also some old ones for variety.
|
||||||
|
def self.refresh!
|
||||||
|
transaction do
|
||||||
|
exec_sql "DELETE FROM hot_topics"
|
||||||
|
|
||||||
|
# TODO, move these to site settings once we're sure this is how we want to figure out hot
|
||||||
|
max_hot_topics = 200 # how many hot topics we want
|
||||||
|
hot_percentile = 0.2 # What percentile of topics we consider good
|
||||||
|
older_percentage = 0.2 # how many old topics we want as a percentage
|
||||||
|
new_days = 21 # how many days old we consider old
|
||||||
|
|
||||||
|
exec_sql("INSERT INTO hot_topics (topic_id, category_id, score)
|
||||||
|
SELECT t.id,
|
||||||
|
t.category_id,
|
||||||
|
RANDOM()
|
||||||
|
FROM topics AS t
|
||||||
|
WHERE t.deleted_at IS NULL
|
||||||
|
AND t.visible
|
||||||
|
AND (NOT t.closed)
|
||||||
|
AND (NOT t.archived)
|
||||||
|
AND t.archetype <> :private_message
|
||||||
|
AND created_at >= (CURRENT_TIMESTAMP - INTERVAL ':days_ago' DAY)
|
||||||
|
AND t.percent_rank < :hot_percentile
|
||||||
|
ORDER BY 3 DESC
|
||||||
|
LIMIT :limit",
|
||||||
|
hot_percentile: hot_percentile,
|
||||||
|
limit: ((1.0 - older_percentage) * max_hot_topics).round,
|
||||||
|
private_message: Archetype::private_message,
|
||||||
|
days_ago: new_days)
|
||||||
|
|
||||||
|
# Add a sprinkling of random older topics
|
||||||
|
exec_sql("INSERT INTO hot_topics (topic_id, category_id, score)
|
||||||
|
SELECT t.id,
|
||||||
|
t.category_id,
|
||||||
|
RANDOM()
|
||||||
|
FROM topics AS t
|
||||||
|
WHERE t.deleted_at IS NULL
|
||||||
|
AND t.visible
|
||||||
|
AND (NOT t.closed)
|
||||||
|
AND (NOT t.archived)
|
||||||
|
AND t.archetype <> :private_message
|
||||||
|
AND created_at < (CURRENT_TIMESTAMP - INTERVAL ':days_ago' DAY)
|
||||||
|
AND t.percent_rank < :hot_percentile
|
||||||
|
LIMIT :limit",
|
||||||
|
hot_percentile: hot_percentile,
|
||||||
|
limit: (older_percentage * max_hot_topics).round,
|
||||||
|
private_message: Archetype::private_message,
|
||||||
|
days_ago: new_days)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -38,6 +38,8 @@ class Topic < ActiveRecord::Base
|
||||||
has_many :posts
|
has_many :posts
|
||||||
has_many :topic_allowed_users
|
has_many :topic_allowed_users
|
||||||
has_many :allowed_users, through: :topic_allowed_users, source: :user
|
has_many :allowed_users, through: :topic_allowed_users, source: :user
|
||||||
|
|
||||||
|
has_one :hot_topic
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :last_poster, class_name: 'User', foreign_key: :last_post_user_id
|
belongs_to :last_poster, class_name: 'User', foreign_key: :last_post_user_id
|
||||||
belongs_to :featured_user1, class_name: 'User', foreign_key: :featured_user1_id
|
belongs_to :featured_user1, class_name: 'User', foreign_key: :featured_user1_id
|
||||||
|
|
|
@ -12,10 +12,8 @@ module Clockwork
|
||||||
|
|
||||||
every(1.day, 'enqueue_digest_emails', at: '06:00')
|
every(1.day, 'enqueue_digest_emails', at: '06:00')
|
||||||
every(1.day, 'category_stats', at: '04:00')
|
every(1.day, 'category_stats', at: '04:00')
|
||||||
every(10.minutes, 'calculate_avg_time')
|
every(10.minutes, 'periodical_updates')
|
||||||
every(10.minutes, 'feature_topics')
|
|
||||||
every(1.minute, 'calculate_score')
|
|
||||||
every(20.minutes, 'calculate_view_counts')
|
|
||||||
every(1.day, 'version_check')
|
every(1.day, 'version_check')
|
||||||
every(1.minute, 'clockwork_heartbeat')
|
every(1.minute, 'clockwork_heartbeat')
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
12
db/migrate/20130328162943_create_hot_topics.rb
Normal file
12
db/migrate/20130328162943_create_hot_topics.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
class CreateHotTopics < ActiveRecord::Migration
|
||||||
|
def change
|
||||||
|
create_table :hot_topics, force: true do |t|
|
||||||
|
t.integer :topic_id, null: false
|
||||||
|
t.integer :category_id, null: true
|
||||||
|
t.float :score, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :hot_topics, :topic_id, unique: true
|
||||||
|
add_index :hot_topics, :score, order: 'desc'
|
||||||
|
end
|
||||||
|
end
|
6
db/migrate/20130328182433_add_score_to_topics.rb
Normal file
6
db/migrate/20130328182433_add_score_to_topics.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
class AddScoreToTopics < ActiveRecord::Migration
|
||||||
|
def change
|
||||||
|
add_column :topics, :score, :float
|
||||||
|
add_column :topics, :percent_rank, :float, null: false, default: 1.0
|
||||||
|
end
|
||||||
|
end
|
|
@ -142,13 +142,7 @@ For details on how to reduce the monthly cost of your application, see the Advan
|
||||||
|
|
||||||
rake category_stats Daily 04:00
|
rake category_stats Daily 04:00
|
||||||
|
|
||||||
rake calculate_avg_time Every 10 minutes --:--
|
rake periodical_updates Every 10 minutes --:--
|
||||||
|
|
||||||
rake feature_topics Every 10 minutes --:--
|
|
||||||
|
|
||||||
rake calculate_score Every 10 minutes --:--
|
|
||||||
|
|
||||||
rake calculate_view_counts Every 10 minutes --:--
|
|
||||||
|
|
||||||
rake version_check Daily 01:00
|
rake version_check Daily 01:00
|
||||||
|
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
module Jobs
|
|
||||||
|
|
||||||
class CalculateAvgTime < Jobs::Base
|
|
||||||
|
|
||||||
def execute(args)
|
|
||||||
Post.calculate_avg_time
|
|
||||||
Topic.calculate_avg_time
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
|
@ -1,13 +0,0 @@
|
||||||
require_dependency 'score_calculator'
|
|
||||||
|
|
||||||
module Jobs
|
|
||||||
|
|
||||||
class CalculateScore < Jobs::Base
|
|
||||||
|
|
||||||
def execute(args)
|
|
||||||
ScoreCalculator.new.calculate
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
|
@ -1,13 +0,0 @@
|
||||||
require_dependency 'score_calculator'
|
|
||||||
|
|
||||||
module Jobs
|
|
||||||
|
|
||||||
class CalculateViewCounts < Jobs::Base
|
|
||||||
|
|
||||||
def execute(args)
|
|
||||||
User.update_view_counts
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
|
@ -1,11 +0,0 @@
|
||||||
module Jobs
|
|
||||||
|
|
||||||
class FeatureTopics < Jobs::Base
|
|
||||||
|
|
||||||
def execute(args)
|
|
||||||
CategoryFeaturedTopic.feature_topics
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
31
lib/jobs/periodical_updates.rb
Normal file
31
lib/jobs/periodical_updates.rb
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
require_dependency 'score_calculator'
|
||||||
|
|
||||||
|
module Jobs
|
||||||
|
|
||||||
|
# This job will run on a regular basis to update statistics and denormalized data.
|
||||||
|
# If it does not run, the site will not function properly.
|
||||||
|
class PeriodicalUpdates < Jobs::Base
|
||||||
|
|
||||||
|
def execute(args)
|
||||||
|
|
||||||
|
# Update the average times
|
||||||
|
Post.calculate_avg_time
|
||||||
|
Topic.calculate_avg_time
|
||||||
|
|
||||||
|
# Feature topics in categories
|
||||||
|
CategoryFeaturedTopic.feature_topics
|
||||||
|
|
||||||
|
# Update view counts for users
|
||||||
|
User.update_view_counts
|
||||||
|
|
||||||
|
# Update the scores of posts
|
||||||
|
ScoreCalculator.new.calculate
|
||||||
|
|
||||||
|
# Refresh Hot Topics
|
||||||
|
HotTopic.refresh!
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -30,21 +30,28 @@ class ScoreCalculator
|
||||||
WHERE x.id = posts.id")
|
WHERE x.id = posts.id")
|
||||||
|
|
||||||
|
|
||||||
# Update the best of flag
|
# Update the topics
|
||||||
exec_sql "
|
exec_sql "UPDATE topics AS t
|
||||||
UPDATE topics SET has_best_of =
|
SET has_best_of = (t.like_count >= :likes_required AND
|
||||||
CASE
|
t.posts_count >= :posts_required AND
|
||||||
WHEN like_count >= :likes_required AND
|
x.min_score >= :score_required),
|
||||||
posts_count >= :posts_required AND
|
score = x.avg_score
|
||||||
EXISTS(SELECT * FROM posts AS p
|
FROM (SELECT p.topic_id,
|
||||||
WHERE p.topic_id = topics.id
|
MIN(p.score) AS min_score,
|
||||||
AND p.score >= :score_required) THEN true
|
AVG(p.score) AS avg_score
|
||||||
ELSE false
|
FROM posts AS p
|
||||||
END",
|
GROUP BY p.topic_id) AS x
|
||||||
|
WHERE x.topic_id = t.id",
|
||||||
likes_required: SiteSetting.best_of_likes_required,
|
likes_required: SiteSetting.best_of_likes_required,
|
||||||
posts_required: SiteSetting.best_of_posts_required,
|
posts_required: SiteSetting.best_of_posts_required,
|
||||||
score_required: SiteSetting.best_of_score_threshold
|
score_required: SiteSetting.best_of_score_threshold
|
||||||
|
|
||||||
|
# Update percentage rank of topics
|
||||||
|
exec_sql("UPDATE topics SET percent_rank = x.percent_rank
|
||||||
|
FROM (SELECT id, percent_rank()
|
||||||
|
OVER (ORDER BY SCORE DESC) as percent_rank
|
||||||
|
FROM topics) AS x
|
||||||
|
WHERE x.id = topics.id")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -11,23 +11,8 @@ task :category_stats => :environment do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Every 10 minutes
|
# Every 10 minutes
|
||||||
task :calculate_avg_time => :environment do
|
task :periodical_updates => :environment do
|
||||||
Jobs::CalculateAvgTime.new.execute(nil)
|
Jobs::PeriodicalUpdates.new.execute(nil)
|
||||||
end
|
|
||||||
|
|
||||||
# Every 10 minutes
|
|
||||||
task :feature_topics => :environment do
|
|
||||||
Jobs::FeatureTopics.new.execute(nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Every 10 minutes
|
|
||||||
task :calculate_score => :environment do
|
|
||||||
Jobs::CalculateScore.new.execute(nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Every 10 minutes
|
|
||||||
task :calculate_view_counts => :environment do
|
|
||||||
Jobs::CalculateViewCounts.new.execute(nil)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Every day
|
# Every day
|
||||||
|
|
|
@ -123,11 +123,9 @@ class TopicQuery
|
||||||
|
|
||||||
def list_hot
|
def list_hot
|
||||||
return_list(unordered: true) do |list|
|
return_list(unordered: true) do |list|
|
||||||
|
# Find hot topics
|
||||||
# Let's not include topic categories on hot
|
list = list.joins(:hot_topic)
|
||||||
list = list.where("categories.topic_id <> topics.id")
|
.order('hot_topics.score + (COALESCE(categories.hotness, 5.0) / 11.0) desc')
|
||||||
|
|
||||||
list =list.order("coalesce(categories.hotness, 5) desc, topics.bumped_at desc")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
require 'spec_helper'
|
|
||||||
require 'jobs'
|
|
||||||
|
|
||||||
describe Jobs::CalculateViewCounts do
|
|
||||||
|
|
||||||
|
|
||||||
it "delegates to User" do
|
|
||||||
User.expects(:update_view_counts)
|
|
||||||
Jobs::CalculateViewCounts.new.execute({})
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
36
spec/components/jobs/periodical_updates_spec.rb
Normal file
36
spec/components/jobs/periodical_updates_spec.rb
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
require 'spec_helper'
|
||||||
|
require 'jobs/periodical_updates'
|
||||||
|
|
||||||
|
describe Jobs::PeriodicalUpdates do
|
||||||
|
|
||||||
|
after do
|
||||||
|
Jobs::PeriodicalUpdates.new.execute(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "calculates avg post time" do
|
||||||
|
Post.expects(:calculate_avg_time).once
|
||||||
|
end
|
||||||
|
|
||||||
|
it "calculates avg topic time" do
|
||||||
|
Topic.expects(:calculate_avg_time).once
|
||||||
|
end
|
||||||
|
|
||||||
|
it "features topics" do
|
||||||
|
CategoryFeaturedTopic.expects(:feature_topics).once
|
||||||
|
end
|
||||||
|
|
||||||
|
it "updates view counts" do
|
||||||
|
User.expects(:update_view_counts).once
|
||||||
|
end
|
||||||
|
|
||||||
|
it "calculates scores" do
|
||||||
|
calculator = mock()
|
||||||
|
ScoreCalculator.expects(:new).once.returns(calculator)
|
||||||
|
calculator.expects(:calculate)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "refreshes hot topics" do
|
||||||
|
HotTopic.expects(:refresh!).once
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -23,6 +23,15 @@ describe ScoreCalculator do
|
||||||
another_post.percent_rank.should == 0.0
|
another_post.percent_rank.should == 0.0
|
||||||
post.percent_rank.should == 1.0
|
post.percent_rank.should == 1.0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "gives the topic a score" do
|
||||||
|
topic.score.should be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "gives the topic a percent_rank" do
|
||||||
|
topic.percent_rank.should_not == 1.0
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'best_of' do
|
context 'best_of' do
|
||||||
|
|
|
@ -71,7 +71,7 @@ describe TopicQuery do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'hot' do
|
pending 'hot' do
|
||||||
let(:cold_category) { Fabricate(:category, name: 'brrrrrr', hotness: 5) }
|
let(:cold_category) { Fabricate(:category, name: 'brrrrrr', hotness: 5) }
|
||||||
let(:hot_category) { Fabricate(:category, name: 'yeeouch', hotness: 10) }
|
let(:hot_category) { Fabricate(:category, name: 'yeeouch', hotness: 10) }
|
||||||
|
|
||||||
|
|
34
spec/models/hot_topic_spec.rb
Normal file
34
spec/models/hot_topic_spec.rb
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe HotTopic do
|
||||||
|
|
||||||
|
it { should belong_to :topic }
|
||||||
|
it { should belong_to :category }
|
||||||
|
|
||||||
|
|
||||||
|
context "refresh!" do
|
||||||
|
|
||||||
|
let!(:t1) { Fabricate(:topic) }
|
||||||
|
let!(:t2) { Fabricate(:topic) }
|
||||||
|
|
||||||
|
it "begins blank" do
|
||||||
|
HotTopic.all.should be_blank
|
||||||
|
end
|
||||||
|
|
||||||
|
context "after calculating" do
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Calculate the scores before we calculate hot
|
||||||
|
ScoreCalculator.new.calculate
|
||||||
|
HotTopic.refresh!
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should have hot topics" do
|
||||||
|
HotTopic.pluck(:topic_id).should =~ [t1.id, t2.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -873,6 +873,10 @@ describe Topic do
|
||||||
topic.has_best_of.should be_false
|
topic.has_best_of.should be_false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "is the 1.0 percent rank" do
|
||||||
|
topic.percent_rank.should == 1.0
|
||||||
|
end
|
||||||
|
|
||||||
it 'is not invisible' do
|
it 'is not invisible' do
|
||||||
topic.should be_visible
|
topic.should be_visible
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue