-
Notifications
You must be signed in to change notification settings - Fork 572
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add getSuggestedFollowsByActor (#1553)
* add getSuggestedFollowsByActor lex * remove pagination * codegen * add pds route * add app view route * first pass at likes-based suggested actors, plus tests * format * backfill with suggested_follow table * combine actors queries * fall back to popular follows, handle backfill differently * revert seed change, update test * lower likes threshold * cleanup * remove todo * format * optimize queries * cover mute lists * clean up into pipeline steps * add changeset
- Loading branch information
1 parent
abc6cf9
commit 3877210
Showing
17 changed files
with
661 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
--- | ||
'@atproto/api': patch | ||
--- | ||
|
||
Adds a new method `app.bsky.graph.getSuggestedFollowsByActor`. This method | ||
returns suggested follows for a given actor based on their likes and follows. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
{ | ||
"lexicon": 1, | ||
"id": "app.bsky.graph.getSuggestedFollowsByActor", | ||
"defs": { | ||
"main": { | ||
"type": "query", | ||
"description": "Get suggested follows related to a given actor.", | ||
"parameters": { | ||
"type": "params", | ||
"required": ["actor"], | ||
"properties": { | ||
"actor": { "type": "string", "format": "at-identifier" } | ||
} | ||
}, | ||
"output": { | ||
"encoding": "application/json", | ||
"schema": { | ||
"type": "object", | ||
"required": ["suggestions"], | ||
"properties": { | ||
"suggestions": { | ||
"type": "array", | ||
"items": { | ||
"type": "ref", | ||
"ref": "app.bsky.actor.defs#profileView" | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
36 changes: 36 additions & 0 deletions
36
packages/api/src/client/types/app/bsky/graph/getSuggestedFollowsByActor.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
/** | ||
* GENERATED CODE - DO NOT MODIFY | ||
*/ | ||
import { Headers, XRPCError } from '@atproto/xrpc' | ||
import { ValidationResult, BlobRef } from '@atproto/lexicon' | ||
import { isObj, hasProp } from '../../../../util' | ||
import { lexicons } from '../../../../lexicons' | ||
import { CID } from 'multiformats/cid' | ||
import * as AppBskyActorDefs from '../actor/defs' | ||
|
||
export interface QueryParams { | ||
actor: string | ||
} | ||
|
||
export type InputSchema = undefined | ||
|
||
export interface OutputSchema { | ||
suggestions: AppBskyActorDefs.ProfileView[] | ||
[k: string]: unknown | ||
} | ||
|
||
export interface CallOptions { | ||
headers?: Headers | ||
} | ||
|
||
export interface Response { | ||
success: boolean | ||
headers: Headers | ||
data: OutputSchema | ||
} | ||
|
||
export function toKnownErr(e: any) { | ||
if (e instanceof XRPCError) { | ||
} | ||
return e | ||
} |
138 changes: 138 additions & 0 deletions
138
packages/bsky/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
import { sql } from 'kysely' | ||
import { Server } from '../../../../lexicon' | ||
import AppContext from '../../../../context' | ||
import { InvalidRequestError } from '@atproto/xrpc-server' | ||
import { Database } from '../../../../db' | ||
import { ActorService } from '../../../../services/actor' | ||
|
||
const RESULT_LENGTH = 10 | ||
|
||
export default function (server: Server, ctx: AppContext) { | ||
server.app.bsky.graph.getSuggestedFollowsByActor({ | ||
auth: ctx.authVerifier, | ||
handler: async ({ auth, params }) => { | ||
const { actor } = params | ||
const viewer = auth.credentials.did | ||
|
||
const db = ctx.db.getReplica() | ||
const actorService = ctx.services.actor(db) | ||
const actorDid = await actorService.getActorDid(actor) | ||
|
||
if (!actorDid) { | ||
throw new InvalidRequestError('Actor not found') | ||
} | ||
|
||
const skeleton = await getSkeleton( | ||
{ | ||
actor: actorDid, | ||
viewer, | ||
}, | ||
{ | ||
db, | ||
actorService, | ||
}, | ||
) | ||
const hydrationState = await actorService.views.profileDetailHydration( | ||
skeleton.map((a) => a.did), | ||
{ viewer }, | ||
) | ||
const presentationState = actorService.views.profileDetailPresentation( | ||
skeleton.map((a) => a.did), | ||
hydrationState, | ||
{ viewer }, | ||
) | ||
const suggestions = Object.values(presentationState).filter((profile) => { | ||
return ( | ||
!profile.viewer?.muted && | ||
!profile.viewer?.mutedByList && | ||
!profile.viewer?.blocking && | ||
!profile.viewer?.blockedBy | ||
) | ||
}) | ||
|
||
return { | ||
encoding: 'application/json', | ||
body: { suggestions }, | ||
} | ||
}, | ||
}) | ||
} | ||
|
||
async function getSkeleton( | ||
params: { | ||
actor: string | ||
viewer: string | ||
}, | ||
ctx: { | ||
db: Database | ||
actorService: ActorService | ||
}, | ||
): Promise<{ did: string }[]> { | ||
const actorsViewerFollows = ctx.db.db | ||
.selectFrom('follow') | ||
.where('creator', '=', params.viewer) | ||
.select('subjectDid') | ||
const mostLikedAccounts = await ctx.db.db | ||
.selectFrom( | ||
ctx.db.db | ||
.selectFrom('like') | ||
.where('creator', '=', params.actor) | ||
.select(sql`split_part(subject, '/', 3)`.as('subjectDid')) | ||
.limit(1000) // limit to 1000 | ||
.as('likes'), | ||
) | ||
.select('likes.subjectDid as did') | ||
.select((qb) => qb.fn.count('likes.subjectDid').as('count')) | ||
.where('likes.subjectDid', 'not in', actorsViewerFollows) | ||
.where('likes.subjectDid', 'not in', [params.actor, params.viewer]) | ||
.groupBy('likes.subjectDid') | ||
.orderBy('count', 'desc') | ||
.limit(RESULT_LENGTH) | ||
.execute() | ||
const resultDids = mostLikedAccounts.map((a) => ({ did: a.did })) as { | ||
did: string | ||
}[] | ||
|
||
if (resultDids.length < RESULT_LENGTH) { | ||
// backfill with popular accounts followed by actor | ||
const mostPopularAccountsActorFollows = await ctx.db.db | ||
.selectFrom('follow') | ||
.innerJoin('profile_agg', 'follow.subjectDid', 'profile_agg.did') | ||
.select('follow.subjectDid as did') | ||
.where('follow.creator', '=', params.actor) | ||
.where('follow.subjectDid', '!=', params.viewer) | ||
.where('follow.subjectDid', 'not in', actorsViewerFollows) | ||
.if(resultDids.length > 0, (qb) => | ||
qb.where( | ||
'subjectDid', | ||
'not in', | ||
resultDids.map((a) => a.did), | ||
), | ||
) | ||
.orderBy('profile_agg.followersCount', 'desc') | ||
.limit(RESULT_LENGTH) | ||
.execute() | ||
|
||
resultDids.push(...mostPopularAccountsActorFollows) | ||
} | ||
|
||
if (resultDids.length < RESULT_LENGTH) { | ||
// backfill with suggested_follow table | ||
const additional = await ctx.db.db | ||
.selectFrom('suggested_follow') | ||
.where( | ||
'did', | ||
'not in', | ||
// exclude any we already have | ||
resultDids.map((a) => a.did).concat([params.actor, params.viewer]), | ||
) | ||
// and aren't already followed by viewer | ||
.where('did', 'not in', actorsViewerFollows) | ||
.selectAll() | ||
.execute() | ||
|
||
resultDids.push(...additional) | ||
} | ||
|
||
return resultDids | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.