Fresh server data overrides a pinned brief; pin holds otherwise
Per the agreed model: the brief is server-authoritative and a client Replace is a soft override that yields when genuinely new data arrives. - build_daily_brief is now idempotent: if the composed selection is unchanged it leaves the brief (and its created_at) alone, so the timer's 15-min rebuilds are no-ops when no new data landed. - /api/brief exposes generated_at (the brief's created_at = a content-change stamp). The client pins its view against generated_at and keeps it across plain refreshes, but drops it and shows the fresh server brief when generated_at advances. Missed stories remain in the mood feeds. Tests: idempotent rebuild (no-op vs content change) — 93 total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -76,14 +76,16 @@
|
|||||||
const ex = Array.from(dismissed).join(',');
|
const ex = Array.from(dismissed).join(',');
|
||||||
const fetched = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
|
const fetched = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`);
|
||||||
const view = P.loadJSON(BRIEF_VIEW_KEY, null);
|
const view = P.loadJSON(BRIEF_VIEW_KEY, null);
|
||||||
// On a plain (re)load, keep the reader's curated view for the same day — so
|
// Keep the reader's curated view (swaps + hero) across plain refreshes — but
|
||||||
// swaps and the hero hold steady. Re-fetch fresh on a new day's brief, or
|
// only while the server's brief is unchanged. When genuinely fresh data
|
||||||
// when forced (e.g. a boundary changed and must be re-applied).
|
// arrives (generated_at advances), the server wins and the pin is dropped.
|
||||||
if (!fresh && view && view.date === fetched.brief_date && Array.isArray(view.items) && view.items.length) {
|
const sameServerBrief =
|
||||||
brief = { brief_date: view.date, title: fetched.title, items: view.items };
|
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 {
|
} else {
|
||||||
brief = fetched;
|
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);
|
remember(brief.items);
|
||||||
}
|
}
|
||||||
@@ -155,8 +157,9 @@
|
|||||||
if (i >= 0) {
|
if (i >= 0) {
|
||||||
brief.items[i] = repl;
|
brief.items[i] = repl;
|
||||||
brief = { ...brief, items: [...brief.items] };
|
brief = { ...brief, items: [...brief.items] };
|
||||||
// Pin the swap so this exact curated brief survives a refresh.
|
// Pin the swap against the current server brief; it holds until fresh
|
||||||
P.saveJSON(BRIEF_VIEW_KEY, { date: brief.brief_date, items: brief.items });
|
// server data arrives (then the server's version takes over).
|
||||||
|
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: brief.generated_at, items: brief.items });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const i = feed.findIndex((a) => a.id === article.id);
|
const i = feed.findIndex((a) => a.id === article.id);
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ class FeedResponse(BaseModel):
|
|||||||
class BriefResponse(BaseModel):
|
class BriefResponse(BaseModel):
|
||||||
brief_date: str | None
|
brief_date: str | None
|
||||||
title: str | None
|
title: str | None
|
||||||
|
generated_at: str | None = None # freshness stamp: changes only when content changes
|
||||||
items: list[Article]
|
items: list[Article]
|
||||||
|
|
||||||
|
|
||||||
@@ -336,6 +337,7 @@ def create_app() -> FastAPI:
|
|||||||
return BriefResponse(
|
return BriefResponse(
|
||||||
brief_date=data["brief_date"],
|
brief_date=data["brief_date"],
|
||||||
title=data["title"],
|
title=data["title"],
|
||||||
|
generated_at=data.get("created_at"),
|
||||||
items=[Article.from_row(r) for r in items],
|
items=[Article.from_row(r) for r in items],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+22
-9
@@ -14,10 +14,29 @@ def build_daily_brief(
|
|||||||
window_days: int = 3,
|
window_days: int = 3,
|
||||||
) -> int:
|
) -> int:
|
||||||
target_date = brief_date or date.today().isoformat()
|
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()
|
existing = conn.execute("SELECT id FROM daily_briefs WHERE brief_date = ?", (target_date,)).fetchone()
|
||||||
if existing and not replace:
|
if existing:
|
||||||
return int(existing["id"])
|
existing_ids = [
|
||||||
if existing and replace:
|
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"],))
|
conn.execute("DELETE FROM daily_briefs WHERE id = ?", (existing["id"],))
|
||||||
|
|
||||||
brief_id = conn.execute(
|
brief_id = conn.execute(
|
||||||
@@ -25,12 +44,6 @@ def build_daily_brief(
|
|||||||
(target_date, f"Highlights from Today - {target_date}"),
|
(target_date, f"Highlights from Today - {target_date}"),
|
||||||
).lastrowid
|
).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):
|
for index, row in enumerate(selected, start=1):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
+3
-2
@@ -120,11 +120,11 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
|
|||||||
return {"brief_date": None, "title": None, "items": []}
|
return {"brief_date": None, "title": None, "items": []}
|
||||||
|
|
||||||
header = conn.execute(
|
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,),
|
(target_date,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not header:
|
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(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -143,6 +143,7 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
|
|||||||
return {
|
return {
|
||||||
"brief_date": header["brief_date"],
|
"brief_date": header["brief_date"],
|
||||||
"title": header["title"],
|
"title": header["title"],
|
||||||
|
"created_at": header["created_at"],
|
||||||
"items": [dict(row) for row in rows],
|
"items": [dict(row) for row in rows],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user