summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2023-12-19 11:59:43 +0100
committerGitHub <noreply@github.com>2023-12-19 10:59:43 +0000
commitb5ac61b2c5cad94a680527b961def46aea0a1ad4 (patch)
treeb440fb0d7d4ec012b685348a3e6916c2a37f5e93
parentb7bdcd4f395aaa1e85930940975439d10b570f40 (diff)
Change algorithm of follow recommendations (#28314)
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
-rw-r--r--.github/renovate.json57
-rw-r--r--app/controllers/admin/follow_recommendations_controller.rb2
-rw-r--r--app/controllers/api/v1/suggestions_controller.rb13
-rw-r--r--app/controllers/api/v2/suggestions_controller.rb12
-rw-r--r--app/lib/potential_friendship_tracker.rb31
-rw-r--r--app/models/account_domain_block.rb5
-rw-r--r--app/models/account_suggestions.rb48
-rw-r--r--app/models/account_suggestions/friends_of_friends_source.rb37
-rw-r--r--app/models/account_suggestions/global_source.rb34
-rw-r--r--app/models/account_suggestions/past_interactions_source.rb36
-rw-r--r--app/models/account_suggestions/setting_source.rb34
-rw-r--r--app/models/account_suggestions/similar_profiles_source.rb67
-rw-r--r--app/models/account_suggestions/source.rb30
-rw-r--r--app/models/block.rb9
-rw-r--r--app/models/concerns/account/associations.rb1
-rw-r--r--app/models/concerns/account/interactions.rb13
-rw-r--r--app/models/concerns/account/search.rb1
-rw-r--r--app/models/follow.rb8
-rw-r--r--app/models/follow_recommendation_filter.rb7
-rw-r--r--app/models/follow_recommendation_mute.rb26
-rw-r--r--app/models/follow_recommendation_suppression.rb14
-rw-r--r--app/models/follow_request.rb5
-rw-r--r--app/models/mute.rb9
-rw-r--r--app/models/preview_cards_status.rb4
-rw-r--r--app/services/account_search_service.rb5
-rw-r--r--app/services/favourite_service.rb7
-rw-r--r--app/services/post_status_service.rb3
-rw-r--r--app/services/reblog_service.rb8
-rw-r--r--app/views/admin/follow_recommendations/show.html.haml2
-rw-r--r--app/workers/scheduler/follow_recommendations_scheduler.rb50
-rw-r--r--db/migrate/20231211234923_create_follow_recommendation_mutes.rb14
-rw-r--r--db/migrate/20231212073317_add_languages_index_to_account_summaries.rb9
-rw-r--r--db/schema.rb14
-rw-r--r--spec/requests/api/v1/suggestions_spec.rb22
-rw-r--r--spec/workers/scheduler/follow_recommendations_scheduler_spec.rb2
35 files changed, 297 insertions, 292 deletions
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index aca52285940..dab99829a1b 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -102,9 +102,12 @@
{
// Group actions/*-artifact in the same PR
matchManagers: ['github-actions'],
- matchPackageNames: ['actions/download-artifact', 'actions/upload-artifact'],
+ matchPackageNames: [
+ 'actions/download-artifact',
+ 'actions/upload-artifact',
+ ],
matchUpdateTypes: ['major'],
- groupName: 'artifact actions (major)'
+ groupName: 'artifact actions (major)',
},
{
// Update @types/* packages every week, with one grouped PR
diff --git a/app/controllers/admin/follow_recommendations_controller.rb b/app/controllers/admin/follow_recommendations_controller.rb
index 841e3cc7fbf..a54e41bd8c1 100644
--- a/app/controllers/admin/follow_recommendations_controller.rb
+++ b/app/controllers/admin/follow_recommendations_controller.rb
@@ -8,7 +8,7 @@ module Admin
authorize :follow_recommendation, :show?
@form = Form::AccountBatch.new
- @accounts = filtered_follow_recommendations
+ @accounts = filtered_follow_recommendations.page(params[:page])
end
def update
diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb
index 9737ae5cb62..9ba1cef63ca 100644
--- a/app/controllers/api/v1/suggestions_controller.rb
+++ b/app/controllers/api/v1/suggestions_controller.rb
@@ -3,22 +3,23 @@
class Api::V1::SuggestionsController < Api::BaseController
include Authorization
- before_action -> { doorkeeper_authorize! :read }
+ before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
+ before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
before_action :require_user!
+ before_action :set_suggestions
def index
- suggestions = suggestions_source.get(current_account, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT))
- render json: suggestions.map(&:account), each_serializer: REST::AccountSerializer
+ render json: @suggestions.get(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:offset].to_i).map(&:account), each_serializer: REST::AccountSerializer
end
def destroy
- suggestions_source.remove(current_account, params[:id])
+ @suggestions.remove(params[:id])
render_empty
end
private
- def suggestions_source
- AccountSuggestions::PastInteractionsSource.new
+ def set_suggestions
+ @suggestions = AccountSuggestions.new(current_account)
end
end
diff --git a/app/controllers/api/v2/suggestions_controller.rb b/app/controllers/api/v2/suggestions_controller.rb
index 35eb276c01f..8516796e860 100644
--- a/app/controllers/api/v2/suggestions_controller.rb
+++ b/app/controllers/api/v2/suggestions_controller.rb
@@ -3,17 +3,23 @@
class Api::V2::SuggestionsController < Api::BaseController
include Authorization
- before_action -> { doorkeeper_authorize! :read }
+ before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
+ before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
before_action :require_user!
before_action :set_suggestions
def index
- render json: @suggestions, each_serializer: REST::SuggestionSerializer
+ render json: @suggestions.get(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:offset].to_i), each_serializer: REST::SuggestionSerializer
+ end
+
+ def destroy
+ @suggestions.remove(params[:id])
+ render_empty
end
private
def set_suggestions
- @suggestions = AccountSuggestions.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT))
+ @suggestions = AccountSuggestions.new(current_account)
end
end
diff --git a/app/lib/potential_friendship_tracker.rb b/app/lib/potential_friendship_tracker.rb
deleted file mode 100644
index f5bc2034659..00000000000
--- a/app/lib/potential_friendship_tracker.rb
+++ /dev/null
@@ -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
diff --git a/app/models/account_domain_block.rb b/app/models/account_domain_block.rb
index db2e37184f1..753935d6af6 100644
--- a/app/models/account_domain_block.rb
+++ b/app/models/account_domain_block.rb
@@ -19,6 +19,7 @@ class AccountDomainBlock < ApplicationRecord
validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true
after_commit :invalidate_domain_blocking_cache
+ after_commit :invalidate_follow_recommendations_cache
private
@@ -26,4 +27,8 @@ class AccountDomainBlock < ApplicationRecord
Rails.cache.delete("exclude_domains_for:#{account_id}")
Rails.cache.delete(['exclude_domains', account_id, domain])
end
+
+ def invalidate_follow_recommendations_cache
+ Rails.cache.delete("follow_recommendations/#{account_id}")
+ end
end
diff --git a/app/models/account_suggestions.rb b/app/models/account_suggestions.rb
index d1774e62fae..d62176c7ca3 100644
--- a/app/models/account_suggestions.rb
+++ b/app/models/account_suggestions.rb
@@ -1,28 +1,48 @@
# frozen_string_literal: true
class AccountSuggestions
+ include DatabaseHelper
+
SOURCES = [
AccountSuggestions::SettingSource,
- AccountSuggestions::PastInteractionsSource,
+ AccountSuggestions::FriendsOfFriendsSource,
+ AccountSuggestions::SimilarProfilesSource,
AccountSuggestions::GlobalSource,
].freeze
- def self.get(account, limit)
- 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
- )
+ BATCH_SIZE = 40
- suggestions.concat(source_suggestions)
- end
+ def initialize(account)
+ @account = account
end
- def self.remove(account, target_account_id)
- SOURCES.each do |source_class|
- source = source_class.new
- source.remove(account, target_account_id)
+ 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
+
+ # The sources deliver accounts that haven't yet been followed, are not blocked,
+ # and so on. Since we reset the cache on follows, blocks, and so on, we don't need
+ # a complicated query on this end.
+
+ 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
+
+ def remove(target_account_id)
+ FollowRecommendationMute.create(account_id: @account.id, target_account_id: target_account_id)
+ end
end
diff --git a/app/models/account_suggestions/friends_of_friends_source.rb b/app/models/account_suggestions/friends_of_friends_source.rb
new file mode 100644
index 00000000000..77d4f635aa6
--- /dev/null
+++ b/app/models/account_suggestions/friends_of_friends_source.rb
@@ -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
diff --git a/app/models/account_suggestions/global_source.rb b/app/models/account_suggestions/global_source.rb
index 651041d6751..d68f285e4f4 100644
--- a/app/models/account_suggestions/global_source.rb
+++ b/app/models/account_suggestions/global_source.rb
@@ -1,39 +1,13 @@
# frozen_string_literal: true
class AccountSuggestions::GlobalSource < AccountSuggestions::Source
- include Redisable
-
- 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
+ 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)
end
private
- def scope(account)
- Account.searchable
- .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
+ def content_locale
+ I18n.locale.to_s.split(/[_-]/).first
end
end
diff --git a/app/models/account_suggestions/past_interactions_source.rb b/app/models/account_suggestions/past_interactions_source.rb
deleted file mode 100644
index d169394f11a..00000000000
--- a/app/models/account_suggestions/past_interactions_source.rb
+++ /dev/null
@@ -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
diff --git a/app/models/account_suggestions/setting_source.rb b/app/models/account_suggestions/setting_source.rb
index 6185732b4bc..4b7275bf7ad 100644
--- a/app/models/account_suggestions/setting_source.rb
+++ b/app/models/account_suggestions/setting_source.rb
@@ -1,32 +1,18 @@
# frozen_string_literal: true
class AccountSuggestions::SettingSource < AccountSuggestions::Source
- def key
- :staff
- end
-
- def get(account, skip_account_ids: [], limit: 40)
- return [] unless setting_enabled?
-
- as_ordered_suggestions(
- scope(account).where(setting_to_where_condition).where.not(id: skip_account_ids),
- usernames_and_domains
- ).take(limit)
- end
-
- def remove(_account, _target_account_id)
- nil
+ def get(account, limit: 10)
+ if setting_enabled?
+ base_account_scope(account).where(setting_to_where_condition).limit(limit).pluck(:id).zip([key].cycle)
+ else
+ []
+ end
end
private
- def scope(account)
- Account.searchable
- .followable_by(account)
- .not_excluded_by_account(account)
- .not_domain_blocked_by_account(account)
- .where(locked: false)
- .where.not(id: account.id)
+ def key
+ :featured
end
def usernames_and_domains
@@ -61,8 +47,4 @@ class AccountSuggestions::SettingSource < AccountSuggestions::Source
def setting
Setting.bootstrap_timeline_accounts
end
-
- def to_ordered_list_key(account)
- [account.username.downcase, account.domain&.downcase]
- end
end
diff --git a/app/models/account_suggestions/similar_profiles_source.rb b/app/models/account_suggestions/similar_profiles_source.rb
new file mode 100644
index 00000000000..733c5f0bbcd
--- /dev/null
+++ b/app/models/account_suggestions/similar_profiles_source.rb
@@ -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
diff --git a/app/models/account_suggestions/source.rb b/app/models/account_suggestions/source.rb
index 504d26a8bd6..ee93a1342fc 100644
--- a/app/models/account_suggestions/source.rb
+++ b/app/models/account_suggestions/source.rb
@@ -1,34 +1,18 @@
# frozen_string_literal: true
class AccountSuggestions::Source
- def key
- raise NotImplementedError
- end
-
def get(_account, **kwargs)
raise NotImplementedError
end
- def remove(_account, target_account_id)
- raise NotImplementedError
- end
-
protected
- def as_ordered_suggestions(scope, ordered_list)
- return [] if ordered_list.empty?
-
- map = scope.index_by { |account| to_ordered_list_key(account) }
-
- ordered_list.filter_map { |ordered_list_key| map[ordered_list_key] }.map do |account|
- AccountSuggestions::Suggestion.new(
- account: account,
- source: key
- )
- end
- end
-
- def to_ordered_list_key(_account)
- raise NotImplementedError
+ def base_account_scope(account)
+ Account.searchable
+ .followable_by(account)
+ .not_excluded_by_account(account)
+ .not_domain_blocked_by_account(account)
+ .where.not(id: account.id)
+ .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 })
end
end
diff --git a/app/models/block.rb b/app/models/block.rb
index 11156ebab31..5476542a5ab 100644
--- a/app/models/block.rb
+++ b/app/models/block.rb
@@ -26,15 +26,20 @@ class Block < ApplicationRecord
end
before_validation :set_uri, only: :create
- after_commit :remove_blocking_cache
+ after_commit :invalidate_blocking_cache
+ after_commit :invalidate_follow_recommendations_cache
private
- def remove_blocking_cache
+ def invalidate_blocking_cache
Rails.cache.delete("exclude_account_ids_for:#{account_id}")
Rails.cache.delete("exclude_account_ids_for:#{target_account_id}")
end
+ def invalidate_follow_recommendations_cache
+ Rails.cache.delete("follow_recommendations/#{account_id}")
+ end
+
def set_uri
self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
end
diff --git a/app/models/concerns/account/associations.rb b/app/models/concerns/account/associations.rb
index 31902ae21a8..2bb6fed5ad0 100644
--- a/app/models/concerns/account/associations.rb
+