]> cat aescling's git repositories - mastodon.git/commitdiff
Re-add follow recommendations API (#7918)
authorEugen Rochko <eugen@zeonfederated.com>
Mon, 2 Jul 2018 23:47:56 +0000 (01:47 +0200)
committerGitHub <noreply@github.com>
Mon, 2 Jul 2018 23:47:56 +0000 (01:47 +0200)
* Re-add follow recommendations API

    GET /api/v1/suggestions

Removed in 8efa081f210d72ed450c39ac4cde0fd84fb3d3fb due to Neo4J
dependency. The algorithm uses triadic closures, takes into account
suspensions, blocks, mutes, domain blocks, excludes locked and moved
accounts, and prefers more recently updated accounts.

* Track interactions with people you don't follow

Replying to, favouriting and reblogging someone you're not following
will make them show up in follow recommendations. The interactions
have different weights:

- Replying is 1
- Favouriting is 10 (decidedly positive interaction, but private)
- Reblogging is 20

Following them, muting or blocking will remove them from the list,
obviously.

* Remove triadic closures, ensure potential friendships are trimmed

app/controllers/api/v1/suggestions_controller.rb [new file with mode: 0644]
app/lib/potential_friendship_tracker.rb [new file with mode: 0644]
app/models/account.rb
app/models/concerns/account_interactions.rb
app/services/favourite_service.rb
app/services/post_status_service.rb
app/services/reblog_service.rb
config/routes.rb
spec/controllers/api/v1/suggestions_controller_spec.rb [new file with mode: 0644]
spec/models/account_spec.rb

diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb
new file mode 100644 (file)
index 0000000..3abcced
--- /dev/null
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Api::V1::SuggestionsController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :read }
+  before_action :require_user!
+  before_action :set_accounts
+
+  respond_to :json
+
+  def index
+    render json: @accounts, each_serializer: REST::AccountSerializer
+  end
+
+  private
+
+  def set_accounts
+    @accounts = PotentialFriendshipTracker.get(current_account.id, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT))
+  end
+end
diff --git a/app/lib/potential_friendship_tracker.rb b/app/lib/potential_friendship_tracker.rb
new file mode 100644 (file)
index 0000000..3624826
--- /dev/null
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class PotentialFriendshipTracker
+  EXPIRE_AFTER = 90.days.seconds
+  MAX_ITEMS    = 80
+
+  WEIGHTS = {
+    reply: 1,
+    favourite: 10,
+    reblog: 20,
+  }.freeze
+
+  class << self
+    def record(account_id, target_account_id, action)
+      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
+
+    def get(account_id, limit: 20, offset: 0)
+      account_ids = redis.zrevrange("interactions:#{account_id}", offset, limit)
+      return [] if account_ids.empty?
+      Account.searchable.where(id: account_ids)
+    end
+
+    private
+
+    def redis
+      Redis.current
+    end
+  end
+end
index 40a45b1f88951113e1cf651d4c58402d17712a0c..1f720bf88110116017691b2aad70e244ccbb7c0f 100644 (file)
@@ -127,6 +127,7 @@ class Account < ApplicationRecord
   scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) }
   scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
   scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
+  scope :searchable, -> { where(suspended: false).where(moved_to_account_id: nil) }
 
   delegate :email,
            :unconfirmed_email,
@@ -309,34 +310,6 @@ class Account < ApplicationRecord
       DeliveryFailureTracker.filter(urls)
     end
 
-    def triadic_closures(account, limit: 5, offset: 0)
-      sql = <<-SQL.squish
-        WITH first_degree AS (
-          SELECT target_account_id
-          FROM follows
-          WHERE account_id = :account_id
-        )
-        SELECT accounts.*
-        FROM follows
-        INNER JOIN accounts ON follows.target_account_id = accounts.id
-        WHERE
-          account_id IN (SELECT * FROM first_degree)
-          AND target_account_id NOT IN (SELECT * FROM first_degree)
-          AND target_account_id NOT IN (:excluded_account_ids)
-          AND accounts.suspended = false
-        GROUP BY target_account_id, accounts.id
-        ORDER BY count(account_id) DESC
-        OFFSET :offset
-        LIMIT :limit
-      SQL
-
-      excluded_account_ids = account.excluded_from_timeline_account_ids + [account.id]
-
-      find_by_sql(
-        [sql, { account_id: account.id, excluded_account_ids: excluded_account_ids, limit: limit, offset: offset }]
-      )
-    end
-
     def search_for(terms, limit = 10)
       textsearch, query = generate_query_for_search(terms)
 
index ef59f5d151dff787208a015e6a88eb28d1b17cbb..ee435f956f38442c59df640ad0b1af4fe5d49999 100644 (file)
@@ -89,10 +89,13 @@ module AccountInteractions
                               .find_or_create_by!(target_account: other_account)
 
     rel.update!(show_reblogs: reblogs)
+    remove_potential_friendship(other_account)
+
     rel
   end
 
   def block!(other_account, uri: nil)
+    remove_potential_friendship(other_account)
     block_relationships.create_with(uri: uri)
                        .find_or_create_by!(target_account: other_account)
   end
@@ -100,10 +103,13 @@ module AccountInteractions
   def mute!(other_account, notifications: nil)
     notifications = true if notifications.nil?
     mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account)
+    remove_potential_friendship(other_account)
+
     # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
     if mute.hide_notifications? != notifications
       mute.update!(hide_notifications: notifications)
     end
+
     mute
   end
 
@@ -194,4 +200,10 @@ module AccountInteractions
     lists.joins(account: :user)
          .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
   end
+
+  private
+
+  def remove_potential_friendship(other_account)
+    PotentialFriendshipTracker.remove(id, other_account.id)
+  end
 end
index bc2d1547a0e08da803c1073f0730b2e8dfaa7771..6e1ac3ba99b046c97891bac152cd76e8f5fda495 100644 (file)
@@ -15,7 +15,10 @@ class FavouriteService < BaseService
     return favourite unless favourite.nil?
 
     favourite = Favourite.create!(account: account, status: status)
+
     create_notification(favourite)
+    bump_potential_friendship(account, status)
+
     favourite
   end
 
@@ -33,6 +36,11 @@ class FavouriteService < BaseService
     end
   end
 
+  def bump_potential_friendship(account, status)
+    return if account.following?(status.account_id)
+    PotentialFriendshipTracker.record(account.id, status.account_id, :favourite)
+  end
+
   def build_json(favourite)
     Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       favourite,
index 735985725794c3a8534c816db22cb911ff19922c..bad82051a079f9883a1ce992c81adfbec6883231 100644 (file)
@@ -47,6 +47,8 @@ class PostStatusService < BaseService
       redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id)
     end
 
+    bump_potential_friendship(account, status)
+
     status
   end
 
@@ -79,4 +81,9 @@ class PostStatusService < BaseService
   def redis
     Redis.current
   end
+
+  def bump_potential_friendship(account, status)
+    return if !status.reply? || account.following?(status.account_id)
+    PotentialFriendshipTracker.record(account.id, status.in_reply_to_account_id, :reply)
+  end
 end
index 3c4e5847f3050a56884da198a1d8dad2813af3f5..0ee8bac2f24c5bc7a6e9620ca5ab5c04833d6500 100644 (file)
@@ -24,6 +24,8 @@ class ReblogService < BaseService
     ActivityPub::DistributionWorker.perform_async(reblog.id)
 
     create_notification(reblog)
+    bump_potential_friendship(account, reblog)
+
     reblog
   end
 
@@ -41,6 +43,11 @@ class ReblogService < BaseService
     end
   end
 
+  def bump_potential_friendship(account, reblog)
+    return if account.following?(reblog.reblog.account_id)
+    PotentialFriendshipTracker.record(account.id, reblog.reblog.account_id, :reblog)
+  end
+
   def build_json(reblog)
     Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
       reblog,
index 5fdd3b390e6406670c6b519421fdb5ef408a2dc3..e593259646d8470984767adf2b3af62c09f9574d 100644 (file)
@@ -246,6 +246,7 @@ Rails.application.routes.draw do
 
       resources :streaming, only: [:index]
       resources :custom_emojis, only: [:index]
+      resources :suggestions, only: [:index]
 
       get '/search', to: 'search#index', as: :search
 
diff --git a/spec/controllers/api/v1/suggestions_controller_spec.rb b/spec/controllers/api/v1/suggestions_controller_spec.rb
new file mode 100644 (file)
index 0000000..17f10b0
--- /dev/null
@@ -0,0 +1,35 @@
+require 'rails_helper'
+
+RSpec.describe Api::V1::SuggestionsController, type: :controller do
+  render_views
+
+  let(:user)  { Fabricate(:user) }
+  let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read write') }
+
+  before do
+    allow(controller).to receive(:doorkeeper_token) { token }
+  end
+
+  describe 'GET #index' do
+    let(:bob) { Fabricate(:account) }
+    let(:jeff) { Fabricate(:account) }
+
+    before do
+      PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog)
+      PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
+
+      get :index
+    end
+
+    it 'returns http success' do
+      expect(response).to have_http_status(200)
+    end
+
+    it 'returns accounts' do
+      json = body_as_json
+
+      expect(json.size).to be >= 1
+      expect(json.map { |i| i[:id] }).to include *[bob, jeff].map { |i| i.id.to_s }
+    end
+  end
+end
index cce659a8a1efcdc4bf823a0032d108682076a64d..c50791bcd3f57ffbc13dbb7430c120e703dccee1 100644 (file)
@@ -454,77 +454,6 @@ RSpec.describe Account, type: :model do
     end
   end
 
-  describe '.triadic_closures' do
-    let!(:me) { Fabricate(:account) }
-    let!(:friend) { Fabricate(:account) }
-    let!(:friends_friend) { Fabricate(:account) }
-    let!(:both_follow) { Fabricate(:account) }
-
-    before do
-      me.follow!(friend)
-      friend.follow!(friends_friend)
-
-      me.follow!(both_follow)
-      friend.follow!(both_follow)
-    end
-
-    it 'finds accounts you dont follow which are followed by accounts you do follow' do
-      expect(described_class.triadic_closures(me)).to eq [friends_friend]
-    end
-
-    it 'limits by 5 with offset 0 by defualt' do
-      first_degree = 6.times.map { Fabricate(:account) }
-      matches = 5.times.map { Fabricate(:account) }
-      first_degree.each { |account| me.follow!(account) }
-      matches.each do |match|
-        first_degree.each { |account| account.follow!(match) }
-        first_degree.shift
-      end
-
-      expect(described_class.triadic_closures(me)).to eq matches
-    end
-
-    it 'accepts arbitrary limits' do
-      another_friend = Fabricate(:account)
-      higher_friends_friend = Fabricate(:account)
-      me.follow!(another_friend)
-      friend.follow!(higher_friends_friend)
-      another_friend.follow!(higher_friends_friend)
-
-      expect(described_class.triadic_closures(me, limit: 1)).to eq [higher_friends_friend]
-    end
-
-    it 'acceps arbitrary offset' do
-      another_friend = Fabricate(:account)
-      higher_friends_friend = Fabricate(:account)
-      me.follow!(another_friend)
-      friend.follow!(higher_friends_friend)
-      another_friend.follow!(higher_friends_friend)
-
-      expect(described_class.triadic_closures(me, offset: 1)).to eq [friends_friend]
-    end
-
-    context 'when you block account' do
-      before do
-        me.block!(friends_friend)
-      end
-
-      it 'rejects blocked accounts' do
-        expect(described_class.triadic_closures(me)).to be_empty
-      end
-    end
-
-    context 'when you mute account' do
-      before do
-        me.mute!(friends_friend)
-      end
-
-      it 'rejects muted accounts' do
-        expect(described_class.triadic_closures(me)).to be_empty
-      end
-    end
-  end
-
   describe '#statuses_count' do
     subject { Fabricate(:account) }