diff --git a/data/wotd_audio/reverence.mp3 b/data/wotd_audio/reverence.mp3
new file mode 100644
index 0000000..46569bc
Binary files /dev/null and b/data/wotd_audio/reverence.mp3 differ
diff --git a/deploy/caddy/Caddyfile.snapshot b/deploy/caddy/Caddyfile.snapshot
index aa1bd14..9e6a2b2 100644
--- a/deploy/caddy/Caddyfile.snapshot
+++ b/deploy/caddy/Caddyfile.snapshot
@@ -60,7 +60,7 @@ upbeatbytes.com {
# Hidden in-progress prototypes — keep crawlers out at the HTTP level (the JS
# isn't seen by non-JS bots since the static shell is generic).
- @hidden path /home2 /home3
+ @hidden path /home2 /home3 /word /word.html /quote /quote.html /onthisday /onthisday.html /admin /admin.html
header @hidden X-Robots-Tag "noindex, nofollow"
# Content-hashed assets never change for a given URL — cache them forever.
diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte
index 239629f..af9efae 100644
--- a/frontend/src/routes/admin/+page.svelte
+++ b/frontend/src/routes/admin/+page.svelte
@@ -230,7 +230,7 @@
joyLoading = false;
}
async function joyMutate(id, action, fields) {
- try { await postJSON(`/api/admin/joys/${joyKind}/${id}`, { action, fields }); await loadJoys(joyKind); } catch { /* ignore */ }
+ try { await postJSON(`/api/admin/joys/${joyKind}/items/${id}`, { action, fields }); await loadJoys(joyKind); } catch { /* ignore */ }
}
async function joyAddSubmit() {
joyAddErr = '';
diff --git a/goodnews/api.py b/goodnews/api.py
index c476b94..942cb6f 100644
--- a/goodnews/api.py
+++ b/goodnews/api.py
@@ -2358,6 +2358,7 @@ def create_app() -> FastAPI:
# --- Small-joys admin: manage the WOTD / QOTD / On-This-Day pools ----------------
_JOY_TABLES = {"onthisday": "onthisday_pool", "quote": "quote_pool", "word": "wotd_pool"}
+ _JOY_DAILY = {"onthisday": "daily_onthisday", "quote": "daily_quote", "word": "daily_wotd"}
_JOY_MODULES = {"onthisday": onthisday, "quote": quote, "word": wotd}
_JOY_EDITABLE = { # whitelist of editable columns
"onthisday": {"text", "summary", "year"},
@@ -2375,7 +2376,7 @@ def create_app() -> FastAPI:
rows = conn.execute(f"SELECT * FROM {table} ORDER BY featured DESC, id DESC LIMIT ?", (limit,)).fetchall()
return [dict(r) for r in rows]
- @app.post("/api/admin/joys/{kind}/{item_id}")
+ @app.post("/api/admin/joys/{kind}/items/{item_id}") # /items/ so 'add'/'repick' don't parse as an id
def admin_joys_mutate(kind: str, item_id: int, body: JoyAction, request: Request) -> dict:
table = _JOY_TABLES.get(kind)
if not table:
@@ -2438,7 +2439,11 @@ def create_app() -> FastAPI:
raise HTTPException(status_code=404, detail="Unknown joy.")
with get_conn() as conn:
_require_admin(conn, request)
- picked = mod.pick_daily(conn, force=True)
+ cur = conn.execute(
+ f"SELECT pool_id FROM {_JOY_DAILY[kind]} WHERE feature_date=?", (local_today(),)
+ ).fetchone()
+ avoid = cur["pool_id"] if cur else None # force a DIFFERENT item, not the same one
+ picked = mod.pick_daily(conn, force=True, avoid=avoid)
return {"ok": True, "picked": bool(picked)}
@app.get("/api/replacement", response_model=Article | None)
diff --git a/goodnews/onthisday.py b/goodnews/onthisday.py
index 41c2b05..e5eeb8b 100644
--- a/goodnews/onthisday.py
+++ b/goodnews/onthisday.py
@@ -62,10 +62,15 @@ def _llm_keep(client, candidates: list[dict]) -> list[dict]:
keep the keyword-passed set (never lose the day to a model hiccup)."""
lines = [f"{i}: {c['text']}" for i, c in enumerate(candidates)]
user = (
- "These are 'on this day' history events. Return the indices of the ones with a "
- "POSITIVE or NEUTRAL, uplifting tone — discoveries, inventions, firsts, achievements, "
- "peace, the arts, science, exploration, culture, milestones. EXCLUDE anything about "
- "war, violence, disasters, death, or tragedy.\n\n" + "\n".join(lines) +
+ "These are 'on this day' history events. Return the indices of the ones that are "
+ "GENUINELY UPLIFTING — a reader should feel a small lift of wonder, hope, or delight. "
+ "Keep: discoveries, inventions, scientific breakthroughs, the arts and culture, "
+ "exploration, human achievement, acts of courage or kindness, milestones of progress "
+ "(rights won, things built, records set). EXCLUDE war, violence, disasters, death, or "
+ "tragedy, AND exclude merely procedural or political-administrative events that carry no "
+ "warmth (a coronation or accession, a treaty signing, an election, a law passed, a "
+ "boundary or office change). When unsure whether something is truly uplifting, leave it "
+ "out.\n\n" + "\n".join(lines) +
'\n\nReply with JSON only, exactly: {"keep": []}'
)
txt = client.chat_text([{"role": "user", "content": user}])
@@ -115,23 +120,29 @@ def harvest(conn: sqlite3.Connection, md: str | None = None, client=None) -> dic
return {"md": md, "fetched": len(events), "kept": len(kept), "added": after - before, "pool": after}
-def _candidates(conn: sqlite3.Connection, md: str) -> list[int]:
+def _candidates(conn: sqlite3.Connection, md: str, avoid: int | None = None) -> list[int]:
"""The pick pool for a date: if admin has featured any, pick only among those;
- otherwise the N least-recently-shown."""
+ otherwise the N least-recently-shown. `avoid` drops a specific id (admin re-pick)
+ unless it's the only option."""
featured = conn.execute(
"SELECT id FROM onthisday_pool WHERE md=? AND blocked=0 AND featured=1 ORDER BY id", (md,)
).fetchall()
if featured:
- return [r[0] for r in featured]
- rows = conn.execute(
- "SELECT id FROM onthisday_pool WHERE md=? AND blocked=0 "
- "ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
- (md, _NO_REPEAT_POOL),
- ).fetchall()
- return [r[0] for r in rows]
+ ids = [r[0] for r in featured]
+ else:
+ rows = conn.execute(
+ "SELECT id FROM onthisday_pool WHERE md=? AND blocked=0 "
+ "ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
+ (md, _NO_REPEAT_POOL),
+ ).fetchall()
+ ids = [r[0] for r in rows]
+ if avoid is not None:
+ ids = [i for i in ids if i != avoid] or ids
+ return ids
-def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, force: bool = False) -> dict | None:
+def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, force: bool = False,
+ avoid: int | None = None) -> dict | None:
"""Pick + cache today's fact. Idempotent (skips if today's done unless force).
Returns the stored row, or None if the pool has nothing for today's date."""
feature_date = feature_date or local_today()
@@ -139,7 +150,7 @@ def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, force:
existing = conn.execute("SELECT * FROM daily_onthisday WHERE feature_date=?", (feature_date,)).fetchone()
if existing and not force:
return dict(existing)
- ids = _candidates(conn, md)
+ ids = _candidates(conn, md, avoid)
if not ids:
return None
pick_id = daily.seeded_order(ids, feature_date)[0]
diff --git a/goodnews/quote.py b/goodnews/quote.py
index b60e03a..9c1ba0d 100644
--- a/goodnews/quote.py
+++ b/goodnews/quote.py
@@ -58,25 +58,30 @@ def _explain(client, text: str, author: str | None) -> str | None:
return out or None
-def _candidates(conn: sqlite3.Connection) -> list[int]:
+def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]:
featured = conn.execute(
"SELECT id FROM quote_pool WHERE blocked=0 AND featured=1 ORDER BY id"
).fetchall()
if featured:
- return [r[0] for r in featured]
- rows = conn.execute(
- "SELECT id FROM quote_pool WHERE blocked=0 ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
- (_NO_REPEAT_POOL,),
- ).fetchall()
- return [r[0] for r in rows]
+ ids = [r[0] for r in featured]
+ else:
+ rows = conn.execute(
+ "SELECT id FROM quote_pool WHERE blocked=0 ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
+ (_NO_REPEAT_POOL,),
+ ).fetchall()
+ ids = [r[0] for r in rows]
+ if avoid is not None:
+ ids = [i for i in ids if i != avoid] or ids
+ return ids
-def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, client=None, force: bool = False) -> dict | None:
+def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, client=None,
+ force: bool = False, avoid: int | None = None) -> dict | None:
feature_date = feature_date or local_today()
existing = conn.execute("SELECT * FROM daily_quote WHERE feature_date=?", (feature_date,)).fetchone()
if existing and not force:
return dict(existing)
- ids = _candidates(conn)
+ ids = _candidates(conn, avoid)
if not ids:
return None
pick_id = daily.seeded_order(ids, feature_date)[0]
diff --git a/goodnews/wotd.py b/goodnews/wotd.py
index a0b235c..9144dd6 100644
--- a/goodnews/wotd.py
+++ b/goodnews/wotd.py
@@ -44,23 +44,33 @@ def _http_bytes(url: str, timeout: int = 30) -> tuple[bytes, str]:
return r.read(), (r.headers.get("Content-Type") or "")
-def _propose_words(client, n: int) -> list[str]:
+def _propose_words(client, n: int) -> list[dict]:
+ """Ask for word + the intended part of speech, so _lookup picks the sense the LLM meant
+ (e.g. 'serene' the adjective, not the archaic noun)."""
user = (
f"Suggest {n} English vocabulary words for an uplifting 'word of the day' — positive, "
"calming, hopeful, or quietly beautiful in meaning (e.g. serene, kindness, dawn, "
- "resilience, wonder). Real, usable words; vary common and slightly elevated. "
- 'Reply with JSON only: {"words": ["...", "..."]}'
+ "resilience, wonder). Real, usable words; vary common and slightly elevated. For each, "
+ "give the part of speech you intend (the everyday modern sense, not an archaic one). "
+ 'Reply with JSON only: {"words": [{"word": "serene", "pos": "adjective"}, ...]}'
)
txt = client.chat_text([{"role": "user", "content": user}])
m = re.search(r"\{.*\}", txt, re.S)
if not m:
return []
- words = json.loads(m.group(0)).get("words", [])
- return [str(w).strip().lower() for w in words if isinstance(w, str) and w.strip()]
+ out = []
+ for w in json.loads(m.group(0)).get("words", []):
+ if isinstance(w, str) and w.strip():
+ out.append({"word": w.strip().lower(), "pos": None})
+ elif isinstance(w, dict) and str(w.get("word", "")).strip():
+ out.append({"word": str(w["word"]).strip().lower(),
+ "pos": (str(w.get("pos")).strip().lower() or None) if w.get("pos") else None})
+ return out
-def _lookup(word: str) -> dict | None:
- """Validate + enrich a word via the dictionary. Returns None if it's not a real word."""
+def _lookup(word: str, prefer_pos: str | None = None) -> dict | None:
+ """Validate + enrich a word via the dictionary. Returns None if it's not a real word.
+ When prefer_pos is given, picks the meaning of that part of speech (the sense the LLM meant)."""
try:
data = daily.http_json(f"{DICT_BASE}/{urllib.parse.quote(word)}")
except Exception: # noqa: BLE001 — unknown word / network → just skip it
@@ -71,9 +81,22 @@ def _lookup(word: str) -> dict | None:
meanings = entry.get("meanings") or []
if not meanings or not (meanings[0].get("definitions") or []):
return None
- definition = (meanings[0]["definitions"][0].get("definition") or "").strip()
- if not definition:
+ # Prefer the meaning whose part of speech matches the LLM's intent; else the first usable one.
+ chosen = None
+ if prefer_pos:
+ for mn in meanings:
+ if (mn.get("partOfSpeech") or "").strip().lower() == prefer_pos and (mn.get("definitions") or []):
+ if (mn["definitions"][0].get("definition") or "").strip():
+ chosen = mn
+ break
+ if chosen is None:
+ for mn in meanings:
+ if (mn.get("definitions") or []) and (mn["definitions"][0].get("definition") or "").strip():
+ chosen = mn
+ break
+ if chosen is None:
return None
+ definition = (chosen["definitions"][0].get("definition") or "").strip()
phonetic = entry.get("phonetic")
audio_url = None
for p in (entry.get("phonetics") or []):
@@ -82,13 +105,13 @@ def _lookup(word: str) -> dict | None:
if not audio_url and p.get("audio"):
audio_url = p["audio"]
examples = []
- for m in meanings:
+ for m in [chosen] + [mn for mn in meanings if mn is not chosen]: # chosen sense's examples first
for d in (m.get("definitions") or []):
if d.get("example"):
examples.append(d["example"].strip())
return {
"word": (entry.get("word") or word).strip().lower(),
- "part_of_speech": meanings[0].get("partOfSpeech"),
+ "part_of_speech": chosen.get("partOfSpeech"),
"phonetic": phonetic,
"audio_url": audio_url,
"definition": definition,
@@ -136,10 +159,11 @@ def harvest(conn: sqlite3.Connection, client, count: int = _HARVEST_BATCH) -> di
except Exception: # noqa: BLE001
return {"proposed": 0, "added": 0, "pool": _pool_count(conn)}
rows = []
- for w in words:
+ for item in words:
+ w = item["word"]
if not w.isalpha() or conn.execute("SELECT 1 FROM wotd_pool WHERE word=?", (w,)).fetchone():
continue
- info = _lookup(w)
+ info = _lookup(w, item.get("pos"))
if not info:
continue
audio_file = _cache_audio(info["audio_url"], info["word"])
@@ -155,23 +179,28 @@ def harvest(conn: sqlite3.Connection, client, count: int = _HARVEST_BATCH) -> di
return {"proposed": len(words), "added": after - before, "pool": after}
-def _candidates(conn: sqlite3.Connection) -> list[int]:
+def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]:
featured = conn.execute("SELECT id FROM wotd_pool WHERE blocked=0 AND featured=1 ORDER BY id").fetchall()
if featured:
- return [r[0] for r in featured]
- rows = conn.execute(
- "SELECT id FROM wotd_pool WHERE blocked=0 ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
- (_NO_REPEAT_POOL,),
- ).fetchall()
- return [r[0] for r in rows]
+ ids = [r[0] for r in featured]
+ else:
+ rows = conn.execute(
+ "SELECT id FROM wotd_pool WHERE blocked=0 ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
+ (_NO_REPEAT_POOL,),
+ ).fetchall()
+ ids = [r[0] for r in rows]
+ if avoid is not None:
+ ids = [i for i in ids if i != avoid] or ids
+ return ids
-def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, force: bool = False) -> dict | None:
+def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, force: bool = False,
+ avoid: int | None = None) -> dict | None:
feature_date = feature_date or local_today()
existing = conn.execute("SELECT * FROM daily_wotd WHERE feature_date=?", (feature_date,)).fetchone()
if existing and not force:
return dict(existing)
- ids = _candidates(conn)
+ ids = _candidates(conn, avoid)
if not ids:
return None
pick_id = daily.seeded_order(ids, feature_date)[0]
diff --git a/tests/test_joys_admin.py b/tests/test_joys_admin.py
new file mode 100644
index 0000000..0ab947f
--- /dev/null
+++ b/tests/test_joys_admin.py
@@ -0,0 +1,95 @@
+"""Small-joys admin API — route resolution (add/repick must NOT parse as an item id),
+auth gating, add/feature/block/delete with numeric ids, and re-pick excluding the
+currently-shown item."""
+import pytest
+from fastapi.testclient import TestClient
+
+from goodnews.db import connect, init_db
+
+
+@pytest.fixture
+def api_app(tmp_path, monkeypatch):
+ db = tmp_path / "t.sqlite3"
+ monkeypatch.setenv("GOODNEWS_DB", str(db))
+ monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
+ monkeypatch.setenv("GOODNEWS_ADMIN_EMAILS", "admin@b.com")
+ monkeypatch.setenv("GOODNEWS_LLM_BASE_URL", "http://127.0.0.1:9") # dead → no LLM, fast
+ import importlib
+ import goodnews.api as api
+ importlib.reload(api)
+ c = connect(str(db)); init_db(c)
+ c.close()
+ return api.create_app()
+
+
+def _admin(app):
+ tc = TestClient(app)
+ sent = {}
+ import goodnews.email_send as es
+ orig = es.send_magic_link
+ es.send_magic_link = lambda to, link: sent.update(link=link)
+ try:
+ tc.post("/api/auth/email/start", json={"email": "admin@b.com"})
+ tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
+ finally:
+ es.send_magic_link = orig
+ return tc
+
+
+def test_add_and_repick_require_admin_not_422(api_app):
+ """The blocker: /add and /repick must resolve to their own routes (401 unauth),
+ not be swallowed by /{item_id} and 422 on int parsing."""
+ anon = TestClient(api_app)
+ for kind in ("quote", "word", "onthisday"):
+ assert anon.post(f"/api/admin/joys/{kind}/add", json={}).status_code == 401
+ assert anon.post(f"/api/admin/joys/{kind}/repick").status_code == 401
+ assert anon.get(f"/api/admin/joys/{kind}").status_code == 401
+ assert anon.post(f"/api/admin/joys/{kind}/items/1",
+ json={"action": "feature"}).status_code == 401
+
+
+def test_quote_add_feature_block_delete(api_app):
+ tc = _admin(api_app)
+ r = tc.post("/api/admin/joys/quote/add",
+ json={"text": "A small kindness echoes far.", "author": "Anon"})
+ assert r.status_code == 200 and r.json()["ok"] is True
+ items = tc.get("/api/admin/joys/quote").json() # endpoint returns a bare list
+ qid = next(it["id"] for it in items if "kindness echoes" in (it.get("text") or ""))
+ assert tc.post(f"/api/admin/joys/quote/items/{qid}", json={"action": "feature"}).json()["ok"]
+ assert tc.post(f"/api/admin/joys/quote/items/{qid}", json={"action": "block"}).json()["ok"]
+ assert tc.post(f"/api/admin/joys/quote/items/{qid}", json={"action": "delete"}).json()["ok"]
+ remaining = tc.get("/api/admin/joys/quote").json()
+ assert all(it["id"] != qid for it in remaining)
+
+
+def test_repick_excludes_current(api_app):
+ tc = _admin(api_app)
+ tc.post("/api/admin/joys/quote/add", json={"text": "First light is a fresh start.", "author": "A"})
+ tc.post("/api/admin/joys/quote/add", json={"text": "Every step forward counts.", "author": "B"})
+ assert tc.post("/api/admin/joys/quote/repick").json()["picked"] is True # establish today's pick
+ first = tc.get("/api/quote/today").json()
+ assert tc.post("/api/admin/joys/quote/repick").json()["picked"] is True # force a fresh one
+ second = tc.get("/api/quote/today").json()
+ assert second["text"] != first["text"] # a genuinely different item
+
+
+def test_word_add_and_repick(api_app, monkeypatch):
+ import goodnews.wotd as wotd
+ # admin word-add validates against the dictionary + caches audio — stub both (no network)
+ fake = {"serene": {"word": "serene", "part_of_speech": "adjective", "phonetic": "/sɪˈriːn/",
+ "audio_url": None, "definition": "Calm, peaceful, untroubled.", "examples": []},
+ "luminous": {"word": "luminous", "part_of_speech": "adjective", "phonetic": "/ˈluːmɪnəs/",
+ "audio_url": None, "definition": "Giving off light; radiant.", "examples": []}}
+ monkeypatch.setattr(wotd, "_lookup", lambda w, prefer_pos=None: fake.get(w))
+ monkeypatch.setattr(wotd, "_cache_audio", lambda url, word: None)
+
+ tc = _admin(api_app)
+ assert tc.post("/api/admin/joys/word/add", json={"word": "serene"}).json()["ok"]
+ assert tc.post("/api/admin/joys/word/add", json={"word": "luminous"}).json()["ok"]
+ items = tc.get("/api/admin/joys/word").json()
+ assert {"serene", "luminous"} <= {it.get("word") for it in items}
+ assert tc.post("/api/admin/joys/word/repick").json()["picked"] is True
+ first = tc.get("/api/word/today").json()
+ assert tc.post("/api/admin/joys/word/repick").json()["picked"] is True
+ second = tc.get("/api/word/today").json()
+ assert second["word"] != first["word"]
diff --git a/tests/test_wotd.py b/tests/test_wotd.py
index af8e799..611a899 100644
--- a/tests/test_wotd.py
+++ b/tests/test_wotd.py
@@ -24,7 +24,7 @@ class FakeClient:
@pytest.fixture
def conn(tmp_path, monkeypatch):
monkeypatch.setenv("GOODNEWS_WOTD_AUDIO", str(tmp_path / "audio"))
- monkeypatch.setattr(wotd, "_lookup", lambda w: FAKE_DICT.get(w)) # no dictionary network
+ monkeypatch.setattr(wotd, "_lookup", lambda w, prefer_pos=None: FAKE_DICT.get(w)) # no dictionary network
monkeypatch.setattr(wotd, "_cache_audio", lambda url, word: f"{word}.mp3" if url else None)
c = connect(":memory:"); init_db(c)
yield c
@@ -64,3 +64,24 @@ def test_get_today_never_empty(conn):
def test_run_daily_bootstraps(conn):
r = wotd.run_daily(conn, client=FakeClient())
assert r["pool"] == 2 and r["picked"] in ("serene", "dawn")
+
+
+def test_lookup_prefers_intended_pos(monkeypatch):
+ """When the LLM says 'serene' as an adjective, _lookup must pick the adjective sense,
+ not an earlier archaic noun sense the dictionary lists first."""
+ entry = {"word": "serene", "phonetics": [], "meanings": [
+ {"partOfSpeech": "noun", "definitions": [{"definition": "Serenity; clearness; calmness."}]},
+ {"partOfSpeech": "adjective", "definitions": [{"definition": "Calm, peaceful, untroubled."}]},
+ ]}
+ monkeypatch.setattr(wotd.daily, "http_json", lambda url, timeout=20: [entry])
+ assert wotd._lookup("serene", "adjective")["part_of_speech"] == "adjective"
+ assert wotd._lookup("serene", "adjective")["definition"] == "Calm, peaceful, untroubled."
+ assert wotd._lookup("serene")["part_of_speech"] == "noun" # no preference → first usable sense
+
+
+def test_propose_words_accepts_dicts_and_strings():
+ class C:
+ def chat_text(self, m):
+ return '{"words": [{"word": "Serene", "pos": "Adjective"}, "dawn", {"word": ""}]}'
+ out = wotd._propose_words(C(), 3)
+ assert out == [{"word": "serene", "pos": "adjective"}, {"word": "dawn", "pos": None}]