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}]