Personalized brief: refill to full count when a boundary hides a highlight
When a reader's boundary (avoid-term, muted topic/flavor, pause) removes a brief item, top the highlights back up with other readable, boundary-respecting good news instead of showing fewer cards — so 'Highlights from Today' stays full and still honors what they don't want to see. (Reverses the earlier filter-down-only MVP, now that the count is fixed at seven.) - /api/brief: after filtering by prefs, refill from the accepted pool (same categorical SQL filters + avoid-terms) excluding already-shown items. - Shared _prefs_sql_kw helper for feed/replacement/brief filters. - Tests: refill stays full and respects mute + avoid-terms (89 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+29
-4
@@ -57,6 +57,18 @@ def get_conn():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _prefs_sql_kw(fp, now) -> dict:
|
||||||
|
"""Categorical prefs → queries.feed keyword filters (avoid-terms stay Python)."""
|
||||||
|
return 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _pick_lead(items: list[dict]) -> list[dict]:
|
def _pick_lead(items: list[dict]) -> list[dict]:
|
||||||
"""Lead with a gentle, readable, ideally illustrated story.
|
"""Lead with a gentle, readable, ideally illustrated story.
|
||||||
|
|
||||||
@@ -303,15 +315,28 @@ def create_app() -> FastAPI:
|
|||||||
prefs: str | None = Query(None),
|
prefs: str | None = Query(None),
|
||||||
) -> BriefResponse:
|
) -> BriefResponse:
|
||||||
fp = prefs_from_json(prefs)
|
fp = prefs_from_json(prefs)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
data = queries.brief(conn, brief_date=date, limit=limit)
|
data = queries.brief(conn, brief_date=date, limit=limit)
|
||||||
items = data["items"]
|
items = data["items"]
|
||||||
if not fp.is_empty():
|
if not fp.is_empty():
|
||||||
# MVP: filter the stored brief DOWN; no refill from outside the brief.
|
# Apply personal boundaries (avoid-terms take precedence over curation).
|
||||||
# Runs before hero selection, so personal avoid-terms take precedence.
|
items = filter_articles(items, fp, now)
|
||||||
items = filter_articles(items, fp, datetime.now(timezone.utc))
|
# Keep the highlights full: if a boundary hid a story, top up with
|
||||||
|
# other readable, boundary-respecting good news rather than show fewer.
|
||||||
|
if len(items) < limit:
|
||||||
|
have = {a["id"] for a in items}
|
||||||
|
pool = queries.feed(
|
||||||
|
conn, accepted_only=True, limit=limit * 5 + 20, offset=0, **_prefs_sql_kw(fp, now)
|
||||||
|
)
|
||||||
|
for a in filter_articles(pool, fp, now):
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
if a["id"] not in have:
|
||||||
|
items.append(a)
|
||||||
|
have.add(a["id"])
|
||||||
# Lead with a gentle, readable story (charged or paywalled stories stay
|
# Lead with a gentle, readable story (charged or paywalled stories stay
|
||||||
# in the five, just not as the first thing seen).
|
# in the set, just not as the first thing seen).
|
||||||
items = _pick_lead(items)
|
items = _pick_lead(items)
|
||||||
return BriefResponse(
|
return BriefResponse(
|
||||||
brief_date=data["brief_date"],
|
brief_date=data["brief_date"],
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from goodnews.db import connect, init_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
db = tmp_path / "t.sqlite3"
|
||||||
|
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
||||||
|
conn = connect(db); init_db(conn)
|
||||||
|
conn.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',6)")
|
||||||
|
|
||||||
|
def add(aid, topic, in_brief_rank=None, title=None):
|
||||||
|
conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,published_at,url_hash) "
|
||||||
|
"VALUES (?,1,?,?, '2026-05-31T10:00:00+00:00', ?)",
|
||||||
|
(aid, f"http://s/{aid}", title or f"t{aid}", f"h{aid}"))
|
||||||
|
conn.execute("INSERT INTO article_scores (article_id,constructive_score,agency_score,human_benefit_score,"
|
||||||
|
"cortisol_score,ragebait_score,pr_risk_score,accepted,topic,flavor) "
|
||||||
|
"VALUES (?,7,2,2,1,0,2,1,?,'solution')", (aid, topic))
|
||||||
|
if in_brief_rank:
|
||||||
|
conn.execute("INSERT INTO daily_brief_items (brief_id,article_id,rank) VALUES (1,?,?)", (aid, in_brief_rank))
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO daily_briefs (id,brief_date,title) VALUES (1,'2026-05-31','B')")
|
||||||
|
add(1, "health", 1) # will be muted
|
||||||
|
add(2, "science", 2)
|
||||||
|
add(3, "environment", 3)
|
||||||
|
add(4, "community") # refill candidates (accepted, not in brief)
|
||||||
|
add(5, "animals")
|
||||||
|
add(6, "culture")
|
||||||
|
conn.commit(); conn.close()
|
||||||
|
from goodnews.api import create_app
|
||||||
|
return TestClient(create_app())
|
||||||
|
|
||||||
|
|
||||||
|
def test_brief_refills_to_full_count_under_boundary(client):
|
||||||
|
full = client.get("/api/brief", params={"limit": 3}).json()
|
||||||
|
assert len(full["items"]) == 3
|
||||||
|
|
||||||
|
muted = client.get("/api/brief", params={"limit": 3, "prefs": json.dumps({"mute_topics": ["health"]})}).json()
|
||||||
|
topics = [i["topic"] for i in muted["items"]]
|
||||||
|
assert len(muted["items"]) == 3 # stayed full (refilled)
|
||||||
|
assert "health" not in topics # boundary respected
|
||||||
|
|
||||||
|
|
||||||
|
def test_brief_refill_respects_avoid_terms(client):
|
||||||
|
# avoid a word in the health item's title
|
||||||
|
client_resp = client.get("/api/brief", params={"limit": 3, "prefs": json.dumps({"avoid_terms": ["t1"]})}).json()
|
||||||
|
assert len(client_resp["items"]) == 3
|
||||||
|
assert all(i["id"] != 1 for i in client_resp["items"])
|
||||||
Reference in New Issue
Block a user