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:
+9
-5
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"✦ We’re 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 it’s 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
@@ -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
|
||||
|
||||
@@ -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 it’s here" reason line is the calm fallback
|
||||
fb = share.render_share_page(art, "http://b", summary="sum", explanation=None)
|
||||
assert "Why it’s here" in fb and "calm reason" in fb
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user