Fix stale pinned-brief images; enrich all 7 + retry failures

Root cause (Codex audit): the client pins the brief by generated_at, but image
enrichment populates image_url AFTER the brief is built without bumping
generated_at — so a verbatim pinned copy stays imageless even once the server
has the image. The reclassify rebuilt the brief and the early pin stuck.

- Frontend: when reusing a pinned brief (same generated_at), refresh server-owned
  metadata by article id (esp. image_url) while preserving the user's order and
  replacements. Re-saves the merged view so it stays current.
- enrich_brief_images: default limit 5 -> 7 (any brief item can become the hero
  via the client fallback or a replace, so cover the whole brief).
- Don't cache image failures forever: retry brief items still missing an image
  after a TTL (retry_days=2) instead of stamping them imageless permanently.

Pairs with the hero image fallback (dd0087b). 99 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-02 07:38:54 +00:00
parent dd0087b8b3
commit 6d5bcb13e5
2 changed files with 27 additions and 10 deletions
+9 -1
View File
@@ -119,7 +119,15 @@
const sameServerBrief = const sameServerBrief =
view && view.generated_at && fetched.generated_at && view.generated_at === fetched.generated_at; view && view.generated_at && fetched.generated_at && view.generated_at === fetched.generated_at;
if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) { if (!fresh && sameServerBrief && Array.isArray(view.items) && view.items.length) {
brief = { ...fetched, items: view.items }; // Same server brief: keep the user's pinned order + replacements, but
// refresh server-owned metadata by id. image_url especially is enriched
// AFTER the brief is built (without bumping generated_at), so a verbatim
// pinned copy can stay imageless forever. Items the user swapped in
// (absent from the fresh brief) keep their own data.
const freshById = new Map(fetched.items.map((a) => [a.id, a]));
const items = view.items.map((it) => freshById.get(it.id) ?? it);
brief = { ...fetched, items };
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items });
} else { } else {
brief = fetched; brief = fetched;
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items }); P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items });
+18 -9
View File
@@ -146,27 +146,36 @@ def fetch_og_image(url: str | None) -> str | None:
return None # too many redirects return None # too many redirects
def enrich_brief_images(conn: sqlite3.Connection, brief_date: str, fetch=fetch_og_image, limit: int = 5) -> int: def enrich_brief_images(
"""Fetch a hero-quality image for brief items that lack one (once each). conn: sqlite3.Connection, brief_date: str, fetch=fetch_og_image, limit: int = 7, retry_days: int = 2
) -> int:
"""Fetch a hero-quality image for brief items that lack one.
Only touches the given date's brief items with no image and not yet checked; Any of the brief's items can become the hero (via the client's fallback or a
stamps image_checked_at either way so failures are not retried forever. replace), so this covers the whole brief (limit defaults to the brief size, 7),
not just the top few. Items already carrying an image are left alone; items
still without one are retried after `retry_days` so a transient fetch failure
or a weaker earlier extractor doesn't mark an article imageless forever.
Returns how many images were newly found. Returns how many images were newly found.
""" """
# Brief items not yet checked. We fetch even when a feed image exists, # Fetch even when a feed image exists, because feed thumbnails are often tiny
# because feed thumbnails are often tiny and the hero is shown large — a # and the hero is shown large — a page's og:image is the better hero visual.
# page's og:image (the social-share image) is the better hero visual.
rows = conn.execute( rows = conn.execute(
""" """
SELECT a.id, a.canonical_url SELECT a.id, a.canonical_url
FROM daily_briefs b FROM daily_briefs b
JOIN daily_brief_items bi ON bi.brief_id = b.id JOIN daily_brief_items bi ON bi.brief_id = b.id
JOIN articles a ON a.id = bi.article_id JOIN articles a ON a.id = bi.article_id
WHERE b.brief_date = ? AND a.image_checked_at IS NULL WHERE b.brief_date = ?
AND (
a.image_checked_at IS NULL
OR ((a.image_url IS NULL OR a.image_url = '')
AND a.image_checked_at < datetime('now', ?))
)
ORDER BY bi.rank ORDER BY bi.rank
LIMIT ? LIMIT ?
""", """,
(brief_date, limit), (brief_date, f"-{retry_days} days", limit),
).fetchall() ).fetchall()
found = 0 found = 0