diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index b38ade2..7676f67 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -83,7 +83,8 @@ } else { const mood = moods.find((m) => m.key === key); 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); } } catch (e) { diff --git a/goodnews/api.py b/goodnews/api.py index 104c618..f33aa93 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -261,6 +261,7 @@ def create_app() -> FastAPI: limit: int = Query(30, ge=1, le=100), offset: int = Query(0, ge=0), prefs: str | None = Query(None), + exclude: str = Query("", description="comma-separated article ids the reader has dismissed"), ) -> FeedResponse: if topic and topic.lower() not in TOPICS: raise HTTPException(400, f"unknown topic: {topic}") @@ -268,36 +269,26 @@ def create_app() -> FastAPI: raise HTTPException(400, f"unknown flavor: {flavor}") fp = prefs_from_json(prefs) 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: - if fp.is_empty(): - rows = queries.feed( - conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=limit, offset=offset + if fp.avoid_terms or excl: + # Over-fetch enough to cover what the Python pass might remove. + 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: - # 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 need a Python pass, so - # 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, + rows = queries.feed( + conn, topic=topic, flavor=flavor, accepted_only=accepted_only, + limit=limit, offset=offset, **kw, ) - 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 # below readable ones (composite order preserved within each group). rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"])) diff --git a/tests/test_api.py b/tests/test_api.py index be4f9fd..427d146 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -74,3 +74,9 @@ def test_brief_filters_down_without_refill(client): def test_category_counts_match_filtered_feed(client): counts = client.get("/api/category-counts", params={"prefs": json.dumps({"mute_topics": ["health"]})}).json() 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