diff --git a/goodnews/api.py b/goodnews/api.py index 4b55324..f35a0cf 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -57,6 +57,18 @@ def get_conn(): 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]: """Lead with a gentle, readable, ideally illustrated story. @@ -303,15 +315,28 @@ def create_app() -> FastAPI: prefs: str | None = Query(None), ) -> BriefResponse: fp = prefs_from_json(prefs) + now = datetime.now(timezone.utc) with get_conn() as conn: data = queries.brief(conn, brief_date=date, limit=limit) - items = data["items"] - if not fp.is_empty(): - # MVP: filter the stored brief DOWN; no refill from outside the brief. - # Runs before hero selection, so personal avoid-terms take precedence. - items = filter_articles(items, fp, datetime.now(timezone.utc)) + items = data["items"] + if not fp.is_empty(): + # Apply personal boundaries (avoid-terms take precedence over curation). + items = filter_articles(items, fp, now) + # 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 - # in the five, just not as the first thing seen). + # in the set, just not as the first thing seen). items = _pick_lead(items) return BriefResponse( brief_date=data["brief_date"], diff --git a/tests/test_brief_refill.py b/tests/test_brief_refill.py new file mode 100644 index 0000000..124ce03 --- /dev/null +++ b/tests/test_brief_refill.py @@ -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"])