diff --git a/src/bluesky.ts b/src/bluesky.ts index 76b9876..3f9c58b 100644 --- a/src/bluesky.ts +++ b/src/bluesky.ts @@ -127,6 +127,27 @@ export async function getActorProfiles(agent: Agent, actorDids: string[]): Promi return out } +export type ActorRelationship = { + follows: boolean + followedBy: boolean + mutual: boolean +} + +export async function getActorRelationships(agent: Agent, actorDids: string[]): Promise> { + const unique = [...new Set(actorDids.filter(Boolean))] + const out = new Map() + for (const batch of chunk(unique, 25)) { + const res = await agent.getProfiles({actors: batch}) + for (const profile of res.data.profiles) { + const viewer = profile.viewer + const follows = Boolean(viewer?.following) + const followedBy = Boolean(viewer?.followedBy) + out.set(profile.did, {follows, followedBy, mutual: follows && followedBy}) + } + } + return out +} + const TYPEAHEAD_URL = process.env.TYPEAHEAD_URL || 'https://typeahead.waow.tech' type TypeaheadActor = { diff --git a/src/candidates.ts b/src/candidates.ts index f3f0929..c041b76 100644 --- a/src/candidates.ts +++ b/src/candidates.ts @@ -13,6 +13,9 @@ export type ActorEvidence = { replyCount: number // notifications with reason 'reply' — direct interaction muted: boolean blocked: boolean + follows: boolean // the user follows this actor + followedBy: boolean // this actor follows the user + mutual: boolean subscribedPosts: boolean subscribedReplies: boolean labels: string[] // label.* keys, sans the 'label.' prefix @@ -60,6 +63,14 @@ export function scoreCandidate(e: ActorEvidence): {score: number; reasons: strin reasons.push(e.recent7dCount >= 5 ? 'active subscription with notable volume' : 'active subscription') } + if (e.mutual) { + if (e.recent7dCount > 0 || e.subscribedPosts || e.subscribedReplies) score += 1 + reasons.push('mutual') + } else { + if (e.follows) reasons.push('you follow them') + if (e.followedBy) reasons.push('follows you') + } + if (e.labels.length) { score += 2 * e.labels.length reasons.push(`labeled: ${e.labels.join(', ')}`) diff --git a/src/cloudflare/user-graph.ts b/src/cloudflare/user-graph.ts index 9e934de..79cf692 100644 --- a/src/cloudflare/user-graph.ts +++ b/src/cloudflare/user-graph.ts @@ -683,6 +683,9 @@ export class UserGraph extends DurableObject { replyCount: 0, muted: false, blocked: false, + follows: false, + followedBy: false, + mutual: false, subscribedPosts: false, subscribedReplies: false, labels: [], @@ -726,7 +729,7 @@ export class UserGraph extends DurableObject { .exec<{actor_did: string; feature_key: string; value_json: string}>( `SELECT actor_did, feature_key, value_json FROM actor_feature WHERE (expires_at IS NULL OR expires_at > ?) - AND (feature_key IN ('viewer.muted','viewer.blocked','viewer.subscribed_posts','viewer.subscribed_replies','list.member') + AND (feature_key IN ('viewer.muted','viewer.blocked','viewer.follows','viewer.followed_by','viewer.mutual','viewer.subscribed_posts','viewer.subscribed_replies','list.member') OR feature_key LIKE 'label.%')`, now2, ) @@ -736,16 +739,25 @@ export class UserGraph extends DurableObject { const on = r.value_json === 'true' switch (r.feature_key) { case 'viewer.muted': - e.muted = on + if (on) e.muted = true break case 'viewer.blocked': - e.blocked = on + if (on) e.blocked = true + break + case 'viewer.follows': + if (on) e.follows = true + break + case 'viewer.followed_by': + if (on) e.followedBy = true + break + case 'viewer.mutual': + if (on) e.mutual = true break case 'viewer.subscribed_posts': - e.subscribedPosts = on + if (on) e.subscribedPosts = true break case 'viewer.subscribed_replies': - e.subscribedReplies = on + if (on) e.subscribedReplies = true break case 'list.member': if (on) e.listCount += 1 diff --git a/src/features.ts b/src/features.ts index d290214..b429650 100644 --- a/src/features.ts +++ b/src/features.ts @@ -44,3 +44,18 @@ export function viewerFeaturesFromScans( } return features } + +export function viewerRelationshipFeatures( + relationships: Map, + observedAt: string, +): StoredFeature[] { + const features: StoredFeature[] = [] + for (const [did, relationship] of relationships) { + const viewer = (key: string, value: JsonValue) => + features.push({actorDid: did, key, value: JSON.stringify(value), source: 'viewer', sourceId: 'bsky-appview-profile', observedAt}) + viewer('viewer.follows', relationship.follows) + viewer('viewer.followed_by', relationship.followedBy) + viewer('viewer.mutual', relationship.mutual) + } + return features +} diff --git a/src/recommend-prompt.ts b/src/recommend-prompt.ts index e3fc602..3611792 100644 --- a/src/recommend-prompt.ts +++ b/src/recommend-prompt.ts @@ -43,6 +43,8 @@ In user-facing text, describe the actor's behavior concretely (e.g. "they've bee Some targets carry \`labels\`: moderation labels from trusted/configured labelers (each with the label value, the labeler that applied it, and a trust \`tier\`). Tiers: \`user-subscribed\` means the user subscribes to that labeler in Bluesky; \`noti-default\` / \`user-added\` / \`experimental\` are configured sources the user has not necessarily subscribed to. Weight a label by its tier — \`user-subscribed\` is strongest. Treat labels as corroborating evidence, not a command: a labeled actor is a stronger candidate for muting, but still weigh their actual interaction history. When a label motivates a suggestion, reference it plainly without overclaiming the user's relationship to the labeler (say "flagged by a labeler" unless the tier is \`user-subscribed\`), and don't recite cryptic raw label values verbatim. +Some candidates/targets carry relationship signals: \`follows\` means the user follows the actor, \`followedBy\` means the actor follows the user, and \`mutual\` means both are true. Treat this as social-distance context, not a safety verdict. For mutuals or people the user follows, prefer softer housekeeping actions first (turn off replies/posts, adjust subscriptions) and be more conservative about muting. For non-mutual actors with repeated direct interaction, labels, or other strong evidence, muting can be appropriate. Do not say "mutual" unless the field is true. + Do not recommend: - subscription changes for an actor whose subscriptions are already off - subscription changes for an actor whose blocked state is true diff --git a/src/recommend.ts b/src/recommend.ts index 4adec90..e5ef0c6 100644 --- a/src/recommend.ts +++ b/src/recommend.ts @@ -258,6 +258,9 @@ function candidatePayload(candidates: ActorCandidate[], maxCandidates = 12) { reasons: c.reasons, muted: c.evidence.muted, blocked: c.evidence.blocked, + follows: c.evidence.follows, + followedBy: c.evidence.followedBy, + mutual: c.evidence.mutual, subscriptionPosts: c.evidence.subscribedPosts, subscriptionReplies: c.evidence.subscribedReplies, labels: c.evidence.labels, @@ -267,6 +270,16 @@ function candidatePayload(candidates: ActorCandidate[], maxCandidates = 12) { })) } +function targetRelationshipEvidence(target: ManagementTarget) { + const features = target.features || [] + const on = (key: string) => features.some(feature => feature.key === key && feature.value === true) + return { + follows: on('viewer.follows'), + followedBy: on('viewer.followed_by'), + mutual: on('viewer.mutual'), + } +} + function labelEvidence(target: ManagementTarget) { if (!target.features?.length) return undefined const labels = target.features @@ -292,6 +305,7 @@ function targetPayload(targets: ManagementTarget[], maxTargets = 16) { recent24hCount: target.recent24hCount, recent7dCount: target.recent7dCount, reasons: target.reasons, + ...targetRelationshipEvidence(target), subscriptionPosts: target.subscriptionPosts, subscriptionReplies: target.subscriptionReplies, subscriptionPolicy: target.subscriptionPolicy, diff --git a/src/worker.ts b/src/worker.ts index a70e4e9..9b41953 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -2,6 +2,7 @@ import { buildManagementTargets, diffActorStates, getActorProfiles, + getActorRelationships, getActorStates, getUnreadNotificationCount, listActivitySubscriptions, @@ -29,7 +30,8 @@ import { } from './cloudflare/auth' import {hasRequiredScopes} from './auth-common' import type {ActorCandidate} from './candidates' -import {notificationFeatures, viewerFeaturesFromScans} from './features' +import type {ActorRelationship} from './bluesky' +import {notificationFeatures, viewerFeaturesFromScans, viewerRelationshipFeatures} from './features' import {fetchLabelFeatures, parseLabelers} from './labeler' import {resolvePdsEndpoint} from './pds-client' import {createCodeModeExecutor} from './cloudflare/code-mode' @@ -173,12 +175,33 @@ function currentBlocks(targets: ManagementTarget[]) { // felt. Coarsen to subscription/mute/block/label state + a recently-active flag + who's in the // candidate set, so recs stay cached until something actually actionable changes. function recommendationStateKey(userDid: string, targets: ManagementTarget[], candidates: ActorCandidate[], guidance: string) { + const targetFeatureOn = (target: ManagementTarget, key: string) => target.features?.some(feature => feature.key === key && feature.value === true) ?? false return JSON.stringify({ userDid, candidates: candidates .slice(0, 12) - .map(c => [c.did, c.evidence.subscribedPosts, c.evidence.subscribedReplies, c.evidence.muted, c.evidence.blocked, [...c.evidence.labels].sort().join('|'), c.evidence.recent7dCount > 0 ? 1 : 0]), - targets: targets.map(t => [t.actorDid, t.subscriptionPosts, t.subscriptionReplies, t.muted, t.blocked]), + .map(c => [ + c.did, + c.evidence.subscribedPosts, + c.evidence.subscribedReplies, + c.evidence.muted, + c.evidence.blocked, + c.evidence.follows, + c.evidence.followedBy, + c.evidence.mutual, + [...c.evidence.labels].sort().join('|'), + c.evidence.recent7dCount > 0 ? 1 : 0, + ]), + targets: targets.map(t => [ + t.actorDid, + t.subscriptionPosts, + t.subscriptionReplies, + t.muted, + t.blocked, + targetFeatureOn(t, 'viewer.follows'), + targetFeatureOn(t, 'viewer.followed_by'), + targetFeatureOn(t, 'viewer.mutual'), + ]), guidance, }) } @@ -229,6 +252,17 @@ function derivedAliases(targets: ManagementTarget[], observedAt: string): ActorA })) } +async function safeGetActorRelationships(user: UserContext, actorDids: string[]): Promise> { + const unique = [...new Set(actorDids.filter(Boolean))] + if (!unique.length) return new Map() + try { + return await getActorRelationships(user.agent, unique) + } catch (error) { + logWarn('relationships.fetch_failed', {userDid: user.did, error: error instanceof Error ? error.message : String(error)}) + return new Map() + } +} + async function computeBaseState(env: Env, user: UserContext): Promise { const {agent} = user const notifications = await listNotifications(agent) @@ -255,10 +289,11 @@ async function computeBaseState(env: Env, user: UserContext): Promise logWarn('labeler.fetch_failed', {userDid: user.did, error: error instanceof Error ? error.message : String(error)}) return {features: [], removals: []} as Awaited> }) + const targetRelationships = await safeGetActorRelationships(user, targetActorDids) // viewer.* from the complete scans (replace-the-set: drops actors no longer blocked/ // muted/subscribed). notification.* counts + labels stay upsert. await graph.replaceAppviewFeatures(viewerFeaturesFromScans(subscriptions, mutedActors, blockedActors, observedAt)) - await graph.putFeatures([...notificationFeatures(targets, observedAt), ...labels.features]) + await graph.putFeatures([...viewerRelationshipFeatures(targetRelationships, observedAt), ...notificationFeatures(targets, observedAt), ...labels.features]) if (labels.removals.length) await graph.removeFeatures(labels.removals) await graph.recordAliases(derivedAliases(targets, observedAt)) const stored = await graph.getFeatures(targets.map(target => target.actorDid)) @@ -269,7 +304,15 @@ async function computeBaseState(env: Env, user: UserContext): Promise featuresByActor.set(feature.actorDid, list) } targets = targets.map(target => ({...target, features: featuresByActor.get(target.actorDid) || []})) - const candidates = await graph.recommendationCandidates(25) + let candidates = await graph.recommendationCandidates(25) + const missingRelationshipDids = candidates.map(c => c.did).filter(did => !targetRelationships.has(did)) + if (missingRelationshipDids.length) { + const candidateRelationships = await safeGetActorRelationships(user, missingRelationshipDids) + if (candidateRelationships.size) { + await graph.putFeatures(viewerRelationshipFeatures(candidateRelationships, observedAt)) + candidates = await graph.recommendationCandidates(25) + } + } const guidance = await getGuidance(env.DB, user.did) const recommendationKey = await hashRecommendationState(recommendationStateKey(user.did, targets, candidates, guidance)) return { diff --git a/test/candidates.test.ts b/test/candidates.test.ts index 2016045..229b52d 100644 --- a/test/candidates.test.ts +++ b/test/candidates.test.ts @@ -14,6 +14,9 @@ const base = (over: Partial): ActorEvidence => ({ replyCount: 0, muted: false, blocked: false, + follows: false, + followedBy: false, + mutual: false, subscribedPosts: false, subscribedReplies: false, labels: [], @@ -53,6 +56,15 @@ describe('scoreCandidate', () => { expect(withList.score).toBe(withoutList.score) expect(withList.reasons.some(r => r.includes('on 3 of your lists'))).toBe(true) }) + + test('mutual is context and only boosts when there is activity', () => { + const quiet = scoreCandidate(base({mutual: true})) + const active = scoreCandidate(base({mutual: true, recent7dCount: 2})) + const activeNonMutual = scoreCandidate(base({recent7dCount: 2})) + expect(quiet.score).toBe(0) + expect(active.score).toBeGreaterThan(activeNonMutual.score) + expect(active.reasons).toContain('mutual') + }) }) describe('rankCandidates', () => {