1d71575982
The /a/<id> page now carries an original short summary so it stands on its own,
without republishing the publisher's article:
- summarize.py: transient SSRF-guarded fetch of the article text → local LLM
writes a 2-4 sentence ORIGINAL summary (our words). Cached in article_summaries
forever; we store only our summary, never the body. Generated lazily (only for
shared/viewed articles), de-duped so concurrent hits don't double-generate.
- /a serves cached-or-pending; when pending it shows a calm "summary on its way,
read at {source}" note and self-polls /api/summary/<id>, swapping the summary
in the moment it's ready (never blocks the page on the batch-tier LLM).
- Share menu warms generation on open so recipients usually get the rich version.
- Container reaches the arbiter at arbiter:8080 over caddy_web (LLM env added to
the API container). 124 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
57 lines
2.5 KiB
Python
57 lines
2.5 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.")
|
|
# 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."}
|
|
# and the share page now embeds it (no pending poll)
|
|
html = tc.get("/a/1").text
|
|
assert "Cached summary." in html
|