Extend dismissed-exclusion to mood feeds for consistency

Mood feeds now honor the same dismissed list as the brief: /api/feed accepts an
exclude param (over-fetching to stay full), and the client passes the persisted
dismissed set. Swapping a story away now keeps it gone everywhere — brief and
browse — not just on the home view. Also simplified the feed filter path to the
shared _prefs_sql_kw helper.

Tests: feed exclude (91 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-31 13:29:27 +00:00
parent 0ccd5554d2
commit 3fe7c4f228
3 changed files with 25 additions and 27 deletions
+2 -1
View File
@@ -83,7 +83,8 @@
} else { } else {
const mood = moods.find((m) => m.key === key); const mood = moods.find((m) => m.key === key);
const q = P.param(P.merge(userPrefs, mood?.filter ?? {})); const q = P.param(P.merge(userPrefs, mood?.filter ?? {}));
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}`)).items; const ex = Array.from(dismissed).join(',');
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
remember(feed); remember(feed);
} }
} catch (e) { } catch (e) {
+17 -26
View File
@@ -261,6 +261,7 @@ def create_app() -> FastAPI:
limit: int = Query(30, ge=1, le=100), limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
prefs: str | None = Query(None), prefs: str | None = Query(None),
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
) -> FeedResponse: ) -> FeedResponse:
if topic and topic.lower() not in TOPICS: if topic and topic.lower() not in TOPICS:
raise HTTPException(400, f"unknown topic: {topic}") raise HTTPException(400, f"unknown topic: {topic}")
@@ -268,36 +269,26 @@ def create_app() -> FastAPI:
raise HTTPException(400, f"unknown flavor: {flavor}") raise HTTPException(400, f"unknown flavor: {flavor}")
fp = prefs_from_json(prefs) fp = prefs_from_json(prefs)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
# Categorical filters (include/mute topics+flavors incl. active pauses,
# cortisol ceiling) go to SQL so nothing is truncated by ranking. Only
# word-boundary avoid-terms and dismissals need a Python pass.
kw = _prefs_sql_kw(fp, now)
with get_conn() as conn: with get_conn() as conn:
if fp.is_empty(): if fp.avoid_terms or excl:
rows = queries.feed( # Over-fetch enough to cover what the Python pass might remove.
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=limit, offset=offset fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))
raw = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
limit=fetch_n, offset=0, **kw,
) )
kept = [a for a in filter_articles(raw, fp, now) if a["id"] not in excl]
rows = kept[offset : offset + limit]
else: else:
# Categorical filters (include/mute topics+flavors incl. active rows = queries.feed(
# pauses, cortisol ceiling) go to SQL so nothing is truncated by conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
# ranking. Only word-boundary avoid-terms need a Python pass, so limit=limit, offset=offset, **kw,
# over-fetch just enough to cover what they might remove.
kw = dict(
include_topics=fp.include_topics or None,
include_flavors=fp.include_flavors or None,
mute_topics=list(fp.muted_topics(now)) or None,
mute_flavors=list(fp.muted_flavors(now)) or None,
max_cortisol=fp.max_cortisol,
max_ragebait=fp.max_ragebait,
) )
if fp.avoid_terms:
raw = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
limit=min(2000, (offset + limit) * 4 + 50), offset=0, **kw,
)
kept = filter_articles(raw, fp, now) # drops avoid-term matches
rows = kept[offset : offset + limit]
else:
rows = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
limit=limit, offset=offset, **kw,
)
# Keep the top of a browse view readable: stable-sort paywalled items # Keep the top of a browse view readable: stable-sort paywalled items
# below readable ones (composite order preserved within each group). # below readable ones (composite order preserved within each group).
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"])) rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
+6
View File
@@ -74,3 +74,9 @@ def test_brief_filters_down_without_refill(client):
def test_category_counts_match_filtered_feed(client): def test_category_counts_match_filtered_feed(client):
counts = client.get("/api/category-counts", params={"prefs": json.dumps({"mute_topics": ["health"]})}).json() counts = client.get("/api/category-counts", params={"prefs": json.dumps({"mute_topics": ["health"]})}).json()
assert all(c["topic"] != "health" for c in counts) assert all(c["topic"] != "health" for c in counts)
def test_feed_excludes_dismissed(client):
r = client.get("/api/feed", params={"exclude": "1"})
ids = [i["id"] for i in r.json()["items"]]
assert 1 not in ids