Files
thejayman77 337dc3f901 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>
2026-06-09 20:05:26 -04:00

59 lines
2.6 KiB
Python

from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
from goodnews import summarize
from goodnews.db import connect, init_db
def _db(path):
c = connect(str(path)); init_db(c)
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'NPR','http://s/f',5)")
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,description) "
"VALUES (1,1,'https://npr.org/x','Voles return','h1','A snippet.')")
c.execute("INSERT INTO article_scores (article_id,accepted,reason_text) VALUES (1,1,'Hopeful.')")
c.commit()
return c
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)
assert out == "Our own short summary."
assert summarize.get_summary(c, 1) == "Our own short summary."
# second call is a cache hit (no regeneration)
monkeypatch.setattr(summarize, "summarize_article", lambda *a: "DIFFERENT")
assert summarize.generate_summary(c, 1) == "Our own short summary."
def test_summary_endpoint_pending_then_ready(tmp_path, monkeypatch):
db = tmp_path / "t.sqlite3"
_db(db).close()
monkeypatch.setenv("GOODNEWS_DB", str(db))
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com")
import importlib
import goodnews.api as api
importlib.reload(api)
# stub the actual generation the background task runs
monkeypatch.setattr(
api.summarize, "generate_summary",
lambda conn, aid, client=None: conn.execute(
"INSERT OR REPLACE INTO article_summaries (article_id, summary) VALUES (?, 'Cached summary.')", (aid,)
) and conn.commit(),
)
tc = TestClient(api.create_app())
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.", "explanation": None}
# and the share page now embeds it (no pending poll)
html = tc.get("/a/1").text
assert "Cached summary." in html