feat: replace per-author cap with interleaving to avoid dropping posts in feed and home

The old `diversify_by_author` capped each author to 2 posts per page, silently dropping excess rows. The new `interleave_by_author` reorders the full result set so no two consecutive posts share an author, preserving every post while breaking up same-author runs. `paginate_diverse` loses its `max_per_author` and `pool` parameters; the home route now fetches exactly 6 rows and interleaves them instead of over-fetching 50 and capping. Tests are rewritten to verify interleaving behavior and per-author chronological order preservation.
This commit is contained in:
2026-06-14 16:50:30 +00:00
parent 9ed611097d
commit aeea551e4b
9 changed files with 81 additions and 59 deletions
+37 -30
View File
@@ -2222,18 +2222,36 @@ def paginate(
return rows, next_cursor
def diversify_by_author(rows, max_per_author=2, limit=None, uid_key="user_uid"):
counts: dict = {}
kept = []
for row in rows:
def interleave_by_author(rows, uid_key="user_uid"):
queues: dict = {}
appearance: list = []
for position, row in enumerate(rows):
owner = row.get(uid_key)
if counts.get(owner, 0) >= max_per_author:
continue
counts[owner] = counts.get(owner, 0) + 1
kept.append(row)
if limit is not None and len(kept) >= limit:
break
return kept
queue = queues.get(owner)
if queue is None:
queue = queues[owner] = []
appearance.append(owner)
queue.append((position, row))
spread = []
last_owner = object()
while len(spread) < len(rows):
chosen = None
for owner in appearance:
queue = queues[owner]
if not queue or owner == last_owner:
continue
count = len(queue)
head_position = queue[0][0]
if (
chosen is None
or count > chosen[0]
or (count == chosen[0] and head_position < chosen[1])
):
chosen = (count, head_position, owner)
owner = last_owner if chosen is None else chosen[2]
spread.append(queues[owner].pop(0)[1])
last_owner = owner
return spread
def paginate_diverse(
@@ -2242,29 +2260,18 @@ def paginate_diverse(
before=None,
order=None,
cursor_field="created_at",
max_per_author=2,
uid_key="user_uid",
pool=200,
**filters,
):
order = order or ["-" + cursor_field]
clauses = list(clauses)
if table.has_column("deleted_at") and "deleted_at" not in filters:
clauses.append(table.table.columns.deleted_at.is_(None))
if before:
clauses.append(table.table.columns[cursor_field] < before)
rows = list(table.find(*clauses, **filters, order_by=order, _limit=pool + 1))
exhausted = len(rows) <= pool
rows = rows[:pool]
kept = diversify_by_author(
rows, max_per_author=max_per_author, limit=PAGE_SIZE, uid_key=uid_key
rows, next_cursor = paginate(
table,
*clauses,
before=before,
order=order,
cursor_field=cursor_field,
**filters,
)
if not kept:
return [], None
last_cursor = kept[-1][cursor_field]
older_exists = (not exhausted) or any(r[cursor_field] < last_cursor for r in rows)
next_cursor = last_cursor if older_exists else None
return kept, next_cursor
return interleave_by_author(rows, uid_key=uid_key), next_cursor
def get_user_post_count(user_uid: str) -> int:
+2 -2
View File
@@ -695,7 +695,7 @@ four ways to sign requests.
summary=(
"The home page. Guests see the marketing splash; authenticated users see a "
"personalized home (welcome, feed shortcut, latest posts, news). It no longer "
"redirects to /feed. The latest-posts section shows at most two posts per author."
"redirects to /feed. The latest-posts section interleaves authors so no two consecutive posts share an author."
),
auth="public",
interactive=True,
@@ -705,7 +705,7 @@ four ways to sign requests.
method="GET",
path="/feed",
title="Browse the feed",
summary="The main post feed. Returns an HTML page. Each page shows at most two posts per author.",
summary="The main post feed. Returns an HTML page. Each page interleaves authors so no two consecutive posts share an author.",
auth="public",
interactive=True,
params=[
+3 -3
View File
@@ -31,7 +31,7 @@ from devplacepy.database import (
get_news_images_by_uids,
get_setting,
get_int_setting,
diversify_by_author,
interleave_by_author,
get_user_post_count,
)
from devplacepy.templating import templates
@@ -506,9 +506,9 @@ async def landing(request: Request):
if "posts" in db.tables:
posts_table = get_table("posts")
raw_posts = list(
posts_table.find(deleted_at=None, order_by=["-created_at"], _limit=50)
posts_table.find(deleted_at=None, order_by=["-created_at"], _limit=6)
)
raw_posts = diversify_by_author(raw_posts, max_per_author=2, limit=6)
raw_posts = interleave_by_author(raw_posts)
if raw_posts:
post_uids = [p["uid"] for p in raw_posts]
author_uids = [p["user_uid"] for p in raw_posts]
+6
View File
@@ -51,6 +51,12 @@ DOCS_PAGES = [
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "feed",
"title": "The feed",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "devii",
"title": "Devii Assistant",
-2
View File
@@ -52,7 +52,6 @@ def get_feed_posts(
*search_clauses,
before=before,
order=order,
max_per_author=2,
)
else:
filters = {"topic": topic} if topic else {}
@@ -61,7 +60,6 @@ def get_feed_posts(
*search_clauses,
before=before,
order=order,
max_per_author=2,
**filters,
)