People
Recommendations
recommendation-service suggests people, groups and events to follow. It uses no model: each suggestion is worked out from the graph as it stands, by rules written down here, and is sent with the reasons for it.
Its routes are in the API reference: recommendation-service.
It is optional. An installation that does not want suggestions leaves the service out, and the web app shows none. An installation that wants them chosen another way runs another service that answers the same paths.
The contract
Every path needs somebody signed in: a suggestion is made to a person, about that person.
| Endpoint | What |
|---|---|
GET /recommendations/actors?kind=&context=&limit= |
Who the viewer might follow, best first |
GET /recommendations/interests?limit= |
Hashtags to offer somebody who has just joined, the most used first (see below). limit up to 40, 20 by default |
GET /recommendations/preferences |
What the viewer has chosen about suggestions |
PUT /recommendations/preferences |
Changes any of those choices. One not sent is left as it was |
GET /recommendations/dismissals |
The profiles the viewer said they were not interested in |
POST /recommendations/dismissals { actor_id } |
Not interested in this one |
DELETE /recommendations/dismissals/<actor_id> |
Takes that back |
GET /recommendations/actors takes:
| Parameter | Values |
|---|---|
kind |
person (the default), group or event |
context |
home (the default), or onboarding for somebody who has just joined and follows nobody: nothing worked out from who they follow is used |
limit |
Up to 50. 10 when left out |
and answers:
{
"items": [
{
"actor_id": 4,
"score": 9,
"reasons": [
{ "type": "followed_by_leaders", "count": 3, "ids": [2, 3, 9] }
],
"actor": { "id": 4, "type": "node.actor.person-service.person.v1", "name": "…" }
}
],
"related": [
{ "id": 2, "type": "node.actor.person-service.person.v1", "name": "…" }
]
}
actoris the profile as the viewer is sent it anywhere else.reasonsis why, the one that counted most first.countis how strongly, andidsup to three of what it was found through.relatedis what the reasons name, to say them in words: the people who follow somebody suggested, the hashtags shared. Only the ones the viewer may see.
Why somebody is suggested
Each reason adds to a score, up to a most. The score is then kept in full for a profile that was visited or posted on in the last 90 days, halved for one quiet for up to a year, and cut to a tenth for one quiet longer, so that the dormant come last.
A person quiet for longer than the reader chose is not suggested at all, whatever else speaks for them: somebody who has gone will not see that they were followed, and has nothing new to show. The choice is active_within_days in the preferences, one of 30, 90, 180, 365, 730, 1825 or 0 for no limit (active_within_choices in the answer lists them); it is a year for somebody who has not chosen, and reset_weights puts it back there. It is kept beside their weights, in the same column. A group that quiet is only put last, since it is still there to be joined and woken. On an installation brought over from an older one, where few people have been back, a year leaves few people to suggest: the reader slides it further back, to any time if need be.
| Reason | Found when | Each | Most |
|---|---|---|---|
follows_you |
They follow the viewer, who does not follow them. People only | 4 | 4 |
pictured_with_you |
They and the viewer are in the same public photo or video, tagged, or one posted it and the other is tagged in it. Approved tags only. People only | 3 | 6 |
followed_by_leaders |
People the viewer follows, follow them. ids are those people |
3 | 15 |
pictured_with_leaders |
They are in public photos or videos with people the viewer follows, or in ones those people posted. Approved tags only. ids are those people, and are never named under the suggestion. People only |
2 | 8 |
cited_by_leaders |
People the viewer follows cited a public post of theirs or on their profile, in a published public article or topic of the last year. ids are those people |
3 | 9 |
inviter |
They invited the viewer, or the viewer invited them. People only | 3 | 3 |
hosted_by_leader |
A group the viewer follows is hosting it. Events only. ids are those groups |
3 | 3 |
you_engaged |
The viewer liked what is posted on their profile, in the last 90 days | 2 | 8 |
shared_interest |
Their public posts of the last year carry hashtags the viewer has used or liked, or follows. ids are those hashtags |
2 | 8 |
cited |
Anybody cited them, as above, but never themselves and never the viewer. ids are those people |
1 | 4 |
featured |
The installation's administrators featured them | 0.5 | 0.5 |
popular |
Many people followed them in the last 30 days | 0.5 | 0.5 |
Two profiles that score the same are in the order of their ids, so an answer is the same each time it is asked for.
shared_interest looks only at public posts. The hashtags come back as the reason, and a reason must not say what is in a post the viewer could not read.
Hashtags somebody follows count toward shared_interest as well. Following a hashtag makes a custom feed of it, the viewer's own; the service reads the hashtags of the feeds the viewer owns. That is what the Interests step of signing up gives it: somebody who has just joined has written and liked nothing, and what they picked is the one thing to go by besides who invited them, what is popular and what is featured. Since following is a choice they made, it counts even when they turned use_activity off, and in context=onboarding.
Hashtags to offer
GET /recommendations/interests lists the hashtags on the most public posts, for the Interests step of signing up. Only public posts of profiles anybody may see are counted, so a hashtag offered says nothing about what is kept from view; spellings that differ only in case count as one, under the spelling used most. A hashtag on fewer than two posts is not offered. It looks at the last three months first, then the last year, then all time, until it has found eight, so that a site that has been quiet lately still has something to offer. The web app leaves the step out when fewer than three come back, as on a site that has just been set up.
The numbers are constants in recommendation-service/constants. They are the service's own judgement of what makes a good suggestion, and each person can set their own in its place: see Each person's own weights.
Events
An event is suggested while it is still to come or on, and has not been called off. For an event, followed_by_leaders is people the viewer follows who have answered it, and following already is having answered it. An event is not asked to have a picture or a description, and is not marked down for when it was last visited: one that is still to come is as alive as anything is. It is sent with its event block, as it is in search results.
People on other networks
Somebody on Bluesky or on a server of the fediverse who is kept here is a
person like any other to whoever is asking who to follow, and is suggested
for the same reasons: people the viewer follows follow them, they follow
the viewer, they post about what the viewer does. They are among the
people, with their whole handle (alice.bsky.social,
@bob@mastodon.example) to say where they are.
- Only the ones kept here. This installation knows of nobody on those networks but whoever somebody here looked up or follows, whoever they reposted, and whoever did something to a post that went out from here. It cannot suggest a good account that nobody here has met.
- Somebody on Bluesky is suggested to anybody. Anybody here can follow them.
- Somebody on the fediverse is suggested only to a person who is on the fediverse themselves. Anybody else's follow of them would be taken away again, since the other server could not find out who was asking.
- How lately they were active is what was kept of them here: the last post of theirs that arrived.
Who is never suggested
Whatever the reason, a profile is left out when it is:
- the viewer, or somebody they follow already, or have asked to follow;
- on either side of a block with the viewer;
- one the viewer said they were not interested in;
- somebody who asked not to be suggested;
- disabled, archived or deleted;
- an event that is over or was called off;
- a person who has not confirmed their email address;
- a person or group without a picture and with nothing said about itself;
- one the viewer may not see.
All but the last are one clause every signal's query ends with (eligibleCandidate). The last is the graph's rule and is asked of the graph: the best hundred of what was found are put to FilterVisible, and only what it returns is sent. So is everything in related.
What each person chooses
GET and PUT /recommendations/preferences:
| Choice | Means |
|---|---|
suggest_people |
To be suggested people |
suggest_groups |
To be suggested groups |
suggest_events |
To be suggested events |
use_activity |
That what they liked and wrote, and the photos they are in, may be used to choose for them. Off, you_engaged, shared_interest, pictured_with_you and pictured_with_leaders are not looked for. Who they follow is still used |
discoverable |
That they may be suggested to other people |
Every choice is yes until its owner says otherwise. They are theirs to read and change and nobody else's.
Each person's own weights
Somebody can set how much each reason counts for when choosing for them. It changes the order they are suggested things in, and nothing about what anybody else is suggested.
GET /recommendations/preferencessendsweights(what each reason counts for, for them),default_weights(the service's own),max_weight(10) andreasons(the order to list them in).PUTtakesweights, by the reason's name. The ones sent are set and the others left. A weight is from 0 to 10; a reason there is not, or a number out of range, is answered422withinvalid_weightand nothing is changed.- A reason set to 0 is not used for them at all.
- What a reason may count for at most keeps its proportion:
followed_by_leaderscounts for up to five times what it counts for once, whatever that is set to. reset_weights: truegoes back to the service's own.
Kept as JSON in recommendation_preferences.weights, empty until set (migration 20261021000001_recommendation_weights).
Not interested
A profile somebody turns down is not suggested to them again. It is theirs alone: whoever was turned down is not told, and nobody else can read the list. It can be taken back from the list.
Running it, and not running it
- It reads the installation's database for its signals and writes two tables of its own,
recommendation_preferencesandrecommendation_dismissals(migration20261020000001_recommendations). - It takes the settings every service does (
DSN,GRAPH_SERVICE_ADDR,FILE_SERVICE_ADDR,AMQP_ADDR, the OAuth settings) and has none of its own. - nginx sends
/recommendations/to it.make recommendationbuilds and starts it on the development stack. - When an actor is removed for good, what it chose and what was chosen about it are removed with it (
node.actor.*.*.purged.v1). - Without it: the gateway answers
/recommendations/with an error, which the web app takes as there being no suggestions and shows nothing of them. On Kubernetes, leaveservices/recommendation.ymlout of the kustomization. - In its place: another service answering the paths above. It should ask the graph which profiles the viewer may see, as this one does.
Where the code is
recommendation-service/data/repositories/signals.go: the queries, one a reason, and the clause that keeps each to profiles that may be suggested. Tested against a real database insignals_integration_test.go.recommendation-service/scoring: the ranking. It reads nothing, so the same finds give the same answer.recommendation-service/cmd/api/handlers: the endpoints.
What is not built
- Locations somebody picks. Only hashtags are offered in signing up and read from followed feeds; locations followed as feeds are not used yet.
- Nearby. Nothing uses where people are.
- A cache. Each answer is worked out when it is asked for, which on the development data takes under a tenth of a second.
- Suggested posts. The same contract would take a second path for them.
From anahitasocial/anahita-services, 2026-10-27.
Written by Claude, reviewed by Rastin Mehr · About this