diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte index dfecb1e..697c922 100644 --- a/frontend/src/lib/components/ArticleCard.svelte +++ b/frontend/src/lib/components/ArticleCard.svelte @@ -36,8 +36,8 @@
{#if article.topic}{article.topic}{/if} {#if article.flavor}{article.flavor}{/if} - {article.source}
+
{article.source}

{article.title}

@@ -85,7 +85,8 @@ padding: 2px 9px; font-weight: 600; text-transform: capitalize; } .tag.soft { background: var(--sage-soft); color: var(--sage-deep); } - .src { color: var(--muted); margin-left: auto; } + /* Source on its own line below the tags, left-justified, for uniformity. */ + .src { color: var(--muted); font-size: 0.78rem; margin: -2px 0 2px; } h3 { font-size: 1.18rem; } h3 a:hover { color: var(--sage-deep); } @@ -134,7 +135,7 @@ color: var(--sage); opacity: 0.05; text-transform: lowercase; letter-spacing: -0.02em; pointer-events: none; user-select: none; } - .tile .tags, .tile h3, .tile .why, .tile .actions { position: relative; } + .tile .tags, .tile .src, .tile h3, .tile .why, .tile .actions { position: relative; } /* ---- Hero WITH image: two columns, with an atmospheric overlay ---- */ .hero { display: grid; grid-template-columns: 1.1fr 1fr; } diff --git a/goodnews/briefs.py b/goodnews/briefs.py index 4dfdc4f..0f702b4 100644 --- a/goodnews/briefs.py +++ b/goodnews/briefs.py @@ -148,24 +148,44 @@ def _candidate_articles( ).fetchall() -def _select_diverse(rows: list[sqlite3.Row], limit: int, max_per_topic: int = 2) -> list[sqlite3.Row]: - """Pick up to `limit` items from `rows` (already ranked best-first). +def _select_diverse(rows: list[sqlite3.Row], limit: int) -> list[sqlite3.Row]: + """Pick up to `limit` items for the daily brief (rows ranked best-first). - Contract: - 1. Prefer higher-ranked items. - 2. Source diversity: at most one item per source while other sources remain. - 3. Topic balance: no more than `max_per_topic` of the same topic while other - topics still have candidates — so the five don't cluster (e.g. several - medical/science items) when community/culture/animals/environment exist. - 4. Always fill to `limit` when enough candidates exist: the source and topic - caps are relaxed (in that order) only as needed to reach the count. + The daily five should feel like *good news*, not a research digest, so the + emotional mix is guarded — not just topic count: + - at most 1 health item, + - at most 2 science+health items combined, + - at most 2 of any single topic, + - distinct sources. + Because science/health are capped at 2 combined, at least three of the five + are community/culture/animals/environment whenever those exist — so the page + leads with breadth, not clustered medical/science breakthroughs. + + Caps are relaxed (topic first, then source) only as needed to still fill the + count on thin days; we never return fewer when candidates exist. """ selected: list[sqlite3.Row] = [] selected_ids: set = set() seen_sources: set = set() topic_count: dict = {} - def consider(enforce_source: bool, enforce_topic: bool) -> None: + def add(row: sqlite3.Row) -> None: + selected.append(row) + selected_ids.add(row["id"]) + seen_sources.add(row["source_name"]) + topic_count[row["topic"]] = topic_count.get(row["topic"], 0) + 1 + + def emotional_mix_ok(row: sqlite3.Row) -> bool: + topic = row["topic"] + health = topic_count.get("health", 0) + science = topic_count.get("science", 0) + if topic == "health" and health >= 1: + return False + if topic in ("science", "health") and (science + health) >= 2: + return False + return topic_count.get(topic, 0) < 2 + + def fill(enforce_mix: bool, enforce_source: bool) -> None: for row in rows: if len(selected) >= limit: return @@ -173,17 +193,13 @@ def _select_diverse(rows: list[sqlite3.Row], limit: int, max_per_topic: int = 2) continue if enforce_source and row["source_name"] in seen_sources: continue - topic = row["topic"] - if enforce_topic and topic_count.get(topic, 0) >= max_per_topic: + if enforce_mix and not emotional_mix_ok(row): continue - selected.append(row) - selected_ids.add(row["id"]) - seen_sources.add(row["source_name"]) - topic_count[topic] = topic_count.get(topic, 0) + 1 + add(row) - consider(enforce_source=True, enforce_topic=True) # distinct source, topic-balanced - consider(enforce_source=True, enforce_topic=False) # relax topic cap to fill - consider(enforce_source=False, enforce_topic=False) # relax source too, last resort + fill(enforce_mix=True, enforce_source=True) # balanced mix, distinct sources + fill(enforce_mix=False, enforce_source=True) # relax the mix caps to fill + fill(enforce_mix=False, enforce_source=False) # relax source too, last resort return selected diff --git a/tests/test_briefs.py b/tests/test_briefs.py index 28092a2..a0a98f9 100644 --- a/tests/test_briefs.py +++ b/tests/test_briefs.py @@ -2,7 +2,6 @@ from goodnews.briefs import _select_diverse def row(id, source, topic): - # _select_diverse reads id, source_name, topic; plain dicts support [] access. return {"id": id, "source_name": source, "topic": topic} @@ -10,29 +9,39 @@ def test_prefers_distinct_sources_best_first(): rows = [ row(1, "A", "science"), row(2, "A", "science"), # same source as #1 — skipped while others remain - row(3, "B", "health"), + row(3, "B", "community"), row(4, "C", "environment"), ] assert [r["id"] for r in _select_diverse(rows, limit=3)] == [1, 3, 4] -def test_caps_a_topic_when_alternatives_exist(): +def test_at_most_one_health_when_alternatives_exist(): rows = [ - row(1, "A", "science"), row(2, "B", "science"), - row(3, "C", "science"), row(4, "D", "science"), - row(5, "E", "community"), row(6, "F", "animals"), row(7, "G", "culture"), + row(1, "A", "health"), row(2, "B", "health"), + row(3, "C", "science"), row(4, "D", "community"), + row(5, "E", "animals"), row(6, "F", "environment"), ] - selected = _select_diverse(rows, limit=5, max_per_topic=2) - topics = [r["topic"] for r in selected] - assert len(selected) == 5 - assert topics.count("science") == 2 # capped, even though 4 were available - assert {"community", "animals", "culture"} <= set(topics) + topics = [r["topic"] for r in _select_diverse(rows, limit=5)] + assert len(topics) == 5 + assert topics.count("health") == 1 -def test_relaxes_cap_when_only_one_topic_available(): - rows = [row(i, f"S{i}", "science") for i in range(1, 6)] - selected = _select_diverse(rows, limit=5) - assert len(selected) == 5 # all science: cap relaxed because nothing else exists +def test_science_plus_health_capped_at_two(): + rows = [ + row(1, "A", "science"), row(2, "B", "science"), row(3, "C", "science"), + row(4, "D", "health"), row(5, "E", "community"), + row(6, "F", "animals"), row(7, "G", "culture"), + ] + topics = [r["topic"] for r in _select_diverse(rows, limit=5)] + assert len(topics) == 5 + assert topics.count("science") + topics.count("health") <= 2 + # …which means the rest are the gentler lanes + assert sum(t in {"community", "animals", "culture", "environment"} for t in topics) >= 3 + + +def test_relaxes_caps_to_fill_on_thin_days(): + rows = [row(i, f"S{i}", "science") for i in range(1, 6)] # only science available + assert len(_select_diverse(rows, limit=5)) == 5 def test_backfills_repeating_source_when_needed():