From 6d5bcb13e5b27f38fc46f872272dd9e4add373f9 Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 2 Jun 2026 07:38:54 +0000 Subject: [PATCH] Fix stale pinned-brief images; enrich all 7 + retry failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/routes/+page.svelte | 10 +++++++++- goodnews/enrich.py | 27 ++++++++++++++++++--------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 055acfb..b714dd2 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -119,7 +119,15 @@ 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 }; + // 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 { brief = fetched; P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items }); diff --git a/goodnews/enrich.py b/goodnews/enrich.py index f36a707..966e7ac 100644 --- a/goodnews/enrich.py +++ b/goodnews/enrich.py @@ -146,27 +146,36 @@ def fetch_og_image(url: str | None) -> str | None: return None # too many redirects -def enrich_brief_images(conn: sqlite3.Connection, brief_date: str, fetch=fetch_og_image, limit: int = 5) -> int: - """Fetch a hero-quality image for brief items that lack one (once each). +def enrich_brief_images( + 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; - stamps image_checked_at either way so failures are not retried forever. + Any of the brief's items can become the hero (via the client's fallback or a + 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. """ - # Brief items not yet checked. We fetch even when a feed image exists, - # because feed thumbnails are often tiny and the hero is shown large — a - # page's og:image (the social-share image) is the better hero visual. + # Fetch even when a feed image exists, because feed thumbnails are often tiny + # and the hero is shown large — a page's og:image is the better hero visual. rows = conn.execute( """ SELECT a.id, a.canonical_url FROM daily_briefs b JOIN daily_brief_items bi ON bi.brief_id = b.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 LIMIT ? """, - (brief_date, limit), + (brief_date, f"-{retry_days} days", limit), ).fetchall() found = 0