Per Codex: the Next poll column computed only the streak-backoff time, so a
rate-limited source could show an earlier Next poll than the real gate (which
also requires retry_after_at <= now). Take the later of the two in the Python
post-process so the admin table agrees with due_source_rows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's spec — a publisher saying "slow down" shouldn't make a feed look
broken, but repeated 429s stay visible via last_success_at / stale-source.
* Schema: sources.retry_after_at (nullable) + migration.
* feeds.parse_retry_after: delta-seconds OR HTTP-date → UTC stamp; ignores
invalid/negative/past; caps at now + MAX_BACKOFF_MINUTES.
* fetch_feed raises RateLimited (carrying the parsed time) on a 429.
* poll_source: on 429 set retry_after_at + last_error, status='rate_limited',
and do NOT increment consecutive_failures; on success clear retry_after_at;
non-429 failures unchanged.
* due_source_rows requires BOTH the streak backoff elapsed AND retry_after_at
passed (i.e. the later of the two).
* Admin: source_health returns retry_after_at; status reads
"rate-limited · rests until …" rather than "failed/resting".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex polish note: promote/reject/re-preview now Object.assign the
server-returned candidate onto the local one — keeps status/updated_at/preview
(and any future fields) in sync, while preserving the transient UI fields
(_cat/_activate/_err). Promote uses res.candidate then loadStats().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the supervised source-candidate flow into Sources (Codex's v1 scope), so
adding feeds no longer needs the CLI.
* feeds.safe_fetch_feed: SSRF-safe fetch for UNTRUSTED (admin-pasted) URLs —
http(s) only, every redirect hop re-validated via enrich._host_is_public,
body size-capped, bounded redirects, no cookies. preview_feed gains a
`fetcher` param; the API path passes safe_fetch_feed (NOT the raw fetch_feed
used for already-vetted polling).
* API (admin-gated): GET /candidates; POST /candidates (suggest+preview, gated
before the outbound fetch, no DB conn held during network); /{id}/preview
(explicit re-preview); /{id}/promote (paused by default, returns the new
source + updated candidate); /{id}/reject. rejected stays on candidates only.
* Admin Sources tab: "Add a source" field + a candidate queue showing the
preview (pass rate, recent count, example headlines) with Promote (as paused,
or Activate immediately) / Re-preview / Reject.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex: upsert_sources() wrote `active` but not `status`, so a candidate
promoted inactive (the pipeline default) became active=0 + status='active' —
the exact mirror drift Phase 1 set out to avoid (scheduler won't poll, admin UI
shows "active"). Now derive status from an explicit value or from active, mirror
active off status, and write both columns together (insert + conflict update).
Test: promote_candidate(active=False) → status='paused', active=0.
Also fix stale source_health docstring (now includes retired).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's plan — introduce a lifecycle without a risky "change the source of
truth everywhere" moment.
* Schema: sources.status (active|paused|retired) + content_visible; migration
backfills status from active (active=1→active, else paused), content_visible=1.
* `active` is kept as a SYNCED MIRROR: status active→active=1, paused/retired→0,
so the scheduler/CLI/legacy code keep working unchanged.
* Retire stops polling but keeps articles visible (non-destructive). Hiding is a
separate, reversible lever: content_visible=0 drops a source's articles from
the public feed + brief (read AND build), behind a confirm. Personal saved/
history are untouched.
* API: /sources/{id}/status (validates, mirrors active) + /visibility, replacing
/active. source_health returns status + content_visible.
* Admin: status column (active/paused/retired + "hidden"), Retired filter,
Pause/Resume · Retire/Restore · Hide/Show actions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex: slicing the SANITIZED html with [:8000] could cut through a tag or
entity. Cap the RAW editor HTML (20k) before sanitizing instead, and have
sanitize_reply_html auto-close any still-open allowed tags so malformed input
can never leave a dangling/severed tag in message_html or the email body.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the Markdown composer with a small contenteditable WYSIWYG (Codex
greenlit for this narrow, admin-only surface).
* markup.py: render_reply_html → sanitize_reply_html + reply_html_to_text.
Allowlist rebuild via stdlib HTMLParser — keeps strong/em/p/br/ul/ol/li and
span ONLY with a whitelisted font-size (13/15/18/22px); normalizes b→strong,
i→em, div→p, <font size> → safe span; drops links/images/arbitrary styles
(content kept as escaped text) and discards script/style content entirely.
* API: FeedbackReplyBody.html (raw editor HTML); endpoint sanitizes → message_html,
derives plain text → stored message + the email text/plain part. Unchanged:
multipart send, store-on-success, conn released during SMTP, mark-read, 404/400/422.
* Frontend: contenteditable editor + toolbar (Bold/Italic/Size/• List/1. List),
execCommand with styleWithCSS=false for semantic tags, font size wraps the
selection in a fixed-px span, paste intercepted as plain text. No links yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's optional note: alongside the ingest-wide duplicate_rate, expose
accepted_dup_rate — of what a source got ACCEPTED, how much was a duplicate of
already-served content (accepted_total − served). Nearly free (derived from
existing counts); surfaced as a tooltip on the Dup column so the table stays
uncluttered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex: a constrained Markdown-ish composer rather than contenteditable.
* goodnews/markup.render_reply_html — escapes everything first, then introduces
only a tiny whitelist (**bold**, - bullets, #/##/### headings, paragraphs,
line breaks). No links, attributes, inline styles, or raw HTML passthrough.
* feedback_replies.message_html column (+ live migration); replies store both
the Markdown text and the rendered HTML.
* email_send.send_feedback_reply now sends multipart text/plain + text/html
(the sanitized render, wrapped in a trusted email template).
* Frontend: textarea + a small toolbar (Bold / • List / H) that inserts
Markdown; the reply thread renders the server-sanitized HTML.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's polish note: flagging a source now opens a small inline popover for
the optional reason (consistent with the calm admin UI) instead of a native
prompt(). Clearing a flag stays immediate. Backdrop/Escape/Cancel close it;
Enter confirms. Optimistic with revert-on-failure, like the other actions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex: validate + gather in one short DB block, send SMTP with no
connection held (~20s), then reopen to record the reply + mark read. Better
operational hygiene; no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reply to a reader from the admin inbox instead of a mailto. Per Codex: keep v1
plain text (no rich editor — defers the user's bold/bullets ask as a fast-follow).
* DB: feedback_replies table (feedback_id, user_id, message, sent_to, sent_at),
created on the live DB.
* email_send.send_feedback_reply: plain-text "Re: Your Upbeat Bytes feedback"
with a quoted context block, no analytics/account details.
* API: POST /api/admin/feedback/{id}/reply — admin-gated, requires the feedback
exists (404) and has a contact_email (400), trims+caps the message; sends via
SMTP and only records the reply on success (502 on send failure so the UI keeps
the draft); marks the item read. Feedback list now includes each item's replies.
* Frontend: inline composer (Send/Cancel, sending state, error keeps draft) +
reply thread under the message; Reply only shows when there's an address,
else "No reply address".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/admin/stats accepts ?days= (clamped to 7/30/90, default 30) → passed to
admin_stats, which already windows visitors, retention, funnel, sharing, daily
trend, and the top lists by that span. Frontend adds a Window picker on the
analytics tabs (Overview/Content/Audience); changing it refetches and the
windowed labels ("Visitors (Nd)", "Last N days", "Returning visitors (Nd)")
follow. Corpus totals + source health are unaffected (not time-windowed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn the Sources tab into a real management console (per Codex):
* source_health now lists ALL sources (active + paused) with backing metrics:
served / accepted_total / total_articles / duplicates + acceptance & duplicate
rates + review_reason, alongside last success/attempt, next poll, failures.
* Admin endpoints (gated, 404 on missing): POST sources/{id}/active (pause/
resume) and /review (flag/clear with reason).
* Pausing only stops future polling — the feed query has no active filter, so a
paused source's accepted articles stay live.
* Frontend: metric table + Paused filter + per-row Pause/Resume & Flag/Clear
(optimistic, revert on failure). Attention 'resting' now scoped to active.
Retire/Delete intentionally deferred (distinct lifecycle state, later).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex note: raise the 404 before commit so a no-match read/delete commits
nothing. get_conn only closes (no auto-commit), so this is clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per audit: read-toggle and delete returned {"ok":true} even for a nonexistent
id. Return 404 when no row is affected, so the optimistic UI can distinguish a
stale/already-deleted row from a real success. (The postJSON/delJSON imports
flagged in the audit were already present — verified in source + built bundle.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the admin Feedback section a real inbox.
* DB: feedback.read_at column (schema + idempotent migration).
* API: feedback list returns read_at; POST /api/admin/feedback/{id}/read
{read} toggles it; DELETE /api/admin/feedback/{id} removes a message
(both admin-gated). admin_stats gains feedback_unread; the Attention strip
and the tab badge now count UNREAD, not total.
* Frontend: unread messages are highlighted with an accent rail + dot; an
Unread filter joins the category chips; each message has Mark read/unread
and Delete (confirm), with optimistic updates that revert on failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex audit:
* Unknown ?section= values now clamp to Overview, so the page never renders the
tabs with an empty body.
* Summary/image coverage counts join through articles+scores and require
accepted=1 AND duplicate_of IS NULL, so percentages stay ≤100% and honest as
rejected/duplicate rows accrue summaries over time.
* A source that's both resting and flagged now shows "⚠ resting · review"
rather than hiding the review flag behind the resting state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex's remaining caveat: appNavDepth decremented on every popstate, so a
browser Back then Forward undercounted (in-page Back would jump to Highlights
early). Use the navigation's signed delta on popstate (Back -1, Forward +1,
±N for jumps) instead of a flat decrement, so the depth stays accurate through
any back/forward dance. Falls back to -1 if delta is unavailable (safe).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex audit follow-ups:
* Track in-app navigation depth (forward goto/link increments, popstate
unwinds, clamped at 0) and base the in-page Back on it instead of
history.length. A direct deep link (email/social/article) now sends the
in-page Back to Highlights rather than out of the site.
* Apply the same stale-load guard to the Today/Highlights path that feed views
have, and only scroll-to-top when the load is still current — avoids stale
error/scroll state during quick navigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per audit (user + Codex): the in-page Back and browser Back were two separate
histories, which is confusing — especially for less-technical users. Make the
URL the single source of truth so both traverse one history.
* The view derives from the URL (/?view=latest, /?tag=, /?source=, bare / for
Highlights); `selected` is $derived from $page.url.
* All navigation goes through goto(); afterNavigate is the single loader hook,
so in-app clicks AND browser back/forward reload the same way.
* The in-page Back button now just calls history.back() (fallback to Highlights)
— identical to the browser Back. Removed the private navStack.
* Stop stripping ?source= — the URL stays honest, so source/tag views are
shareable and survive reload.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Position the in-feed Back button to the right of the view title (inline with
it) instead of stacked above; the accent underline moves under the title text.
* Deep-linking a source feed from an article page (/?source=<id>) now seeds the
back history so the Back button appears (returns to Highlights).
* Strip the ?source= param after consuming it (replaceState) so it can't linger
and make the browser back/forward behave oddly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Back button on feed views: drilling into a tag or source from a card now
remembers where you came from (a small history stack), and a "← Back" appears
in the view header to return there — chains of drill-ins included. Top-level
nav (rail/bottom bar) resets the history.
* Article page: the source name is now a link into that source's in-app feed
(/?source=<id>); the SPA reads the param on load and opens the source view
(label falls back to the loaded feed's source name). Completes the
"cards-only v1" — source is clickable on /a/ too now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Click a source name on any card → a feed of just that source's articles,
newest-first, still accepted / non-duplicate / boundary-filtered (the calm
promise isn't bypassed). A natural way to follow a publication's feel.
* queries.feed + /api/feed: source_id filter; Article output gains source_id.
* Frontend: source label is a button → transient 'source:<id>' view (like
'tag:<slug>'), rendered in the feed grid with Load more, header = source name.
* Ad-hoc, not a pinned lane. Foundation for a future source page (metadata) +
Follow; shareable /source/<slug> route and source_view analytics come then.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make "no blurry images" sustainable, not a one-off cleanup. RSS feed thumbnails
(~44% were ~90px) were stored at ingest and upscaled to mush, so new articles
would reintroduce them. Now image_url is filled ONLY by the quality-gated
og:image enrichment:
* insert_article no longer stores the feed image (was canonicalize_url(item...)).
* enrich_recent_images(): the cycle fetches a quality og:image for the newest
accepted, imageless articles each run (bounded), keeping Latest photo-rich.
* Brief + on-open enrichment unchanged.
Net: every stored image is a validated, ≥450px og:image; the rest are clean
placeholders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Some cards showed blurry photos — feed RSS thumbnails (~90×90, e.g. Phys.org's
/tmb/ path) that load fine but upscale to mush in the banner. Add a header-based
dimension parser (PNG/GIF/JPEG/WebP, stdlib only) and fold a minimum-size gate
(450×250) into the image validation, alongside the existing load check. Images
we can't measure (SVG/AVIF) still pass on content-type. A re-prune clears the
small ones already stored so those cards fall back to the clean placeholder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Carry the brief's uniform card language (compact photo banner or flat topic-
colored placeholder) onto every feed grid — Latest, topic/tag/mood views, and
the account Saved grid — so the whole site is visually consistent. Same quality
gate and uniform sizing; the brief hero remains the single large image.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Placeholder: bold, slightly larger initial letter on the topic word; make
health (teal) and environment (leaf green) clearly distinct and show more hue
in the deepened word so they're easy to tell apart.
* Article page: the source name was chopped to its first word ("Read the full
story at The") — use the full publisher name; open the source link in a new
tab so upbeatbytes.com stays put.
* Use the new SVG back arrow on the account and admin top bars (matching the
article page) instead of the old "←" glyph.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review: the summary and the "Why it's here" line were competing in a tight
card and both came up short. Remove "why" from cards (it still appears on the
article page when clicked in) and give the summary the room (image cards 2→4
lines). Also flatten the placeholder banner — solid topic-color tint instead of
a gradient-to-blank, with the topic word in a deep near-black shade of that
color so it reads clearly instead of washed out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A stored og:image isn't proof it renders: signed/hotlink-protected URLs (e.g.
the Guardian's i.guim.co.uk) 401 on a direct browser load, so they counted
toward coverage yet always fell back. Now fetch_og_image confirms the image
truly returns 200 + image/* (requested no-referrer, same SSRF-safe redirect
handling) before storing it. Add prune_broken_images() to clear already-stored
URLs that no longer load, so coverage is honest and those cards show the
placeholder cleanly. The browser onerror→placeholder remains the final safety net.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review, mixed photo/no-photo rows read as ragged ("the ones without feel
lacking"). Make every brief rest card carry a banner so the grid is uniform: a
compact 16:9 photo when available, otherwise a calm placeholder tinted by the
card's topic accent color (the same per-topic hue as the accent line) with the
topic word set faint in serif. A failed/blocked image falls back to the same
placeholder, so cards never look broken and heights stay identical. Hero remains
the single large image. Light desaturation on photos; no heavy tint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tie image enrichment to attention (per review): when an article earns a summary
(i.e. a reader reached it), best-effort fetch a real og:image if it lacks one —
never blanket-fetch every ingested article. Adds:
* enrich_article_image() — single-article fetch, leaves existing images alone,
retries an imageless article only after 7 days, stamps image_checked_at.
* generate_summary() calls it after caching (wrapped; never breaks summaries).
* enrich_summarized_images() + `goodnews enrich-images` CLI — slow background
backfill of already-summarized, accepted, imageless articles.
* Quality gate: extend the generic-image skip list with data:/tracking-pixel/
spacer markers (on top of the existing logo/placeholder + unbranded-BBC logic).
This is coverage only; display (editorial rhythm, tile treatment) comes next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex review: the body-horror boundary was directionally right but a hair too
broad — black-hole/cosmology, lunar-regolith engineering hazards, and a
microplastics measurement-methodology piece were rejected on dramatic vocabulary
alone (cortisol 4–6). Add scoring guidance: score cortisol by the reader's
personal/visceral/public-health threat, not by dramatic words or subject
grandeur. Distant astronomy, equipment hazards, geological forces, scientific
self-correction, natural-history mechanisms, predator–prey biology, and
historical discoveries are LOW cortisol (0–3) even when worded "deadly"/"lethal".
Reserve high cortisol for disease, contamination, outbreak, parasites, violence,
or immediate suffering.
Verified: black hole / moon / microplastics now accept (cortisol 1–2);
parasite (8), Ebola (6), hantavirus outbreak (6) still reject.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The flesh-eating-parasite story slipped through as "calm public-health
monitoring" — the gate had no body-horror class and let "informative/public
health" rescue a viscerally alarming subject. Two fixes:
* Reject visceral-threat hooks (outbreaks, parasites, infestations,
contamination, recalls, poisonings, "flesh-eating" infections) even when
calmly framed as monitoring/surveillance/awareness/public health — judge the
reader's gut, not the prose. Keep genuine health wins (treatments, recovery,
prevention, wellbeing): the line is the hook, not the topic.
* A high cortisol_score is disqualifying on its own — anxiety outweighs how
informative or constructive a piece is.
Verified: 3 flesh-eating-parasite variants now REJECT (cortisol 8) while calm
health/wellness (diabetes treatment, sleep tips, green-space study) still pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bare "←" glyph rendered tiny and sat low (baseline-aligned). Swap it for a
crisp SVG arrow and lay the button out as a centered inline-flex (arrow + label),
so it reads juicier and sits properly centered in the top bar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The server-rendered /a/ summary page had no in-page way back — the only option
was the browser's back button, which feels unfinished. Add a "← Back" control on
the right of the top bar (desktop + mobile). It uses history.back() when the
reader came from within the site, and falls back to the home page for visitors
who arrived via a shared link (no useful history).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructure the nav around two permanent lanes, then the reader's chosen ones:
"Highlights" (the curated daily brief — formerly "Today") and "Latest" (the
freshest accepted stories, newest-first). Now that the gate is tight, a
chronological "incoming" feed is safe to expose.
* feed(): new sort="latest" (pure recency) alongside the default best-first
rank; /api/feed exposes sort=ranked|latest (validated). Still accepted-only
and boundary-respecting either way.
* lanes.py: two pinned lanes (Highlights + Latest) instead of one.
* Home: "Latest" view + "Load more" pagination for every feed view (offset-
paged, de-duped). Mobile bottom bar gains a Latest tab.
* LanePicker shows both pinned lanes; nav rail renders them first.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Date fix: introduce GOODNEWS_TZ (goodnews/localtime.py) so the brief's "today"
rolls over in a pinned zone (Eastern) instead of UTC — robust to host-clock
resets. The home page now formats the brief's date in each VISITOR's local
timezone (from its UTC freshness stamp), so nobody ever sees "tomorrow."
* Admin "Content served": articles live, fresh (7d), ingested (24h), summaries,
active sources, today's brief size — queries.content_stats().
* Admin "Source health": per active source, the failure streak, last error,
accepted contribution, and computed next-poll time (so backoff / "resting
until" is visible), via queries.source_health() reusing the feeds backoff
math. Failing sources sort to the top; times render in the viewer's zone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-traffic cleanup from an audit:
* Scheduler: poll_due_sources now keys on the last *attempt* (success or
failure), not the last success, and scales the wait by the consecutive-
failure streak (capped at a day). A failing feed (e.g. Phys.org's HTTP 429s)
used to be retried every cycle because it had no successful run; it now backs
off and recovers on its own. Extracted due_source_rows() + tests.
* FK hygiene: deleting a daily_brief is supposed to cascade to its items, but
SQLite enforces foreign keys per-connection — connect() already sets the
pragma, so the cascade is correct going forward; added a regression test.
(Orphaned items + Phys.org settings were cleaned directly on the live DB.)
* a11y: modal/drawer dialogs are now focusable (tabindex), close on Escape
(window) and on backdrop click via a target check (dropping the inner
stopPropagation handlers). Build is warning-free.
* tests: conftest points any un-mocked LLM client at a closed port with a 1s
timeout, so an accidental real call fails fast instead of hanging the suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Lanes" section under Account that reuses LanePicker inline, completing
the round trip with Boundaries. Refactor LanePicker to support an `inline`
variant (bare panel vs modal) and apply changes immediately on toggle — so the
account panel needs no explicit save and the home modal now previews the nav
rail live as you pick. Selection still persists through the shared prefs store.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Readers can now choose which quick-access lanes sit above the feed; "Today"
stays pinned. The pool (goodnews/lanes.py, served at /api/lanes) is one source
of truth over three lane kinds the feed already renders: moods, primary topics,
and high-volume Discovery tags. Selection lives in the existing prefs blob
(localStorage + /api/prefs sync); the filter parser ignores the new `lanes`
field, so it rides along harmlessly. Default = today's moods, unchanged.
Food/Space stay grouping tags rather than primary topics (per review): `space`
already existed; added `food` to the Mind & Craft family so the classifier
assigns it, and seeded the Food lane by re-tagging the two food sources.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tighten the gate's AI handling per review: accept practical/beneficial/creative/
scientific/humane/bounded AI stories; reject AI framed around loss of control,
cognitive decline, job/surveillance/existential panic, child/social-harm panic,
"falling behind" productivity anxiety, or arms-race. Verified: MIT TR now rejects
"lose control of our brains" + "flood of AI lawsuits" (both previously accepted).
Shift the acceptance bar from "must be uplifting" to "will a reader finish this
calm or a little better, never worse." Keep neutral-but-absorbing (discoveries,
explainers, clever builds, useful insight), and reject anxiety-inducing content —
especially the comparison traps (inferior/behind/FOMO/hustle/status). Scores still
back the verdict. Lets us pull from mainstream sources and filter, rather than
relying on niche good-news outlets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the summary pages discoverable so traffic compounds passively:
- /today: a server-rendered, shareable + indexable digest of today's brief —
each item's title (→ /a summary), our summary, and a source link. OG/Twitter
meta + self-canonical.
- /sitemap.xml: dynamic — home, /today, and every accepted non-duplicate /a page
with lastmod. robots.txt allows all and points to it.
- Home (SPA shell) gains canonical + OG/Twitter tags for cleaner unfurls.
- Caddy routes /today + /sitemap.xml to the API. 133 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>