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:
@@ -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