Small joys backend: shared daily framework + On This Day engine

- goodnews/daily.py: shared helpers for the daily "small joys" (http_json, date-seeded
  deterministic pick, dedup key) so each joy is a small self-contained module.
- goodnews/onthisday.py: harvest today's MM-DD from Wikimedia's On-this-day feed →
  tone-filter to good/neutral (keyword floor + optional LLM refine) → pool → deterministic
  daily pick (idempotent, respects blocked/featured) → cached row. Network/LLM before any
  DB write. Multi-source ready (source column).
- db.py: onthisday_pool + daily_onthisday tables.
- api.py: GET /api/onthisday/today (edge-cacheable).
- cli.py: cycle step (run after Daily Art; --no-joys to skip), LLM client for tone refine.
- tests/test_onthisday.py: 7 tests (filter+dedup, pick idempotent, blocked/featured,
  never-empty, empty-pool, LLM-narrow). 382 backend tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-22 16:51:29 -04:00
parent 4739d87f4b
commit a7da8362ab
6 changed files with 351 additions and 2 deletions
+15 -1
View File
@@ -36,7 +36,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import art, auth, bloom, email_send, feeds, games, oauth_google, publishing, queries, share, sources, summarize
from . import art, auth, bloom, email_send, feeds, games, oauth_google, onthisday, publishing, queries, share, sources, summarize
from .localtime import local_today
from .markup import reply_html_to_text, sanitize_reply_html
from .db import connect
@@ -2290,6 +2290,20 @@ def create_app() -> FastAPI:
# Cached museum image: immutable for a given object id.
return FileResponse(str(matches[0]), headers={"Cache-Control": "public, max-age=31536000, immutable"})
@app.get("/api/onthisday/today")
def onthisday_today(response: Response) -> dict:
with get_conn() as conn:
a = onthisday.get_today(conn)
if not a:
response.headers["Cache-Control"] = _PRIVATE
raise HTTPException(status_code=404, detail="No fact yet.")
response.headers["Cache-Control"] = _EDGE_FEED # one fact a day, same for everyone
return {
"date": a["feature_date"], "year": a["year"], "text": a["text"],
"summary": a["summary"], "image_url": a["image_url"], "source_url": a["page_url"],
"source": a["source"],
}
@app.get("/api/replacement", response_model=Article | None)
def replacement(
exclude: str = Query("", description="comma-separated article ids already shown"),