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:
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
+22
-9
@@ -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(
|
||||
"""
|
||||
|
||||
+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": []}
|
||||
|
||||
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],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user