From a7d72f2f84c1359a62f7fbcb4c43ff82722e85e0 Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 9 Jun 2026 19:17:17 -0400 Subject: [PATCH] Digest v2: restrained "From what you follow" section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Codex — give follows an immediate payoff without making the email feel algorithmic. The editorial brief stays the star: subject + brief count unchanged. * followed_digest_items(conn, user_id, exclude_ids, limit=3): recent items from the user's followed sources/tags, same accepted/non-dup/content-visible gate, excludes anything in the brief, capped to one per source so a single follow can't dominate. Empty → section omitted (no empty state in email). * build_digest gains an optional `followed` list → a small "From what you follow" section AFTER the brief, only when there are items. Item rendering factored into shared _item_html / _item_text_lines helpers. * send_due_digests computes the followed items per user (excluding the brief). Co-Authored-By: Claude Opus 4.8 (1M context) --- goodnews/digest.py | 124 ++++++++++++++++++++++++++++++++++--------- tests/test_digest.py | 27 ++++++++++ 2 files changed, 126 insertions(+), 25 deletions(-) diff --git a/goodnews/digest.py b/goodnews/digest.py index d0a456e..fc3aca2 100644 --- a/goodnews/digest.py +++ b/goodnews/digest.py @@ -52,6 +52,53 @@ def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> l return items +def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, limit: int = 3) -> list[dict]: + """Up to `limit` recent items from the user's followed sources/tags for the + "From what you follow" section — same accepted/non-dup/visible gate as the feed, + excluding what's already in the brief, capped to one per source so a single + follow can't dominate. Returns [] if they follow nothing (→ section omitted).""" + frows = conn.execute("SELECT kind, value FROM user_follows WHERE user_id = ?", (user_id,)).fetchall() + fsources = [int(r["value"]) for r in frows if r["kind"] == "source" and str(r["value"]).isdigit()] + ftags = [str(r["value"]).lower() for r in frows if r["kind"] == "tag"] + if not fsources and not ftags: + return [] + ors, params = [], [] + if fsources: + ors.append(f"a.source_id IN ({','.join('?' * len(fsources))})") + params += fsources + if ftags: + ors.append( + f"EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id " + f"AND at.tag IN ({','.join('?' * len(ftags))}))" + ) + params += ftags + rows = conn.execute( + f""" + SELECT a.id, a.title, a.canonical_url, s.name AS source, a.source_id, sc.reason_text, + (SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary + FROM articles a + JOIN sources s ON s.id = a.source_id + JOIN article_scores sc ON sc.article_id = a.id + WHERE sc.accepted = 1 AND a.duplicate_of IS NULL AND s.content_visible = 1 + AND ({' OR '.join(ors)}) + ORDER BY COALESCE(a.published_at, a.discovered_at) DESC + LIMIT 30 + """, + params, + ).fetchall() + exclude, per_source, out = set(exclude_ids), {}, [] + for r in rows: + d = dict(r) + if d["id"] in exclude or per_source.get(d["source_id"], 0) >= 1: + continue + per_source[d["source_id"]] = 1 + d["paywalled"] = is_paywalled(d["canonical_url"]) + out.append(d) + if len(out) >= limit: + break + return out + + def _weekday(brief_date: str) -> str: try: return datetime.strptime(brief_date, "%Y-%m-%d").strftime("%A") @@ -59,9 +106,43 @@ def _weekday(brief_date: str) -> str: return "today" -def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str | None = None) -> tuple[str, str, str]: - """Return (subject, text, html) for the digest — calm and dated, no urgency.""" +def _item_text_lines(it: dict, base: str) -> list[str]: + lines = [f"• {it['title']} ({it['source']})"] + if it.get("summary"): + lines.append(f" {it['summary']}") + if it.get("reason_text"): + lines.append(f" Why it's here: {it['reason_text']}") + lines.append(f" Read: {base}/a/{it['id']}") + lines.append(f" Source: {it['canonical_url']}\n") + return lines + + +def _item_html(it: dict, base: str) -> str: + summary = f'
{escape(it["summary"])}
' if it.get("summary") else "" + why = f'
Why it’s here: {escape(it["reason_text"])}
' if it.get("reason_text") else "" + lock = " \U0001f512" if it.get("paywalled") else "" + return ( + '
' + f'{escape(it["title"])}' + f'
{escape(it["source"])}
' + f'{summary}{why}' + '
' + f'Read on Upbeat Bytes' + f'  ·  Full story at source{lock}' + '
' + ) + + +def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str | None = None, + followed: list[dict] | None = None) -> tuple[str, str, str]: + """Return (subject, text, html) for the digest — calm and dated, no urgency. + + `followed` (optional) adds a small "From what you follow" section AFTER the + editorial brief — only when there are qualifying items; omitted otherwise. + The brief stays the star: subject and brief count are unchanged. + """ base = base or _base_url() + followed = followed or [] n = len(items) weekday = _weekday(brief_date) subject = f"{weekday}'s Upbeat Bytes · {n} calm read{'' if n == 1 else 's'}" @@ -75,31 +156,22 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str | "always more waiting on the site whenever you want it.\n", ] for it in items: - text_lines.append(f"• {it['title']} ({it['source']})") - if it.get("summary"): - text_lines.append(f" {it['summary']}") - if it.get("reason_text"): - text_lines.append(f" Why it's here: {it['reason_text']}") - text_lines.append(f" Read: {base}/a/{it['id']}") - text_lines.append(f" Source: {it['canonical_url']}\n") + text_lines += _item_text_lines(it, base) + if followed: + text_lines.append("\n— From what you follow —\n") + for it in followed: + text_lines += _item_text_lines(it, base) text_lines.append(f"That's today's highlights — more good news is always waiting at {base}. See you tomorrow.") text_lines.append(f"\nTo stop these emails: {unsub_url}") text = "\n".join(text_lines) - blocks = [] - for it in items: - summary = f'
{escape(it["summary"])}
' if it.get("summary") else "" - why = f'
Why it’s here: {escape(it["reason_text"])}
' if it.get("reason_text") else "" - lock = " \U0001f512" if it.get("paywalled") else "" - blocks.append( - '
' - f'{escape(it["title"])}' - f'
{escape(it["source"])}
' - f'{summary}{why}' - '
' - f'Read on Upbeat Bytes' - f'  ·  Full story at source{lock}' - '
' + main_blocks = "".join(_item_html(it, base) for it in items) + followed_html = "" + if followed: + followed_html = ( + '
From what you follow
' + + "".join(_item_html(it, base) for it in followed) ) html = ( '
waiting on the site when you want it.

' '
' - + "".join(blocks) + + main_blocks + + followed_html + '

That’s today’s highlights — more good news is ' f'always waiting on Upbeat Bytes. See you tomorrow.

' f'

You’re getting this because you turned on ' @@ -164,7 +237,8 @@ def send_due_digests(conn: sqlite3.Connection, force: bool = False, base: str | conn.commit() user["digest_unsub_token"] = token link = unsub_url(user, base) - subject, text, html = build_digest(items, brief_date, link, base) + followed = followed_digest_items(conn, user["id"], [it["id"] for it in items]) + subject, text, html = build_digest(items, brief_date, link, base, followed=followed) # RFC 2369 / 8058: let inboxes offer native one-click unsubscribe. headers = {"List-Unsubscribe": f"<{link}>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"} try: diff --git a/tests/test_digest.py b/tests/test_digest.py index fd14634..fc891a8 100644 --- a/tests/test_digest.py +++ b/tests/test_digest.py @@ -62,3 +62,30 @@ def test_send_respects_opt_out(monkeypatch, tmp_path): c.execute("UPDATE users SET digest_enabled=0 WHERE id=1"); c.commit() monkeypatch.setattr(digest, "local_today", lambda: date) assert digest.send_due_digests(c, force=True) == 0 and sent == [] + + +def test_followed_digest_items_caps_excludes_and_section(tmp_path): + c = connect(str(tmp_path / "fd.db")); init_db(c) + date = _seed(c, n=5) # brief sources/articles 1..5 + user 1 + # two NON-brief accepted articles from followed source 1 → cap should keep 1 + for aid in (10, 11): + c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (?,1,?,?,?)", + (aid, f"http://a/{aid}", f"Followed {aid}", f"h{aid}")) + c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (?,1)", (aid,)) + c.execute("INSERT INTO user_follows (user_id,kind,value) VALUES (1,'source','1')") + c.commit() + fol = digest.followed_digest_items(c, 1, exclude_ids=[1, 2, 3, 4, 5], limit=3) + assert len(fol) == 1 and fol[0]["source_id"] == 1 # one-per-source cap, brief ids excluded + assert fol[0]["id"] in (10, 11) + # section appears with followed items, omitted without (brief stays the star) + brief = [{"id": 1, "title": "B", "canonical_url": "http://b/1", "source": "S", "summary": "s", "reason_text": "r", "paywalled": False}] + _, text, html = digest.build_digest(brief, "2026-06-09", "http://u", followed=fol) + assert "From what you follow" in html and "From what you follow" in text + _, _, html2 = digest.build_digest(brief, "2026-06-09", "http://u", followed=[]) + assert "From what you follow" not in html2 + + +def test_followed_digest_items_empty_when_no_follows(tmp_path): + c = connect(str(tmp_path / "fd2.db")); init_db(c) + _seed(c, n=5) + assert digest.followed_digest_items(c, 1, exclude_ids=[], limit=3) == []