Overhaul For You feed ranking and freshness
Ranking improvements: - Wider candidate pool (4x limit) with ±12pt score perturbation so same-score videos shuffle differently each load - Recent channel engagement signal: channels watched in past 30 days get a +4pts/watch boost - Bail penalty: -25pts for videos started but abandoned before 20% - Impression penalty: -3pts per prior feed appearance (capped at 10), so repeatedly-skipped videos sink naturally - rn cap raised to 5 for more candidates; Python-side sampling picks top limit Feed UX: - Reshuffle button now available on For You (ranked) mode, not just Explore - shuffleKey now always included in query key (not just random mode) - Ranked mode staleTime reduced from 10min to 90s Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,7 @@ def on_startup():
|
|||||||
note TEXT DEFAULT '',
|
note TEXT DEFAULT '',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)""",
|
)""",
|
||||||
|
"ALTER TABLE user_videos ADD COLUMN feed_shown_count INTEGER NOT NULL DEFAULT 0",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
db.execute(text(col_sql))
|
db.execute(text(col_sql))
|
||||||
|
|||||||
@@ -297,6 +297,8 @@ def home_feed(
|
|||||||
]
|
]
|
||||||
|
|
||||||
# mode == "ranked" (default)
|
# mode == "ranked" (default)
|
||||||
|
import random as _random
|
||||||
|
candidate_limit = limit * 4 # wider pool for tier sampling
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text(f"""
|
text(f"""
|
||||||
WITH channel_stats AS (
|
WITH channel_stats AS (
|
||||||
@@ -305,7 +307,8 @@ def home_feed(
|
|||||||
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
|
COUNT(CASE WHEN uv.watched = 1 THEN 1 END) AS watched_count,
|
||||||
COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_count,
|
COUNT(CASE WHEN uv.liked = 1 THEN 1 END) AS liked_count,
|
||||||
SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum,
|
SUM(CASE WHEN uv.rating IS NOT NULL THEN uv.rating ELSE 0 END) AS rating_sum,
|
||||||
AVG(CASE WHEN uv.completion_percent IS NOT NULL THEN uv.completion_percent END) AS avg_completion_pct
|
AVG(CASE WHEN uv.completion_percent IS NOT NULL THEN uv.completion_percent END) AS avg_completion_pct,
|
||||||
|
COUNT(CASE WHEN uv.watched = 1 AND uv.last_watched_at > datetime('now', '-30 days') THEN 1 END) AS recent_watches
|
||||||
FROM videos v
|
FROM videos v
|
||||||
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
LEFT JOIN user_videos uv ON v.id = uv.video_id AND uv.user_id = :user_id
|
||||||
GROUP BY v.channel_id
|
GROUP BY v.channel_id
|
||||||
@@ -326,7 +329,9 @@ def home_feed(
|
|||||||
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 5.0
|
(SQRT(CAST(COALESCE(cs.watched_count, 0) AS REAL)) * 5.0
|
||||||
+ COALESCE(cs.liked_count, 0) * 10.0
|
+ COALESCE(cs.liked_count, 0) * 10.0
|
||||||
+ COALESCE(cs.rating_sum, 0) * 8.0
|
+ COALESCE(cs.rating_sum, 0) * 8.0
|
||||||
+ COALESCE(cs.avg_completion_pct, 50.0) * 0.08) * :w_channel
|
+ COALESCE(cs.avg_completion_pct, 50.0) * 0.08
|
||||||
|
+ COALESCE(cs.recent_watches, 0) * 4.0
|
||||||
|
) * :w_channel
|
||||||
+ MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency
|
+ MAX(COALESCE(julianday(v.published_at) - julianday('now'), -90), -365) * :w_recency
|
||||||
+ COALESCE((
|
+ COALESCE((
|
||||||
SELECT COALESCE(SUM(uta.score), 0)
|
SELECT COALESCE(SUM(uta.score), 0)
|
||||||
@@ -336,6 +341,10 @@ def home_feed(
|
|||||||
OR instr(LOWER(COALESCE(v.tags, '')), '"' || uta.tag || '"') > 0)
|
OR instr(LOWER(COALESCE(v.tags, '')), '"' || uta.tag || '"') > 0)
|
||||||
LIMIT 5
|
LIMIT 5
|
||||||
), 0) * :w_affinity
|
), 0) * :w_affinity
|
||||||
|
- CASE WHEN COALESCE(uv.completion_percent, 100) < 20
|
||||||
|
AND COALESCE(uv.watch_progress_seconds, 0) > 30
|
||||||
|
THEN 25 ELSE 0 END
|
||||||
|
- 3 * MIN(COALESCE(uv.feed_shown_count, 0), 10)
|
||||||
AS score,
|
AS score,
|
||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY v.channel_id
|
PARTITION BY v.channel_id
|
||||||
@@ -352,17 +361,38 @@ def home_feed(
|
|||||||
{duration_clause}
|
{duration_clause}
|
||||||
)
|
)
|
||||||
SELECT * FROM scored
|
SELECT * FROM scored
|
||||||
WHERE rn <= 3
|
WHERE rn <= 5
|
||||||
ORDER BY score DESC, RANDOM()
|
ORDER BY score DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :candidate_limit OFFSET :offset
|
||||||
"""),
|
"""),
|
||||||
{"user_id": current_user.id, "limit": limit, "offset": offset, "hide_watched": 1 if hide_watched else 0,
|
{"user_id": current_user.id, "candidate_limit": candidate_limit, "offset": offset,
|
||||||
|
"hide_watched": 1 if hide_watched else 0,
|
||||||
"w_recency": w_recency, "w_affinity": w_affinity, "w_channel": w_channel},
|
"w_recency": w_recency, "w_affinity": w_affinity, "w_channel": w_channel},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
|
|
||||||
|
# Tier-based sampling with score perturbation so the feed varies each load
|
||||||
|
candidates = [dict(r) for r in rows]
|
||||||
|
for c in candidates:
|
||||||
|
c["_ps"] = c["score"] + _random.uniform(-12, 12)
|
||||||
|
candidates.sort(key=lambda x: x["_ps"], reverse=True)
|
||||||
|
top = candidates[:limit]
|
||||||
|
|
||||||
|
# Track impressions for page 0 (first visit) — penalises videos shown but ignored
|
||||||
|
if offset == 0 and top:
|
||||||
|
for item in top:
|
||||||
|
if not item["watched"]:
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO user_videos (user_id, video_id, feed_shown_count)
|
||||||
|
VALUES (:uid, :vid, 1)
|
||||||
|
ON CONFLICT (user_id, video_id)
|
||||||
|
DO UPDATE SET feed_shown_count = feed_shown_count + 1
|
||||||
|
"""), {"uid": current_user.id, "vid": item["id"]})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
followed = [
|
followed = [
|
||||||
VideoDetail(**{k: v for k, v in dict(r).items() if k not in ("watched", "score", "rn")},
|
VideoDetail(**{k: v for k, v in item.items() if k not in ("watched", "score", "rn", "_ps")},
|
||||||
is_watched=bool(r["watched"]))
|
is_watched=bool(item["watched"]))
|
||||||
for r in rows
|
for item in top
|
||||||
]
|
]
|
||||||
|
|
||||||
# Inject discovery cards on every page: 1 every 5 followed cards.
|
# Inject discovery cards on every page: 1 every 5 followed cards.
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data: feedData = [], isLoading: loadingFeed } = useQuery({
|
const { data: feedData = [], isLoading: loadingFeed } = useQuery({
|
||||||
queryKey: ["home-feed", mode, page, hideWatched, duration, mode === "random" ? shuffleKey : 0],
|
queryKey: ["home-feed", mode, page, hideWatched, duration, shuffleKey],
|
||||||
queryFn: () => homeFeed(page, PAGE_SIZE, mode, duration).then((r) => r.data),
|
queryFn: () => homeFeed(page, PAGE_SIZE, mode, duration).then((r) => r.data),
|
||||||
staleTime: 10 * 60_000,
|
staleTime: mode === "ranked" ? 90_000 : 10 * 60_000,
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,9 +195,13 @@ export default function Home() {
|
|||||||
{mode === "chronological" && (
|
{mode === "chronological" && (
|
||||||
<p className="text-xs text-zinc-600">All videos from channels you follow, newest first.</p>
|
<p className="text-xs text-zinc-600">All videos from channels you follow, newest first.</p>
|
||||||
)}
|
)}
|
||||||
{mode === "random" && (
|
{(mode === "ranked" || mode === "random") && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-zinc-600">Random from your discovery pool — no weighting, no ranking.</p>
|
<p className="text-xs text-zinc-600">
|
||||||
|
{mode === "ranked"
|
||||||
|
? "Ranked by your taste — reshuffles show a fresh mix."
|
||||||
|
: "Random from your discovery pool — no weighting, no ranking."}
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleReshuffle}
|
onClick={handleReshuffle}
|
||||||
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4"
|
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4"
|
||||||
|
|||||||
Reference in New Issue
Block a user