From 84b1fb514ffe5e6e9dcfc7f070b358506e9a39ae Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 22 Jun 2026 20:19:02 -0400 Subject: [PATCH] Small joys: Codex audit #2 fixes (route resolution, noindex, sense/tone, exclude-current re-pick) - Admin joy item route moved to /api/admin/joys/{kind}/items/{item_id} so the /add and /repick verbs resolve to their own routes instead of 422-ing as a non-int item id (the launch blocker). Frontend mutate URL updated to match. - Re-pick now excludes the currently-shown item: the endpoint reads today's daily pool_id and passes it as `avoid`, so "Re-pick today" yields a different item. Added `avoid` to pick_daily/_candidates across wotd/quote/onthisday. - WOTD sense selection: the LLM now proposes word + intended part of speech, and _lookup prefers that sense (fixes "serene" returning the archaic noun). - On This Day tone prompt tightened to favor genuinely uplifting events and exclude merely procedural/political-administrative ones. - Caddy @hidden now also noindexes /word /quote /onthisday /admin (+ .html). - Regression tests: add/repick resolve (401 not 422), add/feature/block/delete, re-pick excludes current; WOTD pos-preference + proposal parsing units. Co-Authored-By: Claude Opus 4.8 --- data/wotd_audio/reverence.mp3 | Bin 0 -> 20429 bytes deploy/caddy/Caddyfile.snapshot | 2 +- frontend/src/routes/admin/+page.svelte | 2 +- goodnews/api.py | 9 ++- goodnews/onthisday.py | 41 +++++++---- goodnews/quote.py | 23 +++--- goodnews/wotd.py | 73 +++++++++++++------ tests/test_joys_admin.py | 95 +++++++++++++++++++++++++ tests/test_wotd.py | 23 +++++- 9 files changed, 217 insertions(+), 51 deletions(-) create mode 100644 data/wotd_audio/reverence.mp3 create mode 100644 tests/test_joys_admin.py diff --git a/data/wotd_audio/reverence.mp3 b/data/wotd_audio/reverence.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..46569bca5c98a09f7c044a93c3deeb8fa1c1352c GIT binary patch literal 20429 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 literal 0 HcmV?d00001 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}]