Skip to content

Commit c972fc0

Browse files
phatpham9claude
andcommitted
feat: add AI-powered "For You" feed personalization
Reranks the feed by free-text reader interests using Cloudflare Workers AI, with graceful degradation to chronological order on any model or ranking failure. Settings live in the existing Reader settings dialog (disabled by default); personalization is opt-in per browser. The backend ranks once per (interests, source filter, freshness) via the Cache API and re-projects the cached order onto a freshly-fetched item page each request, so pagination behaves exactly like /api/items (true offset/limit, real has_next) without re-invoking the LLM per page. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a86146f commit c972fc0

11 files changed

Lines changed: 854 additions & 34 deletions

File tree

core/personalize/rank.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Pure helpers for the "For You" LLM ranking feature. No I/O — adapters
2+
// (e.g. platforms/cloudflare/src/llmRanker.ts) own the actual model call and
3+
// use these to build the prompt and interpret the response.
4+
5+
import type { FeedItem } from "../domain.ts";
6+
7+
export const MAX_INTERESTS_LENGTH = 300;
8+
const MAX_ITEM_TEXT_LENGTH = 160;
9+
10+
export function buildRankingPrompt(
11+
items: FeedItem[],
12+
interests: string,
13+
): string {
14+
const lines = items.map((item, i) => {
15+
const title = item.title.trim().slice(0, MAX_ITEM_TEXT_LENGTH);
16+
const summary = (item.summary ?? "").trim().slice(0, MAX_ITEM_TEXT_LENGTH);
17+
return `${i}: ${title}${summary ? ` — ${summary}` : ""}`;
18+
});
19+
return [
20+
"You are ranking a list of feed items by relevance to a reader's stated interests.",
21+
`Reader interests: ${interests.trim().slice(0, MAX_INTERESTS_LENGTH)}`,
22+
"Items (index: title — summary):",
23+
...lines,
24+
"",
25+
"Respond with ONLY a JSON array of the item indices above, ordered from most to least relevant to the interests. Include every index exactly once. No other text, no markdown.",
26+
].join("\n");
27+
}
28+
29+
/**
30+
* Defensively extracts a ranked index list from a raw model response.
31+
* Never throws — a malformed or empty response yields []. Indices outside
32+
* [0, itemCount) or repeated are dropped (first occurrence wins).
33+
*/
34+
export function parseRankedIndices(raw: string, itemCount: number): number[] {
35+
const match = /\[[\s\S]*\]/.exec(raw);
36+
if (!match) return [];
37+
38+
let parsed: unknown;
39+
try {
40+
parsed = JSON.parse(match[0]);
41+
} catch {
42+
return [];
43+
}
44+
if (!Array.isArray(parsed)) return [];
45+
46+
const seen = new Set<number>();
47+
const out: number[] = [];
48+
for (const value of parsed) {
49+
const index = typeof value === "number" ? value : Number(value);
50+
if (
51+
!Number.isInteger(index) ||
52+
index < 0 ||
53+
index >= itemCount ||
54+
seen.has(index)
55+
) {
56+
continue;
57+
}
58+
seen.add(index);
59+
out.push(index);
60+
}
61+
return out;
62+
}
63+
64+
/**
65+
* Places ranked items first (in model order), then appends any remaining
66+
* items in their original order — guarantees a full, valid list even when
67+
* the ranker returns a partial or empty result.
68+
*/
69+
export function mergeRankedOrder(
70+
items: FeedItem[],
71+
rankedIndices: number[],
72+
): FeedItem[] {
73+
const used = new Set<number>();
74+
const out: FeedItem[] = [];
75+
for (const index of rankedIndices) {
76+
const item = items[index];
77+
if (!item || used.has(index)) continue;
78+
used.add(index);
79+
out.push(item);
80+
}
81+
for (let i = 0; i < items.length; i++) {
82+
if (!used.has(i)) out.push(items[i]!);
83+
}
84+
return out;
85+
}
86+
87+
/** Stable identifier for a FeedItem — the domain type has no numeric id,
88+
* so (source, externalId) is the natural key for referencing an item
89+
* across requests (e.g. in a cached ranking). */
90+
export function itemKey(item: FeedItem): string {
91+
return `${item.source} ${item.externalId}`;
92+
}
93+
94+
/**
95+
* Projects a previously-computed ranked key order (from a cached ranking,
96+
* possibly over a different/smaller pool) onto a freshly-fetched item
97+
* list: ranked items appear first, in cached order; anything not in
98+
* rankedKeys — new items, or items the cache doesn't cover — keeps its
99+
* original relative order at the end. Unlike mergeRankedOrder, this
100+
* tolerates the two lists having different lengths or contents, since
101+
* `items` is re-fetched live while rankedKeys may be stale or partial.
102+
*/
103+
export function mergeRankedKeysOrder(
104+
items: FeedItem[],
105+
rankedKeys: string[],
106+
): FeedItem[] {
107+
const byKey = new Map(items.map((item) => [itemKey(item), item]));
108+
const used = new Set<string>();
109+
const out: FeedItem[] = [];
110+
for (const key of rankedKeys) {
111+
const item = byKey.get(key);
112+
if (!item || used.has(key)) continue;
113+
used.add(key);
114+
out.push(item);
115+
}
116+
for (const item of items) {
117+
if (!used.has(itemKey(item))) out.push(item);
118+
}
119+
return out;
120+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { FeedItem } from "../../domain.ts";
2+
import type { LlmRanker } from "../../ports.ts";
3+
4+
/** In-memory LlmRanker for tests — no network calls. `script` decides the
5+
* returned ranking (or throws, to simulate a transport/availability failure). */
6+
export class FakeLlmRanker implements LlmRanker {
7+
constructor(
8+
private readonly script: (
9+
items: FeedItem[],
10+
interests: string,
11+
) => number[],
12+
) {}
13+
14+
async rank(items: FeedItem[], interests: string): Promise<number[]> {
15+
return this.script(items, interests);
16+
}
17+
}

core/personalize/test/rank.spec.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { FeedItem } from "../../domain.ts";
3+
import {
4+
buildRankingPrompt,
5+
itemKey,
6+
mergeRankedKeysOrder,
7+
mergeRankedOrder,
8+
parseRankedIndices,
9+
} from "../rank.ts";
10+
import { FakeLlmRanker } from "./fakeLlmRanker.ts";
11+
12+
function item(overrides: Partial<FeedItem>): FeedItem {
13+
return {
14+
source: "hackernews",
15+
externalId: "1",
16+
title: "title",
17+
url: "https://example.com",
18+
sourceRank: 1,
19+
metadata: {},
20+
...overrides,
21+
};
22+
}
23+
24+
describe("buildRankingPrompt", () => {
25+
it("numbers items by index and includes title, summary, and interests", () => {
26+
const items = [
27+
item({ title: "Rust async runtime", summary: "a new executor" }),
28+
item({ title: "No summary item" }),
29+
];
30+
const prompt = buildRankingPrompt(items, "rust, distributed systems");
31+
expect(prompt).toContain("0: Rust async runtime — a new executor");
32+
expect(prompt).toContain("1: No summary item");
33+
expect(prompt).toContain("Reader interests: rust, distributed systems");
34+
});
35+
36+
it("truncates oversized interests text", () => {
37+
const prompt = buildRankingPrompt([], "x".repeat(1000));
38+
const line = prompt.split("\n").find((l) => l.startsWith("Reader interests:"))!;
39+
expect(line.length).toBeLessThan(320);
40+
});
41+
});
42+
43+
describe("parseRankedIndices", () => {
44+
it("parses a well-formed JSON array", () => {
45+
expect(parseRankedIndices("[2, 0, 1]", 3)).toEqual([2, 0, 1]);
46+
});
47+
48+
it("tolerates surrounding prose/markdown around the array", () => {
49+
expect(parseRankedIndices("Sure! ```json\n[1, 0]\n```", 2)).toEqual([
50+
1, 0,
51+
]);
52+
});
53+
54+
it("drops out-of-range and duplicate indices", () => {
55+
expect(parseRankedIndices("[1, 1, 5, -1, 0]", 2)).toEqual([1, 0]);
56+
});
57+
58+
it("returns [] for garbage output instead of throwing", () => {
59+
expect(parseRankedIndices("not even close to json", 3)).toEqual([]);
60+
expect(parseRankedIndices("", 3)).toEqual([]);
61+
});
62+
});
63+
64+
describe("mergeRankedOrder", () => {
65+
const items = [item({ externalId: "a" }), item({ externalId: "b" }), item({ externalId: "c" })];
66+
67+
it("places ranked items first in model order, then appends the rest", () => {
68+
const merged = mergeRankedOrder(items, [2, 0]);
69+
expect(merged.map((i) => i.externalId)).toEqual(["c", "a", "b"]);
70+
});
71+
72+
it("passes through original order when ranking is empty", () => {
73+
expect(mergeRankedOrder(items, []).map((i) => i.externalId)).toEqual([
74+
"a",
75+
"b",
76+
"c",
77+
]);
78+
});
79+
80+
it("ignores indices it doesn't recognize without throwing", () => {
81+
const merged = mergeRankedOrder(items, [99, 1]);
82+
expect(merged.map((i) => i.externalId)).toEqual(["b", "a", "c"]);
83+
});
84+
});
85+
86+
describe("mergeRankedKeysOrder", () => {
87+
const items = [
88+
item({ source: "hackernews", externalId: "a" }),
89+
item({ source: "github", externalId: "b" }),
90+
item({ source: "hackernews", externalId: "c" }),
91+
];
92+
93+
it("places ranked items first by cached key order, then appends the rest", () => {
94+
const merged = mergeRankedKeysOrder(items, [
95+
itemKey(items[2]!),
96+
itemKey(items[0]!),
97+
]);
98+
expect(merged.map((i) => i.externalId)).toEqual(["c", "a", "b"]);
99+
});
100+
101+
it("tolerates a cached key for an item that no longer exists", () => {
102+
const merged = mergeRankedKeysOrder(items, [
103+
"hackernews missing-id",
104+
itemKey(items[1]!),
105+
]);
106+
expect(merged.map((i) => i.externalId)).toEqual(["b", "a", "c"]);
107+
});
108+
109+
it("tolerates duplicate cached keys", () => {
110+
const key = itemKey(items[1]!);
111+
const merged = mergeRankedKeysOrder(items, [key, key]);
112+
expect(merged.map((i) => i.externalId)).toEqual(["b", "a", "c"]);
113+
});
114+
115+
it("passes through original order when there is no cached ranking", () => {
116+
expect(mergeRankedKeysOrder(items, []).map((i) => i.externalId)).toEqual([
117+
"a",
118+
"b",
119+
"c",
120+
]);
121+
});
122+
});
123+
124+
describe("FakeLlmRanker + degrade pattern", () => {
125+
const items = [item({ externalId: "a" }), item({ externalId: "b" })];
126+
127+
it("a successful rank reorders items", async () => {
128+
const ranker = new FakeLlmRanker(() => [1, 0]);
129+
const ranked = await ranker.rank(items, "anything");
130+
expect(mergeRankedOrder(items, ranked).map((i) => i.externalId)).toEqual([
131+
"b",
132+
"a",
133+
]);
134+
});
135+
136+
it("a thrown error is the caller's signal to degrade to chronological order", async () => {
137+
const ranker = new FakeLlmRanker(() => {
138+
throw new Error("model unavailable");
139+
});
140+
await expect(ranker.rank(items, "anything")).rejects.toThrow(
141+
"model unavailable",
142+
);
143+
});
144+
});

core/ports.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,17 @@ export interface FeedRepository {
2929
): Promise<FeedItem[]>;
3030
countTotalItems(): Promise<number>;
3131
}
32+
33+
/**
34+
* Ranks `items` by relevance to a free-text `interests` description.
35+
* Returns a best-effort ordering of 0-based indices into `items`, most
36+
* relevant first — FeedItem has no stable numeric id, so position in the
37+
* input array is the only identifier the ranker needs. The result may be a
38+
* subset (the caller appends any indices the ranker omitted, in their
39+
* original order) and may be empty if ranking failed entirely; it must
40+
* never throw for a malformed model response, only for genuine
41+
* transport/availability failures.
42+
*/
43+
export interface LlmRanker {
44+
rank(items: FeedItem[], interests: string): Promise<number[]>;
45+
}

core/render.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,21 @@ export function renderIndexPage(data: PageData): string {
298298
</div>
299299
</section>
300300
</div>
301+
<section class="config-section" aria-labelledby="config-personalization-title">
302+
<div class="config-section-header">
303+
<h3 id="config-personalization-title" class="config-section-title">AI personalization</h3>
304+
<p class="config-section-copy">Rerank your feed by your interests using AI. Stored only in this browser.</p>
305+
</div>
306+
<div class="config-options">
307+
<label class="config-option">
308+
<input type="checkbox" data-ai-personalization-toggle />
309+
<span class="config-option-body">
310+
<span class="config-option-title">Enable personalized ranking</span>
311+
</span>
312+
</label>
313+
</div>
314+
<textarea class="interests-input" data-interests-input rows="3" maxlength="300" placeholder="fullstack development, software engineering, computer science, AI, machine learning, LLMs"></textarea>
315+
</section>
301316
<section class="config-section" aria-labelledby="config-sources-title">
302317
<div class="config-section-header">
303318
<h3 id="config-sources-title" class="config-section-title">Sources</h3>
@@ -389,6 +404,7 @@ export function renderIndexPage(data: PageData): string {
389404
</dialog>
390405
${errorsBlock}
391406
<main class="shell page-body">
407+
<p class="personalized-indicator is-hidden" data-personalized-indicator>✦ Personalized by AI</p>
392408
<section class="cards-grid${cardsHiddenClass}" data-card-grid data-current-source="${escapeHtml(data.currentSource)}" data-page-size="${data.pageSize}" data-has-next="${data.hasNext ? "true" : "false"}" aria-busy="false">
393409
${data.cards.map(renderCard).join("\n ")}
394410
</section>

platforms/cloudflare/src/env.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ export interface Env {
55
* refreshes as fresh invocations (fresh 10ms CPU budget each), instead of
66
* a public-internet self-fetch that would need a known hostname. */
77
SELF: Fetcher;
8+
/** Workers AI binding backing the "For You" LlmRanker — see
9+
* platforms/cloudflare/src/llmRanker.ts. No secret needed. */
10+
AI: Ai;
811
REFRESH_SECRET: string;
912
APP_VERSION?: string;
1013
FEEDREADER_ITEMS_PER_SOURCE?: string;
1114
FEEDREADER_USER_AGENT?: string;
15+
FEEDREADER_PERSONALIZE_POOL_SIZE?: string;
1216
}

0 commit comments

Comments
 (0)