Skip to content

Commit bbe1194

Browse files
committed
process Accept-Language for users who have no social graph yet
1 parent c779623 commit bbe1194

9 files changed

Lines changed: 126 additions & 8 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ A collaborative filter over the network's **likes**:
2020
the viewer has already liked or seen.
2121

2222
Anonymous viewers and brand-new accounts get a cold-start popularity feed (most-liked recent posts).
23-
Every parameter is env-overridable — see [.env.example](.env.example).
23+
For an authenticated but likeless account, that feed is biased toward the languages in the request's
24+
`Accept-Language` header (forwarded by the AppView) — the only per-viewer taste signal available before
25+
the account has liked anything. Posts declaring a non-matching language are dropped; posts that declare
26+
no language always pass, so the feed narrows toward the viewer's languages without starving. Anonymous
27+
viewers share one cached list, so they stay global (a per-request header there would poison the shared
28+
list). Every ranking parameter is env-overridable — see [.env.example](.env.example).
2429

2530
## Architecture
2631

src/algos/for-you.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818

1919
const cfRanker: Ranker = new CollaborativeFilterRanker()
2020
const graphRanker: Ranker = new GraphRanker()
21-
const popularityRanker: Ranker = new PopularityRanker()
21+
// Concrete type (not Ranker): its rank() takes an extra cold-start language arg.
22+
const popularityRanker = new PopularityRanker()
2223

2324
// Compute-on-request with a per-(feed, viewer) Redis cache of the ranked list.
2425
// The cached list is an IMMUTABLE snapshot: a seen-aware order is baked in once
@@ -32,6 +33,10 @@ export const handler = async (
3233
params: QueryParams,
3334
viewerDid: string | null,
3435
feed: FeedDef,
36+
// Normalized primary language subtags from the viewer's Accept-Language,
37+
// in preference order; [] when the header is absent. Used only to bias the
38+
// cold-start feed (see computeRanked).
39+
viewerLangs: string[],
3540
) => {
3641
const cacheKey = rankedListKey(feed.rkey, viewerDid)
3742
const offset = parseCursor(params.cursor)
@@ -41,7 +46,7 @@ export const handler = async (
4146
// On a cache miss, import the viewer's like history so the first request is
4247
// already personalized (runs at most once per backfill TTL).
4348
if (viewerDid) await ensureViewerBackfilled(ctx, viewerDid)
44-
const scored = await computeRanked(ctx, viewerDid, feed.content)
49+
const scored = await computeRanked(ctx, viewerDid, feed.content, viewerLangs)
4550
// Bake the seen-aware order in now so every page is a plain offset slice.
4651
ranked = await orderBySeen(ctx, viewerDid, scored)
4752
await cacheRankedList(
@@ -91,13 +96,20 @@ const computeRanked = async (
9196
ctx: AppContext,
9297
viewerDid: string | null,
9398
content: ContentFilter,
99+
viewerLangs: string[],
94100
): Promise<string[]> => {
95101
// Personalized first; fall back to popularity for anonymous / no-history
96102
// viewers and while the graph is still building.
97103
const engine = ctx.cfg.rankerEngine === 'graph' ? graphRanker : cfRanker
98104
const personalized = await engine.rank(ctx, viewerDid, content)
99105
if (personalized.length > 0) return personalized
100-
return popularityRanker.rank(ctx, viewerDid, content)
106+
// Bias the cold-start feed by the viewer's Accept-Language — but only for
107+
// authenticated viewers, whose ranked list is cached per-DID. Anonymous
108+
// viewers share one cache entry (viewerDid = null), so applying a per-request
109+
// header there would let one viewer's language poison every other viewer's
110+
// shared list; they stay global.
111+
const langs = viewerDid ? viewerLangs : []
112+
return popularityRanker.rank(ctx, viewerDid, content, langs)
101113
}
102114

103115
const parseCursor = (cursor?: string): number => {

src/db/migrations.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,18 @@ migrations['004'] = {
147147
await db.schema.dropTable('interactions').execute()
148148
},
149149
}
150+
151+
migrations['005'] = {
152+
async up(db: Kysely<unknown>) {
153+
// BCP-47 language subtags declared on the post record, normalized to primary
154+
// subtags and comma-joined (e.g. 'en,de'); '' when the post declares none.
155+
// Powers the cold-start language allowlist (see ranker/finalize.ts).
156+
await db.schema
157+
.alterTable('post_meta')
158+
.addColumn('langs', 'varchar', (col) => col.notNull().defaultTo(''))
159+
.execute()
160+
},
161+
async down(db: Kysely<unknown>) {
162+
await db.schema.alterTable('post_meta').dropColumn('langs').execute()
163+
},
164+
}

src/db/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export type PostMeta = {
3939
// media flags for content-typed feed variants
4040
is_image: number
4141
is_video: number
42+
// declared post languages: normalized primary BCP-47 subtags, comma-joined
43+
// (e.g. 'en,de'); '' when the post declares none. Feeds the cold-start
44+
// language allowlist.
45+
langs: string
4246
// when this metadata was last refreshed (ISO 8601)
4347
hydrated_at: string
4448
}

src/methods/feed-generation.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,45 @@ export default function (server: Server, ctx: AppContext) {
3131
viewerDid = null
3232
}
3333

34-
const body = await handler(ctx, params, viewerDid, feed)
34+
// The AppView forwards the client's content-language preference here; it's
35+
// the only per-viewer taste signal available for a brand-new account with
36+
// no likes, so it seeds the cold-start feed's language bias.
37+
const viewerLangs = parseAcceptLanguage(req.headers['accept-language'])
38+
39+
const body = await handler(ctx, params, viewerDid, feed, viewerLangs)
3540
return {
3641
encoding: 'application/json',
3742
body,
3843
}
3944
})
4045
}
46+
47+
// Parses an Accept-Language header ('fr-CH, fr;q=0.9, en;q=0.8, *;q=0.5') into
48+
// deduped primary BCP-47 subtags in descending q-order ('pt-BR' -> 'pt'). The
49+
// wildcard '*' and malformed entries are dropped. Returns [] when absent/empty,
50+
// which leaves the cold-start feed global.
51+
const parseAcceptLanguage = (header?: string): string[] => {
52+
if (!header) return []
53+
const ranked = header
54+
.split(',')
55+
.map((part) => {
56+
const [tag, ...paramParts] = part.trim().split(';')
57+
const primary = tag.split('-')[0].trim().toLowerCase()
58+
const qParam = paramParts
59+
.map((p) => p.trim())
60+
.find((p) => p.startsWith('q='))
61+
const q = qParam ? parseFloat(qParam.slice(2)) : 1
62+
return { primary, q: isNaN(q) ? 0 : q }
63+
})
64+
.filter((e) => e.primary && e.primary !== '*' && e.q > 0)
65+
.sort((a, b) => b.q - a.q)
66+
67+
const out: string[] = []
68+
const seen = new Set<string>()
69+
for (const { primary } of ranked) {
70+
if (seen.has(primary)) continue
71+
seen.add(primary)
72+
out.push(primary)
73+
}
74+
return out
75+
}

src/ranker/finalize.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export type FinalizeOptions = {
88
applyPopularityPenalty: boolean
99
// restrict results to a media type (image/video) for content-typed variants
1010
content: ContentFilter
11+
// cold-start language allowlist (normalized primary BCP-47 subtags). When
12+
// non-empty, a post survives only if it declares no language or shares at
13+
// least one with the allowlist — undeclared posts always pass, so the feed
14+
// biases toward these languages without starving. Empty/undefined = off.
15+
languages?: string[]
1116
}
1217

1318
// Shared back half of every ranker: hydrate candidate metadata, apply
@@ -24,6 +29,8 @@ export const finalize = async (
2429
const metas = await hydratePostMeta(ctx, [...rawScores.keys()])
2530
const now = Date.now()
2631
const freshnessMs = cfg.freshnessHours * 60 * 60 * 1000
32+
const langAllow =
33+
opts.languages && opts.languages.length > 0 ? new Set(opts.languages) : null
2734

2835
const scored: { uri: string; author: string; score: number }[] = []
2936
for (const [uri, raw] of rawScores) {
@@ -33,6 +40,13 @@ export const finalize = async (
3340
if (meta.is_reply && !cfg.includeReplies) continue // top-level posts only
3441
if (opts.content === 'image' && !meta.is_image) continue
3542
if (opts.content === 'video' && !meta.is_video) continue
43+
// Language allowlist: undeclared posts pass; declared ones must overlap.
44+
if (
45+
langAllow &&
46+
meta.langs.length > 0 &&
47+
!meta.langs.some((l) => langAllow.has(l))
48+
)
49+
continue
3650

3751
const age = now - Date.parse(meta.created_at)
3852
if (isNaN(age) || age > freshnessMs) continue

src/ranker/hydrate.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const hydratePostMeta = async (
5252
is_reply: m.is_reply ? 1 : 0,
5353
is_image: m.is_image ? 1 : 0,
5454
is_video: m.is_video ? 1 : 0,
55+
langs: m.langs.join(','),
5556
hydrated_at: new Date().toISOString(),
5657
})),
5758
)
@@ -65,6 +66,7 @@ export const hydratePostMeta = async (
6566
is_reply: eb.ref('excluded.is_reply'),
6667
is_image: eb.ref('excluded.is_image'),
6768
is_video: eb.ref('excluded.is_video'),
69+
langs: eb.ref('excluded.langs'),
6870
hydrated_at: eb.ref('excluded.hydrated_at'),
6971
})),
7072
)
@@ -116,6 +118,7 @@ const fetchFromAppview = async (
116118
is_reply: !!record.reply,
117119
is_image: hasMedia(post, 'images'),
118120
is_video: hasMedia(post, 'video'),
121+
langs: normalizeLangs(record.langs),
119122
})
120123
}
121124
}
@@ -146,6 +149,23 @@ const hasAdultLabel = (post: any): boolean => {
146149
return labels.some((l) => ADULT_LABELS.has(l?.val))
147150
}
148151

152+
// Normalizes a post record's `langs` to deduped primary BCP-47 subtags:
153+
// lowercased, region/script stripped ('pt-BR' -> 'pt'). Ignores malformed
154+
// entries. Returns [] when the post declares no usable language.
155+
const normalizeLangs = (raw: unknown): string[] => {
156+
if (!Array.isArray(raw)) return []
157+
const out: string[] = []
158+
const seen = new Set<string>()
159+
for (const entry of raw) {
160+
if (typeof entry !== 'string') continue
161+
const primary = entry.split('-')[0].trim().toLowerCase()
162+
if (!primary || seen.has(primary)) continue
163+
seen.add(primary)
164+
out.push(primary)
165+
}
166+
return out
167+
}
168+
149169
const authorFromUri = (uri: string): string => {
150170
// at://<did>/app.bsky.feed.post/<rkey>
151171
const match = uri.match(/^at:\/\/([^/]+)\//)
@@ -162,6 +182,7 @@ const toCandidateMeta = (row: {
162182
is_reply: number
163183
is_image: number
164184
is_video: number
185+
langs: string
165186
}): CandidateMeta => ({
166187
uri: row.uri,
167188
author_did: row.author_did,
@@ -172,4 +193,5 @@ const toCandidateMeta = (row: {
172193
is_reply: row.is_reply === 1,
173194
is_image: row.is_image === 1,
174195
is_video: row.is_video === 1,
196+
langs: row.langs ? row.langs.split(',').filter(Boolean) : [],
175197
})

src/ranker/popularity.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import { Ranker, ContentFilter } from './types'
44
import { finalize } from './finalize'
55

66
// Cold-start ranker for anonymous viewers and users with no likes yet: the
7-
// most-liked recent posts, still subject to time-decay and freshness.
7+
// most-liked recent posts, still subject to time-decay and freshness. When the
8+
// viewer's Accept-Language yields a non-empty `languages` allowlist, the feed is
9+
// biased toward those languages (see finalize); [] leaves it global.
810
export class PopularityRanker implements Ranker {
911
async rank(
1012
ctx: AppContext,
1113
_viewerDid: string | null,
1214
content: ContentFilter,
15+
languages: string[] = [],
1316
): Promise<string[]> {
1417
const cfg = ctx.cfg.ranking
1518
const cutoff = new Date(
@@ -33,7 +36,12 @@ export class PopularityRanker implements Ranker {
3336
const rawScores = new Map<string, number>()
3437
for (const row of rows.rows) rawScores.set(row.subject_uri, row.likes)
3538

36-
// No popularity penalty for cold start — popular posts should win.
37-
return finalize(ctx, rawScores, { applyPopularityPenalty: false, content })
39+
// No popularity penalty for cold start — popular posts should win. Bias
40+
// toward the viewer's languages when known (undeclared posts still pass).
41+
return finalize(ctx, rawScores, {
42+
applyPopularityPenalty: false,
43+
content,
44+
languages,
45+
})
3846
}
3947
}

src/ranker/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,7 @@ export type CandidateMeta = {
2222
is_reply: boolean
2323
is_image: boolean
2424
is_video: boolean
25+
// normalized primary BCP-47 language subtags declared on the post (e.g.
26+
// ['en', 'de']); empty when the post declares no language.
27+
langs: string[]
2528
}

0 commit comments

Comments
 (0)