From f7117646afddb2676e9275d8efe90c3a20c59021 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 12 Apr 2021 12:37:14 +0200 Subject: Add cold-start follow recommendations (#15945) --- app/models/account.rb | 3 +- app/models/account_suggestions.rb | 17 +++++++++++ app/models/account_summary.rb | 25 ++++++++++++++++ app/models/concerns/account_associations.rb | 3 ++ app/models/follow_recommendation.rb | 39 +++++++++++++++++++++++++ app/models/follow_recommendation_filter.rb | 26 +++++++++++++++++ app/models/follow_recommendation_suppression.rb | 28 ++++++++++++++++++ app/models/form/account_batch.rb | 18 ++++++++++++ 8 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 app/models/account_suggestions.rb create mode 100644 app/models/account_summary.rb create mode 100644 app/models/follow_recommendation.rb create mode 100644 app/models/follow_recommendation_filter.rb create mode 100644 app/models/follow_recommendation_suppression.rb (limited to 'app/models') diff --git a/app/models/account.rb b/app/models/account.rb index d85fd1f6e..80689d4aa 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -110,6 +110,7 @@ class Account < ApplicationRecord scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) } scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) } + scope :followable_by, ->(account) { joins(arel_table.join(Follow.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(Follow.arel_table[:target_account_id]).and(Follow.arel_table[:account_id].eq(account.id))).join_sources).where(Follow.arel_table[:id].eq(nil)).joins(arel_table.join(FollowRequest.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(FollowRequest.arel_table[:target_account_id]).and(FollowRequest.arel_table[:account_id].eq(account.id))).join_sources).where(FollowRequest.arel_table[:id].eq(nil)) } scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) } scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) } scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) } @@ -363,7 +364,7 @@ class Account < ApplicationRecord end def excluded_from_timeline_account_ids - Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) } + Rails.cache.fetch("exclude_account_ids_for:#{id}") { block_relationships.pluck(:target_account_id) + blocked_by_relationships.pluck(:account_id) + mute_relationships.pluck(:target_account_id) } end def excluded_from_timeline_domains diff --git a/app/models/account_suggestions.rb b/app/models/account_suggestions.rb new file mode 100644 index 000000000..7fe9d618e --- /dev/null +++ b/app/models/account_suggestions.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AccountSuggestions + class Suggestion < ActiveModelSerializers::Model + attributes :account, :source + end + + def self.get(account, limit) + suggestions = PotentialFriendshipTracker.get(account, limit).map { |target_account| Suggestion.new(account: target_account, source: :past_interaction) } + suggestions.concat(FollowRecommendation.get(account, limit - suggestions.size, suggestions.map { |suggestion| suggestion.account.id }).map { |target_account| Suggestion.new(account: target_account, source: :global) }) if suggestions.size < limit + suggestions + end + + def self.remove(account, target_account_id) + PotentialFriendshipTracker.remove(account.id, target_account_id) + end +end diff --git a/app/models/account_summary.rb b/app/models/account_summary.rb new file mode 100644 index 000000000..6a7e17c6c --- /dev/null +++ b/app/models/account_summary.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: account_summaries +# +# account_id :bigint(8) primary key +# language :string +# sensitive :boolean +# + +class AccountSummary < ApplicationRecord + self.primary_key = :account_id + + scope :safe, -> { where(sensitive: false) } + scope :localized, ->(locale) { where(language: locale) } + scope :filtered, -> { joins(arel_table.join(FollowRecommendationSuppression.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:account_id].eq(FollowRecommendationSuppression.arel_table[:account_id])).join_sources).where(FollowRecommendationSuppression.arel_table[:id].eq(nil)) } + + def self.refresh + Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false) + end + + def readonly? + true + end +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 98849f8fc..aaf371ebd 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -63,5 +63,8 @@ module AccountAssociations # Account deletion requests has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy + + # Follow recommendations + has_one :follow_recommendation_suppression, inverse_of: :account, dependent: :destroy end end diff --git a/app/models/follow_recommendation.rb b/app/models/follow_recommendation.rb new file mode 100644 index 000000000..c4355224d --- /dev/null +++ b/app/models/follow_recommendation.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: follow_recommendations +# +# account_id :bigint(8) primary key +# rank :decimal(, ) +# reason :text is an Array +# + +class FollowRecommendation < ApplicationRecord + self.primary_key = :account_id + + belongs_to :account_summary, foreign_key: :account_id + belongs_to :account, foreign_key: :account_id + + scope :safe, -> { joins(:account_summary).merge(AccountSummary.safe) } + scope :localized, ->(locale) { joins(:account_summary).merge(AccountSummary.localized(locale)) } + scope :filtered, -> { joins(:account_summary).merge(AccountSummary.filtered) } + + def readonly? + true + end + + def self.get(account, limit, exclude_account_ids = []) + account_ids = Redis.current.zrevrange("follow_recommendations:#{account.user_locale}", 0, -1).map(&:to_i) - exclude_account_ids - [account.id] + + return [] if account_ids.empty? || limit < 1 + + accounts = Account.followable_by(account) + .not_excluded_by_account(account) + .not_domain_blocked_by_account(account) + .where(id: account_ids) + .limit(limit) + .index_by(&:id) + + account_ids.map { |id| accounts[id] }.compact + end +end diff --git a/app/models/follow_recommendation_filter.rb b/app/models/follow_recommendation_filter.rb new file mode 100644 index 000000000..acf03cd84 --- /dev/null +++ b/app/models/follow_recommendation_filter.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class FollowRecommendationFilter + KEYS = %i( + language + status + ).freeze + + attr_reader :params, :language + + def initialize(params) + @language = params.delete('language') || I18n.locale + @params = params + end + + def results + if params['status'] == 'suppressed' + Account.joins(:follow_recommendation_suppression).order(FollowRecommendationSuppression.arel_table[:id].desc).to_a + else + account_ids = Redis.current.zrevrange("follow_recommendations:#{@language}", 0, -1).map(&:to_i) + accounts = Account.where(id: account_ids).index_by(&:id) + + account_ids.map { |id| accounts[id] }.compact + end + end +end diff --git a/app/models/follow_recommendation_suppression.rb b/app/models/follow_recommendation_suppression.rb new file mode 100644 index 000000000..170506b85 --- /dev/null +++ b/app/models/follow_recommendation_suppression.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: follow_recommendation_suppressions +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class FollowRecommendationSuppression < ApplicationRecord + include Redisable + + belongs_to :account + + after_commit :remove_follow_recommendations, on: :create + + private + + def remove_follow_recommendations + redis.pipelined do + I18n.available_locales.each do |locale| + redis.zrem("follow_recommendations:#{locale}", account_id) + end + end + end +end diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb index 26d6d3abf..698933c9f 100644 --- a/app/models/form/account_batch.rb +++ b/app/models/form/account_batch.rb @@ -21,6 +21,10 @@ class Form::AccountBatch approve! when 'reject' reject! + when 'suppress_follow_recommendation' + suppress_follow_recommendation! + when 'unsuppress_follow_recommendation' + unsuppress_follow_recommendation! end end @@ -79,4 +83,18 @@ class Form::AccountBatch records.each { |account| authorize(account.user, :reject?) } .each { |account| DeleteAccountService.new.call(account, reserve_email: false, reserve_username: false) } end + + def suppress_follow_recommendation! + authorize(:follow_recommendation, :suppress?) + + accounts.each do |account| + FollowRecommendationSuppression.create(account: account) + end + end + + def unsuppress_follow_recommendation! + authorize(:follow_recommendation, :unsuppress?) + + FollowRecommendationSuppression.where(account_id: account_ids).destroy_all + end end -- cgit