Option A: typographic editorial tiles + single treated hero image; balance brief topics

Frontend (the premium baseline):
- The hero is now the ONLY image slot. Soft feed images get an atmospheric
  gradient overlay; no over-reliance on inconsistent RSS image quality.
- Every secondary/lane card is a uniform typographic editorial tile: no
  thumbnails, equal visual weight, a faint topic wordmark watermark, a slim
  sage top accent, consistent source, reason text as the trust signal, visible
  Replace with quiet tuning actions. Fixes the jarring mixed-media row rhythm
  and removes muddy thumbnails entirely.

Backend (composition):
- _select_diverse now balances topics: no more than 2 of one topic while other
  topics have candidates (relaxing source then topic caps only to fill), so the
  daily five stop clustering medical/science items. Candidates now carry s.topic.

Tests updated for the topic-balance contract (79 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-31 12:10:05 +00:00
parent ba801d90f6
commit 541f59ed6e
4 changed files with 107 additions and 118 deletions
+23 -31
View File
@@ -118,6 +118,8 @@ def _candidate_articles(
s.reason_code,
s.reason_text,
s.model_name,
s.topic,
s.flavor,
CASE WHEN date(COALESCE(a.published_at, a.discovered_at)) = date(?)
THEN 1 ELSE 0 END AS is_today
FROM articles a
@@ -146,52 +148,42 @@ def _candidate_articles(
).fetchall()
def _select_diverse(rows: list[sqlite3.Row], limit: int) -> list[sqlite3.Row]:
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).
Contract:
1. Prefer higher-ranked items.
2. Source diversity: take at most one item per source while other sources
remain; only repeat a source once distinct sources are exhausted.
3. Category diversity: if the result ended up single-category and a different
category is available in the pool, swap in the highest-ranked off-category
candidate by evicting the lowest-ranked currently-selected item (so we
gain breadth without dropping a higher-ranked pick).
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.
"""
selected: list[sqlite3.Row] = []
selected_ids: set = set()
seen_sources: set = set()
topic_count: dict = {}
# Pass 1: best-first, one per source.
for row in rows:
if len(selected) >= limit:
break
if row["source_name"] in seen_sources:
continue
selected.append(row)
seen_sources.add(row["source_name"])
# Pass 2: if short on distinct sources, backfill best-first regardless.
if len(selected) < limit:
selected_ids = {row["id"] for row in selected}
def consider(enforce_source: bool, enforce_topic: bool) -> None:
for row in rows:
if len(selected) >= limit:
break
return
if row["id"] in selected_ids:
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:
continue
selected.append(row)
selected_ids.add(row["id"])
seen_sources.add(row["source_name"])
topic_count[topic] = topic_count.get(topic, 0) + 1
# Pass 3: ensure >= 2 categories when the pool allows it.
categories = {row["default_category"] for row in selected}
if len(categories) < 2:
selected_ids = {row["id"] for row in selected}
for row in rows:
if row["id"] in selected_ids:
continue
if row["default_category"] not in categories:
selected[-1] = row # evict the lowest-ranked selected item
break
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
return selected