diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 4242eb1..190fb36 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -76,14 +76,16 @@ const ex = Array.from(dismissed).join(','); const fetched = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`); const view = P.loadJSON(BRIEF_VIEW_KEY, null); - // On a plain (re)load, keep the reader's curated view for the same day — so - // swaps and the hero hold steady. Re-fetch fresh on a new day's brief, or - // when forced (e.g. a boundary changed and must be re-applied). - if (!fresh && view && view.date === fetched.brief_date && Array.isArray(view.items) && view.items.length) { - brief = { brief_date: view.date, title: fetched.title, items: view.items }; + // Keep the reader's curated view (swaps + hero) across plain refreshes — but + // only while the server's brief is unchanged. When genuinely fresh data + // arrives (generated_at advances), the server wins and the pin is dropped. + const sameServerBrief = + view && view.generated_at && fetched.generated_at && view.generated_at === fetched.generated_at; + if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) { + brief = { ...fetched, items: view.items }; } else { brief = fetched; - P.saveJSON(BRIEF_VIEW_KEY, { date: fetched.brief_date, items: fetched.items }); + P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items }); } remember(brief.items); } @@ -155,8 +157,9 @@ if (i >= 0) { brief.items[i] = repl; brief = { ...brief, items: [...brief.items] }; - // Pin the swap so this exact curated brief survives a refresh. - P.saveJSON(BRIEF_VIEW_KEY, { date: brief.brief_date, items: brief.items }); + // Pin the swap against the current server brief; it holds until fresh + // server data arrives (then the server's version takes over). + P.saveJSON(BRIEF_VIEW_KEY, { generated_at: brief.generated_at, items: brief.items }); } } else { const i = feed.findIndex((a) => a.id === article.id); diff --git a/goodnews/api.py b/goodnews/api.py index f33aa93..35a1145 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -159,6 +159,7 @@ class FeedResponse(BaseModel): class BriefResponse(BaseModel): brief_date: str | None title: str | None + generated_at: str | None = None # freshness stamp: changes only when content changes items: list[Article] @@ -336,6 +337,7 @@ def create_app() -> FastAPI: return BriefResponse( brief_date=data["brief_date"], title=data["title"], + generated_at=data.get("created_at"), items=[Article.from_row(r) for r in items], ) diff --git a/goodnews/briefs.py b/goodnews/briefs.py index 79b8243..f09842c 100644 --- a/goodnews/briefs.py +++ b/goodnews/briefs.py @@ -14,10 +14,29 @@ def build_daily_brief( window_days: int = 3, ) -> int: target_date = brief_date or date.today().isoformat() + + # Compose the selection first so we can tell whether anything actually + # changed. A calm daily brief shouldn't repeatedly hand the reader a locked + # door: push paywalled candidates below readable ones (stable sort) first. + rows = _candidate_articles(conn, target_date, window_days) + rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"])) + selected = _select_diverse(rows, limit) + selected_ids = [row["id"] for row in selected] + existing = conn.execute("SELECT id FROM daily_briefs WHERE brief_date = ?", (target_date,)).fetchone() - if existing and not replace: - return int(existing["id"]) - if existing and replace: + if existing: + existing_ids = [ + r["article_id"] + for r in conn.execute( + "SELECT article_id FROM daily_brief_items WHERE brief_id = ? ORDER BY rank", + (existing["id"],), + ) + ] + # Idempotent: if the selection is unchanged, leave the brief (and its + # created_at freshness stamp) alone — a 15-minute rebuild with no new + # data is a no-op, so a reader's pinned view holds. + if existing_ids == selected_ids or not replace: + return int(existing["id"]) conn.execute("DELETE FROM daily_briefs WHERE id = ?", (existing["id"],)) brief_id = conn.execute( @@ -25,12 +44,6 @@ def build_daily_brief( (target_date, f"Highlights from Today - {target_date}"), ).lastrowid - rows = _candidate_articles(conn, target_date, window_days) - # A calm daily brief shouldn't repeatedly hand the reader a locked door: - # push paywalled candidates below readable ones (stable, so composite order - # is preserved within each group) before selecting the five. - rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"])) - selected = _select_diverse(rows, limit) for index, row in enumerate(selected, start=1): conn.execute( """ diff --git a/goodnews/queries.py b/goodnews/queries.py index 1c56f66..623c27d 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -120,11 +120,11 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int = return {"brief_date": None, "title": None, "items": []} header = conn.execute( - "SELECT brief_date, title FROM daily_briefs WHERE brief_date = ?", + "SELECT brief_date, title, created_at FROM daily_briefs WHERE brief_date = ?", (target_date,), ).fetchone() if not header: - return {"brief_date": target_date, "title": None, "items": []} + return {"brief_date": target_date, "title": None, "created_at": None, "items": []} rows = conn.execute( f""" @@ -143,6 +143,7 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int = return { "brief_date": header["brief_date"], "title": header["title"], + "created_at": header["created_at"], "items": [dict(row) for row in rows], } diff --git a/tests/test_brief_idempotent.py b/tests/test_brief_idempotent.py new file mode 100644 index 0000000..35e7436 --- /dev/null +++ b/tests/test_brief_idempotent.py @@ -0,0 +1,45 @@ +from datetime import date + +from goodnews.db import connect, init_db +from goodnews.briefs import build_daily_brief + + +def _setup(): + c = connect(":memory:"); init_db(c) + c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',5)") + today = date.today().isoformat() + for i, t in enumerate(["environment", "animals", "community", "culture", "science", "health", "environment"], 1): + c.execute("INSERT INTO articles (id,source_id,canonical_url,title,published_at,url_hash) VALUES (?,1,?,?,?,?)", + (i, f"http://s/{i}", f"t{i}", today + "T10:00:00+00:00", f"h{i}")) + c.execute("INSERT INTO article_scores (article_id,constructive_score,agency_score,human_benefit_score," + "cortisol_score,ragebait_score,pr_risk_score,accepted,topic,flavor) VALUES (?,5,2,2,1,0,2,1,?,'solution')", + (i, t)) + c.commit() + return c, today + + +def _created_at(c, day): + return c.execute("SELECT created_at FROM daily_briefs WHERE brief_date=?", (day,)).fetchone()[0] + + +def test_rebuild_with_same_selection_is_noop(): + c, today = _setup() + build_daily_brief(c, brief_date=today, limit=7, replace=True) + before = _created_at(c, today) + build_daily_brief(c, brief_date=today, limit=7, replace=True) # nothing new + assert _created_at(c, today) == before # freshness stamp preserved + c.close() + + +def test_new_higher_ranked_article_changes_the_brief(): + c, today = _setup() + build_daily_brief(c, brief_date=today, limit=7, replace=True) + c.execute("INSERT INTO articles (id,source_id,canonical_url,title,published_at,url_hash) VALUES (99,1,'http://s/99','t99',?,'h99')", + (today + "T11:00:00+00:00",)) + c.execute("INSERT INTO article_scores (article_id,constructive_score,agency_score,human_benefit_score," + "cortisol_score,ragebait_score,pr_risk_score,accepted,topic,flavor) VALUES (99,9,3,3,0,0,1,1,'animals','solution')") + c.commit() + build_daily_brief(c, brief_date=today, limit=7, replace=True) + ids = [r[0] for r in c.execute("SELECT article_id FROM daily_brief_items bi JOIN daily_briefs b ON b.id=bi.brief_id WHERE b.brief_date=?", (today,))] + assert 99 in ids + c.close()