Article pages: structured "Why it belongs" editorial read

Per Codex — make /a/<id> feel like Upbeat Bytes has editorial judgment, not just
a summary wrapper. Trust-building, short, not an essay.

* article_summaries gains what_happened / why_matters / why_belongs (+ migration).
* summarize.explain_article: a separate, fallback-able LLM pass producing three
  short notes (parsed from a labelled WHAT/MATTERS/BELONGS format). generate_summary
  now stores them alongside the summary, and tops up older summaries on next view.
  get_explanation returns them only when all three are present.
* API: share_page + /api/summary expose the explanation.
* share.py: renders the three-part section (accent rule) when complete; otherwise
  the single "Why it's here" reason line is the calm fallback. The page polls and
  swaps in both the summary and the section as they cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-09 20:05:26 -04:00
parent 9befbffd94
commit 337dc3f901
6 changed files with 192 additions and 21 deletions
+9 -5
View File
@@ -886,9 +886,12 @@ def create_app() -> FastAPI:
if not row or row["duplicate_of"] is not None or not row["accepted"]:
return not_found
summary = summarize.get_summary(conn, aid)
if not summary:
_kick_summary(aid, background_tasks) # generate for next time; page polls
return HTMLResponse(share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary))
explanation = summarize.get_explanation(conn, aid)
if not summary or not explanation:
_kick_summary(aid, background_tasks) # generate/top-up for next time; page polls
return HTMLResponse(
share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary, explanation=explanation)
)
# --- Privacy-respecting first-party analytics -------------------------
@@ -1243,10 +1246,11 @@ def create_app() -> FastAPI:
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
with get_conn() as conn:
summary = summarize.get_summary(conn, article_id)
explanation = summarize.get_explanation(conn, article_id)
if summary:
return {"status": "ready", "summary": summary}
return {"status": "ready", "summary": summary, "explanation": explanation}
_kick_summary(article_id, background_tasks)
return {"status": "pending", "summary": None}
return {"status": "pending", "summary": None, "explanation": None}
@app.get("/today", response_class=HTMLResponse)
def today_digest() -> HTMLResponse:
+9
View File
@@ -208,6 +208,9 @@ CREATE TABLE IF NOT EXISTS user_prefs (
CREATE TABLE IF NOT EXISTS article_summaries (
article_id INTEGER PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
summary TEXT NOT NULL,
what_happened TEXT,
why_matters TEXT,
why_belongs TEXT,
model TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -363,3 +366,9 @@ def _migrate(conn: sqlite3.Connection) -> None:
rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")}
if rep_cols and "message_html" not in rep_cols:
conn.execute("ALTER TABLE feedback_replies ADD COLUMN message_html TEXT")
# article_summaries: structured "Why it belongs" fields added later.
sum_cols = {row["name"] for row in conn.execute("PRAGMA table_info(article_summaries)")}
for column in ("what_happened", "why_matters", "why_belongs"):
if sum_cols and column not in sum_cols:
conn.execute(f"ALTER TABLE article_summaries ADD COLUMN {column} TEXT")
+45 -8
View File
@@ -17,7 +17,8 @@ def _tag(name: str, content: str | None, attr: str = "property") -> str:
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
def render_share_page(article: dict, base_url: str, summary: str | None = None) -> str:
def render_share_page(article: dict, base_url: str, summary: str | None = None,
explanation: dict | None = None) -> str:
aid = article["id"]
title = (article.get("title") or "Upbeat Bytes").strip()
why = (article.get("reason_text") or article.get("description")
@@ -63,22 +64,54 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
groupings = f'<div class="chips">{chips}</div>' if chips else ""
if summary:
summary_block = f'<p class="summary" id="summary">{escape(summary)}</p>'
poll = ""
else:
pending = (
f"✦ Were putting together a quick summary — in the meantime, "
f"read the full story at {escape(source)} below."
)
summary_block = f'<p class="summary pending" id="summary">{pending}</p>'
# Quietly poll; swap the real summary in the moment it's cached.
# The structured "Why it belongs" editorial section — only when the LLM gave a
# clean three-part read; otherwise the single reason line is the calm fallback.
def _why_row(lbl: str, text: str) -> str:
return f'<div class="why-row"><span class="lbl">{lbl}</span><p>{escape(text)}</p></div>'
if explanation:
why_block = (
'<div class="why" id="why">'
+ _why_row("What happened", explanation["what_happened"])
+ _why_row("Why it matters", explanation["why_matters"])
+ _why_row("Why it belongs here", explanation["why_belongs"])
+ '</div>'
)
else:
why_block = f'<div class="why" id="why"><div class="why-row"><span class="lbl">Why its here</span><p>{escape(why)}</p></div></div>'
# Quietly poll until BOTH the summary and the structured read are cached, then
# swap each in (older summaries get topped up with the section on first view).
poll = ""
if not summary or not explanation:
poll = f"""<script>
(function(){{
var id={aid}, box=document.getElementById('summary'), n=0;
function row(l,t){{
var d=document.createElement('div'); d.className='why-row';
var s=document.createElement('span'); s.className='lbl'; s.textContent=l;
var p=document.createElement('p'); p.textContent=t;
d.appendChild(s); d.appendChild(p); return d;
}}
function go(){{
n++;
fetch('/api/summary/'+id).then(function(r){{return r.json();}}).then(function(d){{
if(d&&d.status==='ready'&&d.summary){{ box.textContent=d.summary; box.className='summary'; }}
else if(n<12){{ setTimeout(go,2500); }}
var done=true;
if(d&&d.summary){{ box.textContent=d.summary; box.className='summary'; }} else {{ done=false; }}
if(d&&d.explanation){{
var w=document.getElementById('why');
if(w){{ w.innerHTML=''; w.appendChild(row('What happened',d.explanation.what_happened));
w.appendChild(row('Why it matters',d.explanation.why_matters));
w.appendChild(row('Why it belongs here',d.explanation.why_belongs)); }}
}} else {{ done=false; }}
if(!done && n<12){{ setTimeout(go,2500); }}
}}).catch(function(){{ if(n<12) setTimeout(go,3000); }});
}}
go();
@@ -140,9 +173,13 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
font-size:1.7rem; line-height:1.2; margin:6px 0 14px; }}
.summary {{ font-size:1.05rem; color:var(--ink); margin:0 0 18px; }}
.summary.pending {{ color:var(--muted); font-style:italic; }}
.why {{ color:#3b4754; font-size:.92rem; margin:0 0 22px; }}
.why {{ color:#3b4754; font-size:.92rem; margin:0 0 22px;
border-left:2px solid var(--accent); padding-left:14px; }}
.why-row {{ margin:0 0 12px; }}
.why-row:last-child {{ margin-bottom:0; }}
.why-row p {{ margin:0; }}
.why .lbl {{ display:block; text-transform:uppercase; letter-spacing:.08em; font-size:.7rem;
color:var(--muted); margin-bottom:3px; }}
color:var(--accent-deep); font-weight:600; margin-bottom:2px; }}
.chips {{ display:flex; flex-wrap:wrap; gap:7px; margin:0 0 22px; }}
.chip {{ background:#e0eef3; color:var(--accent-deep); border-radius:999px; padding:3px 11px;
font-size:.7rem; text-transform:uppercase; letter-spacing:.06em; }}
@@ -165,7 +202,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
{source_html}
<h1>{escape(title)}</h1>
{summary_block}
<p class="why"><span class="lbl">Why it's here</span>{escape(why)}</p>
{why_block}
{groupings}
<div class="actions">
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
+64 -7
View File
@@ -76,6 +76,39 @@ def summarize_article(client: LocalModelClient, title: str, snippet: str, body_t
return (client.chat_text(messages) or "").strip()[:1200]
_EXPLAIN_SYSTEM = (
"You are the calm editor of a constructive-news site. For the given story write three "
"very short, plain-language notes — 1 to 2 factual sentences each, in your own words, no "
"markdown, no preamble, no quotes, no hype. Use EXACTLY this format and these labels, each "
"on its own line:\n"
"WHAT: <what actually happened — the plain gist>\n"
"MATTERS: <why it matters — the real-world, constructive significance>\n"
"BELONGS: <why it fits a calm, constructive news site — the human benefit, agency, or "
"grounded hope in it>"
)
def _parse_explain(text: str) -> dict:
def grab(label: str) -> str | None:
m = re.search(rf"\b{label}\s*:\s*(.+?)(?=\n\s*[A-Z]+\s*:|\Z)", text or "", re.IGNORECASE | re.DOTALL)
if not m:
return None
val = _WS.sub(" ", m.group(1)).strip().strip("-•* ").strip()
return val[:400] or None
return {"what_happened": grab("WHAT"), "why_matters": grab("MATTERS"), "why_belongs": grab("BELONGS")}
def explain_article(client: LocalModelClient, title: str, snippet: str, body_text: str) -> dict:
"""Three short editorial notes (what happened / why it matters / why it belongs)."""
material = (body_text or snippet or title or "")[:4000]
user = f"Title: {title}\n\nArticle text:\n{material}\n\nWrite the three notes."
text = client.chat_text(
[{"role": "system", "content": _EXPLAIN_SYSTEM}, {"role": "user", "content": user}]
) or ""
return _parse_explain(text)
def get_summary(conn: sqlite3.Connection, article_id: int) -> str | None:
row = conn.execute(
"SELECT summary FROM article_summaries WHERE article_id = ?", (article_id,)
@@ -83,25 +116,49 @@ def get_summary(conn: sqlite3.Connection, article_id: int) -> str | None:
return row["summary"] if row else None
def get_explanation(conn: sqlite3.Connection, article_id: int) -> dict | None:
"""The structured 'Why it belongs' notes — only if all three are present (else
the page falls back to summary + reason_text)."""
row = conn.execute(
"SELECT what_happened, why_matters, why_belongs FROM article_summaries WHERE article_id = ?",
(article_id,),
).fetchone()
if row and row["what_happened"] and row["why_matters"] and row["why_belongs"]:
return dict(row)
return None
def generate_summary(conn: sqlite3.Connection, article_id: int, client: LocalModelClient | None = None) -> str | None:
"""Generate + cache a summary for one article. Returns it, or None if skipped."""
if get_summary(conn, article_id):
return get_summary(conn, article_id) # already cached
"""Generate + cache a summary AND the structured explanation for one article.
Returns the summary, or None if skipped. Idempotent: a fully-cached article
(summary + explanation) is returned as-is; an older summary missing the
explanation is topped up on the next call (so existing pages gain the section).
"""
existing = conn.execute(
"SELECT summary, why_belongs FROM article_summaries WHERE article_id = ?", (article_id,)
).fetchone()
if existing and existing["summary"] and existing["why_belongs"]:
return existing["summary"] # summary + a complete explanation already cached
row = conn.execute(
"SELECT a.title, a.description, a.canonical_url, a.duplicate_of, s.accepted "
"FROM articles a LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
(article_id,),
).fetchone()
if not row or row["duplicate_of"] is not None or not row["accepted"]:
return None
return existing["summary"] if existing else None
client = client or LocalModelClient.from_env()
body = _fetch_text(row["canonical_url"])
summary = summarize_article(client, row["title"], row["description"] or "", body)
summary = existing["summary"] if existing else summarize_article(
client, row["title"], row["description"] or "", body
)
if not summary:
return None
ex = explain_article(client, row["title"], row["description"] or "", body)
conn.execute(
"INSERT OR REPLACE INTO article_summaries (article_id, summary, model) VALUES (?, ?, ?)",
(article_id, summary, client.model),
"INSERT OR REPLACE INTO article_summaries "
"(article_id, summary, what_happened, why_matters, why_belongs, model) VALUES (?, ?, ?, ?, ?, ?)",
(article_id, summary, ex["what_happened"], ex["why_matters"], ex["why_belongs"], client.model),
)
conn.commit()
# Attention-triggered image enrichment: a summarized article is one a reader
+62
View File
@@ -0,0 +1,62 @@
from goodnews import summarize, share
from goodnews.db import connect, init_db
def test_parse_explain_labels():
txt = ("WHAT: A town planted 500 trees.\n"
"MATTERS: It cools streets and cuts flooding.\n"
"BELONGS: Neighbors did it themselves — local agency and clear benefit.")
ex = summarize._parse_explain(txt)
assert ex["what_happened"].startswith("A town planted")
assert "cools streets" in ex["why_matters"]
assert "local agency" in ex["why_belongs"]
def test_parse_explain_partial():
ex = summarize._parse_explain("WHAT: only this")
assert ex["what_happened"] == "only this" and ex["why_matters"] is None and ex["why_belongs"] is None
def _seed_article(c):
c.execute("INSERT INTO sources (id,name,feed_url) VALUES (1,'S','http://s/f')")
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (1,1,'http://a/1','Town plants trees','h')")
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (1,1)")
c.commit()
def test_get_explanation_requires_all_three(tmp_path):
c = connect(str(tmp_path / "e.db")); init_db(c); _seed_article(c)
c.execute("INSERT INTO article_summaries (article_id,summary,what_happened) VALUES (1,'s','w')"); c.commit()
assert summarize.get_explanation(c, 1) is None # partial → fall back
c.execute("UPDATE article_summaries SET why_matters='m', why_belongs='b' WHERE article_id=1"); c.commit()
assert summarize.get_explanation(c, 1) == {"what_happened": "w", "why_matters": "m", "why_belongs": "b"}
def test_generate_summary_stores_explanation(tmp_path, monkeypatch):
c = connect(str(tmp_path / "g.db")); init_db(c); _seed_article(c)
class FakeClient:
model = "test"
def chat_text(self, messages):
if "three" in messages[0]["content"].lower():
return "WHAT: trees planted.\nMATTERS: cooler streets.\nBELONGS: local agency, clear benefit."
return "A town planted trees, cooling streets."
monkeypatch.setattr(summarize, "_fetch_text", lambda url: "body text")
s = summarize.generate_summary(c, 1, client=FakeClient())
assert s and "planted trees" in s
ex = summarize.get_explanation(c, 1)
assert "agency" in ex["why_belongs"] and "cooler streets" in ex["why_matters"]
def test_share_renders_structured_or_fallback():
art = {"id": 1, "title": "T", "source_name": "Src", "source_id": 1,
"canonical_url": "http://x", "reason_text": "calm reason", "tags": ""}
full = share.render_share_page(art, "http://b", summary="sum",
explanation={"what_happened": "WH", "why_matters": "WM", "why_belongs": "WB"})
assert "What happened" in full and "Why it matters" in full and "Why it belongs here" in full
assert "WH" in full and "WB" in full
assert "fetch('/api/summary/'" not in full # both cached → no poll needed
# no explanation → the single "Why its here" reason line is the calm fallback
fb = share.render_share_page(art, "http://b", summary="sum", explanation=None)
assert "Why its here" in fb and "calm reason" in fb
+3 -1
View File
@@ -21,6 +21,8 @@ def test_generate_caches_summary(tmp_path, monkeypatch):
c = _db(tmp_path / "t.sqlite3")
monkeypatch.setattr(summarize, "_fetch_text", lambda url: "Full article body about voles.")
monkeypatch.setattr(summarize, "summarize_article", lambda client, t, s, b: "Our own short summary.")
monkeypatch.setattr(summarize, "explain_article",
lambda client, t, s, b: {"what_happened": "w", "why_matters": "m", "why_belongs": "b"})
# avoid constructing a real LLM client
monkeypatch.setattr(summarize.LocalModelClient, "from_env", classmethod(lambda cls: SimpleNamespace(model="m")))
out = summarize.generate_summary(c, 1)
@@ -50,7 +52,7 @@ def test_summary_endpoint_pending_then_ready(tmp_path, monkeypatch):
first = tc.get("/api/summary/1").json()
assert first["status"] == "pending"
# TestClient runs the background task; the next call sees the cache
assert tc.get("/api/summary/1").json() == {"status": "ready", "summary": "Cached summary."}
assert tc.get("/api/summary/1").json() == {"status": "ready", "summary": "Cached summary.", "explanation": None}
# and the share page now embeds it (no pending poll)
html = tc.get("/a/1").text
assert "Cached summary." in html