Change algorithm of follow recommendations (#28314)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>th-downstream
parent
b7bdcd4f39
commit
b5ac61b2c5
@ -1,31 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class PotentialFriendshipTracker
|
|
||||||
EXPIRE_AFTER = 90.days.seconds
|
|
||||||
MAX_ITEMS = 80
|
|
||||||
|
|
||||||
WEIGHTS = {
|
|
||||||
reply: 1,
|
|
||||||
favourite: 10,
|
|
||||||
reblog: 20,
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
class << self
|
|
||||||
include Redisable
|
|
||||||
|
|
||||||
def record(account_id, target_account_id, action)
|
|
||||||
return if account_id == target_account_id
|
|
||||||
|
|
||||||
key = "interactions:#{account_id}"
|
|
||||||
weight = WEIGHTS[action]
|
|
||||||
|
|
||||||
redis.zincrby(key, weight, target_account_id)
|
|
||||||
redis.zremrangebyrank(key, 0, -MAX_ITEMS)
|
|
||||||
redis.expire(key, EXPIRE_AFTER)
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove(account_id, target_account_id)
|
|
||||||
redis.zrem("interactions:#{account_id}", target_account_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
@ -1,28 +1,48 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class AccountSuggestions
|
class AccountSuggestions
|
||||||
|
include DatabaseHelper
|
||||||
|
|
||||||
SOURCES = [
|
SOURCES = [
|
||||||
AccountSuggestions::SettingSource,
|
AccountSuggestions::SettingSource,
|
||||||
AccountSuggestions::PastInteractionsSource,
|
AccountSuggestions::FriendsOfFriendsSource,
|
||||||
|
AccountSuggestions::SimilarProfilesSource,
|
||||||
AccountSuggestions::GlobalSource,
|
AccountSuggestions::GlobalSource,
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
def self.get(account, limit)
|
BATCH_SIZE = 40
|
||||||
SOURCES.each_with_object([]) do |source_class, suggestions|
|
|
||||||
source_suggestions = source_class.new.get(
|
|
||||||
account,
|
|
||||||
skip_account_ids: suggestions.map(&:account_id),
|
|
||||||
limit: limit - suggestions.size
|
|
||||||
)
|
|
||||||
|
|
||||||
suggestions.concat(source_suggestions)
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get(limit, offset = 0)
|
||||||
|
with_read_replica do
|
||||||
|
account_ids_with_sources = Rails.cache.fetch("follow_recommendations/#{@account.id}", expires_in: 15.minutes) do
|
||||||
|
SOURCES.flat_map { |klass| klass.new.get(@account, limit: BATCH_SIZE) }.each_with_object({}) do |(account_id, source), h|
|
||||||
|
(h[account_id] ||= []).concat(Array(source).map(&:to_sym))
|
||||||
|
end.to_a.shuffle
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.remove(account, target_account_id)
|
# The sources deliver accounts that haven't yet been followed, are not blocked,
|
||||||
SOURCES.each do |source_class|
|
# and so on. Since we reset the cache on follows, blocks, and so on, we don't need
|
||||||
source = source_class.new
|
# a complicated query on this end.
|
||||||
source.remove(account, target_account_id)
|
|
||||||
|
account_ids = account_ids_with_sources[offset, limit]
|
||||||
|
accounts_map = Account.where(id: account_ids.map(&:first)).includes(:account_stat).index_by(&:id)
|
||||||
|
|
||||||
|
account_ids.filter_map do |(account_id, source)|
|
||||||
|
next unless accounts_map.key?(account_id)
|
||||||
|
|
||||||
|
AccountSuggestions::Suggestion.new(
|
||||||
|
account: accounts_map[account_id],
|
||||||
|
source: source
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def remove(target_account_id)
|
||||||
|
FollowRecommendationMute.create(account_id: @account.id, target_account_id: target_account_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AccountSuggestions::FriendsOfFriendsSource < AccountSuggestions::Source
|
||||||
|
def get(account, limit: 10)
|
||||||
|
Account.find_by_sql([<<~SQL.squish, { id: account.id, limit: limit }]).map { |row| [row.id, key] }
|
||||||
|
WITH first_degree AS (
|
||||||
|
SELECT target_account_id
|
||||||
|
FROM follows
|
||||||
|
JOIN accounts AS target_accounts ON follows.target_account_id = target_accounts.id
|
||||||
|
WHERE account_id = :id
|
||||||
|
AND NOT target_accounts.hide_collections
|
||||||
|
)
|
||||||
|
SELECT accounts.id, COUNT(*) AS frequency
|
||||||
|
FROM accounts
|
||||||
|
JOIN follows ON follows.target_account_id = accounts.id
|
||||||
|
JOIN account_stats ON account_stats.account_id = accounts.id
|
||||||
|
LEFT OUTER JOIN follow_recommendation_mutes ON follow_recommendation_mutes.target_account_id = accounts.id AND follow_recommendation_mutes.account_id = :id
|
||||||
|
WHERE follows.account_id IN (SELECT * FROM first_degree)
|
||||||
|
AND follows.target_account_id NOT IN (SELECT * FROM first_degree)
|
||||||
|
AND follows.target_account_id <> :id
|
||||||
|
AND accounts.discoverable
|
||||||
|
AND accounts.suspended_at IS NULL
|
||||||
|
AND accounts.silenced_at IS NULL
|
||||||
|
AND accounts.moved_to_account_id IS NULL
|
||||||
|
AND follow_recommendation_mutes.target_account_id IS NULL
|
||||||
|
GROUP BY accounts.id, account_stats.id
|
||||||
|
ORDER BY frequency DESC, account_stats.followers_count ASC
|
||||||
|
LIMIT :limit
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def key
|
||||||
|
:friends_of_friends
|
||||||
|
end
|
||||||
|
end
|
@ -1,39 +1,13 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class AccountSuggestions::GlobalSource < AccountSuggestions::Source
|
class AccountSuggestions::GlobalSource < AccountSuggestions::Source
|
||||||
include Redisable
|
def get(account, limit: 10)
|
||||||
|
FollowRecommendation.localized(content_locale).joins(:account).merge(base_account_scope(account)).order(rank: :desc).limit(limit).pluck(:account_id, :reason)
|
||||||
def key
|
|
||||||
:global
|
|
||||||
end
|
|
||||||
|
|
||||||
def get(account, skip_account_ids: [], limit: 40)
|
|
||||||
account_ids = account_ids_for_locale(I18n.locale.to_s.split(/[_-]/).first) - [account.id] - skip_account_ids
|
|
||||||
|
|
||||||
as_ordered_suggestions(
|
|
||||||
scope(account).where(id: account_ids),
|
|
||||||
account_ids
|
|
||||||
).take(limit)
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove(_account, _target_account_id)
|
|
||||||
nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def scope(account)
|
def content_locale
|
||||||
Account.searchable
|
I18n.locale.to_s.split(/[_-]/).first
|
||||||
.followable_by(account)
|
|
||||||
.not_excluded_by_account(account)
|
|
||||||
.not_domain_blocked_by_account(account)
|
|
||||||
end
|
|
||||||
|
|
||||||
def account_ids_for_locale(locale)
|
|
||||||
redis.zrevrange("follow_recommendations:#{locale}", 0, -1).map(&:to_i)
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_ordered_list_key(account)
|
|
||||||
account.id
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class AccountSuggestions::PastInteractionsSource < AccountSuggestions::Source
|
|
||||||
include Redisable
|
|
||||||
|
|
||||||
def key
|
|
||||||
:past_interactions
|
|
||||||
end
|
|
||||||
|
|
||||||
def get(account, skip_account_ids: [], limit: 40)
|
|
||||||
account_ids = account_ids_for_account(account.id, limit + skip_account_ids.size) - skip_account_ids
|
|
||||||
|
|
||||||
as_ordered_suggestions(
|
|
||||||
scope.where(id: account_ids),
|
|
||||||
account_ids
|
|
||||||
).take(limit)
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove(account, target_account_id)
|
|
||||||
redis.zrem("interactions:#{account.id}", target_account_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def scope
|
|
||||||
Account.searchable
|
|
||||||
end
|
|
||||||
|
|
||||||
def account_ids_for_account(account_id, limit)
|
|
||||||
redis.zrevrange("interactions:#{account_id}", 0, limit).map(&:to_i)
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_ordered_list_key(account)
|
|
||||||
account.id
|
|
||||||
end
|
|
||||||
end
|
|
@ -0,0 +1,67 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AccountSuggestions::SimilarProfilesSource < AccountSuggestions::Source
|
||||||
|
class QueryBuilder < AccountSearchService::QueryBuilder
|
||||||
|
def must_clauses
|
||||||
|
[
|
||||||
|
{
|
||||||
|
more_like_this: {
|
||||||
|
fields: %w(text text.stemmed),
|
||||||
|
like: @query.map { |id| { _index: 'accounts', _id: id } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
term: {
|
||||||
|
properties: 'discoverable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def must_not_clauses
|
||||||
|
[
|
||||||
|
{
|
||||||
|
terms: {
|
||||||
|
id: following_ids,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
term: {
|
||||||
|
properties: 'bot',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def should_clauses
|
||||||
|
{
|
||||||
|
term: {
|
||||||
|
properties: {
|
||||||
|
value: 'verified',
|
||||||
|
boost: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get(account, limit: 10)
|
||||||
|
recently_followed_account_ids = account.active_relationships.recent.limit(5).pluck(:target_account_id)
|
||||||
|
|
||||||
|
if Chewy.enabled? && !recently_followed_account_ids.empty?
|
||||||
|
QueryBuilder.new(recently_followed_account_ids, account).build.limit(limit).hits.pluck('_id').map(&:to_i).zip([key].cycle)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
rescue Faraday::ConnectionFailed
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def key
|
||||||
|
:similar_to_recently_followed
|
||||||
|
end
|
||||||
|
end
|
@ -1,34 +1,18 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class AccountSuggestions::Source
|
class AccountSuggestions::Source
|
||||||
def key
|
|
||||||
raise NotImplementedError
|
|
||||||
end
|
|
||||||
|
|
||||||
def get(_account, **kwargs)
|
def get(_account, **kwargs)
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
end
|
end
|
||||||
|
|
||||||
def remove(_account, target_account_id)
|
|
||||||
raise NotImplementedError
|
|
||||||
end
|
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def as_ordered_suggestions(scope, ordered_list)
|
def base_account_scope(account)
|
||||||
return [] if ordered_list.empty?
|
Account.searchable
|
||||||
|
.followable_by(account)
|
||||||
map = scope.index_by { |account| to_ordered_list_key(account) }
|
.not_excluded_by_account(account)
|
||||||
|
.not_domain_blocked_by_account(account)
|
||||||
ordered_list.filter_map { |ordered_list_key| map[ordered_list_key] }.map do |account|
|
.where.not(id: account.id)
|
||||||
AccountSuggestions::Suggestion.new(
|
.joins("LEFT OUTER JOIN follow_recommendation_mutes ON follow_recommendation_mutes.target_account_id = accounts.id AND follow_recommendation_mutes.account_id = #{account.id}").where(follow_recommendation_mutes: { target_account_id: nil })
|
||||||
account: account,
|
|
||||||
source: key
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_ordered_list_key(_account)
|
|
||||||
raise NotImplementedError
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -0,0 +1,26 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: follow_recommendation_mutes
|
||||||
|
#
|
||||||
|
# id :bigint(8) not null, primary key
|
||||||
|
# account_id :bigint(8) not null
|
||||||
|
# target_account_id :bigint(8) not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
class FollowRecommendationMute < ApplicationRecord
|
||||||
|
belongs_to :account
|
||||||
|
belongs_to :target_account, class_name: 'Account'
|
||||||
|
|
||||||
|
validates :target_account, uniqueness: { scope: :account_id }
|
||||||
|
|
||||||
|
after_commit :invalidate_follow_recommendations_cache
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def invalidate_follow_recommendations_cache
|
||||||
|
Rails.cache.delete("follow_recommendations/#{account_id}")
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,14 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateFollowRecommendationMutes < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
create_table :follow_recommendation_mutes do |t|
|
||||||
|
t.references :account, null: false, foreign_key: { on_delete: :cascade }, index: false
|
||||||
|
t.references :target_account, null: false, foreign_key: { to_table: 'accounts', on_delete: :cascade }
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :follow_recommendation_mutes, [:account_id, :target_account_id], unique: true
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,9 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddLanguagesIndexToAccountSummaries < ActiveRecord::Migration[7.1]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def change
|
||||||
|
add_index :account_summaries, [:account_id, :language, :sensitive], algorithm: :concurrently
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in new issue