From 67d4bc32cbda1ec4156ae0ec42452b88e801f252 Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 22 Jun 2026 17:28:55 -0400 Subject: [PATCH] Small joys: Quote of the Day + Word of the Day engines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - quote.py: curated public-domain quote pool (16 seeded, admin-grows), deterministic daily pick, lazy AI "what it means" explainer of the real quote (cached). No LLM-invented quotes. - wotd.py: LLM proposes positive words → validated/enriched against dictionaryapi.dev (real definition, IPA, examples, audio) → audio clip cached to our origin (TTS fallback) → deterministic daily pick. Tops the pool up toward 30/day. - db.py: quote_pool/daily_quote + wotd_pool/daily_wotd tables. - api.py: /api/quote/today, /api/word/today, /api/word/audio/{word} (GET+HEAD). - cli.py: cycle steps for both (under --no-joys), shared LLM client. - tests: test_quote.py (6) + test_wotd.py (5). 393 backend tests green. Co-Authored-By: Claude Opus 4.8 --- goodnews/api.py | 37 +++++++- goodnews/cli.py | 18 +++- goodnews/db.py | 49 +++++++++++ goodnews/quote.py | 118 +++++++++++++++++++++++++ goodnews/wotd.py | 208 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_quote.py | 58 ++++++++++++ tests/test_wotd.py | 66 ++++++++++++++ 7 files changed, 550 insertions(+), 4 deletions(-) create mode 100644 goodnews/quote.py create mode 100644 goodnews/wotd.py create mode 100644 tests/test_quote.py create mode 100644 tests/test_wotd.py diff --git a/goodnews/api.py b/goodnews/api.py index 6ee1af8..8d5d232 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -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, onthisday, publishing, queries, share, sources, summarize +from . import art, auth, bloom, email_send, feeds, games, oauth_google, onthisday, publishing, queries, quote, share, sources, summarize, wotd from .localtime import local_today from .markup import reply_html_to_text, sanitize_reply_html from .db import connect @@ -2304,6 +2304,41 @@ def create_app() -> FastAPI: "source": a["source"], } + @app.get("/api/quote/today") + def quote_today(response: Response) -> dict: + with get_conn() as conn: + q = quote.get_today(conn) + if not q: + response.headers["Cache-Control"] = _PRIVATE + raise HTTPException(status_code=404, detail="No quote yet.") + response.headers["Cache-Control"] = _EDGE_FEED + return {"date": q["feature_date"], "text": q["text"], "author": q["author"], + "work": q["work"], "year": q["year"], "meaning": q["meaning"], "source": q["source"]} + + @app.get("/api/word/today") + def word_today(response: Response) -> dict: + with get_conn() as conn: + w = wotd.get_today(conn) + if not w: + response.headers["Cache-Control"] = _PRIVATE + raise HTTPException(status_code=404, detail="No word yet.") + response.headers["Cache-Control"] = _EDGE_FEED + try: + examples = json.loads(w["examples"]) if w["examples"] else [] + except (ValueError, TypeError): + examples = [] + return {"date": w["feature_date"], "word": w["word"], "part_of_speech": w["part_of_speech"], + "phonetic": w["phonetic"], "definition": w["definition"], "examples": examples, + "audio_url": f"/api/word/audio/{w['word']}" if w["audio_file"] else None} + + @app.api_route("/api/word/audio/{word}", methods=["GET", "HEAD"]) + def word_audio(word: str) -> FileResponse: + matches = sorted(wotd.cache_dir().glob(f"{word.lower()}.*")) + matches = [m for m in matches if not m.name.startswith(".")] + if not matches: + raise HTTPException(status_code=404, detail="No audio.") + return FileResponse(str(matches[0]), headers={"Cache-Control": "public, max-age=31536000, immutable"}) + @app.get("/api/replacement", response_model=Article | None) def replacement( exclude: str = Query("", description="comma-separated article ids already shown"), diff --git a/goodnews/cli.py b/goodnews/cli.py index 47e512e..6df997e 100644 --- a/goodnews/cli.py +++ b/goodnews/cli.py @@ -13,7 +13,7 @@ from .games import generate_daily_puzzles from .localtime import local_today from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, cluster_duplicates, dedup as run_dedup from .geo import tag_articles as tag_geo -from . import art, onthisday +from . import art, onthisday, quote, wotd from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images from .summarize import generate_summary, get_summary from .feeds import ( @@ -561,13 +561,25 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non except Exception as exc: print(f"art: skipped ({exc})") - # On This Day: harvest + tone-filter today's date in history, then pick one good fact. + # Small joys: On This Day (history), Quote of the Day, Word of the Day. Each is + # bounded + non-fatal; one shared LLM client for tone/explainer/word proposals. if not args.no_joys: + joy_client = LocalModelClient.from_env() try: - o = onthisday.run_daily(conn, client=LocalModelClient.from_env()) + o = onthisday.run_daily(conn, client=joy_client) print(f"onthisday: md={o['md']} picked={'yes' if o['picked'] else 'no'}") except Exception as exc: print(f"onthisday: skipped ({exc})") + try: + q = quote.run_daily(conn, client=joy_client) + print(f"quote: pool={q['pool']} picked={q['picked']}") + except Exception as exc: + print(f"quote: skipped ({exc})") + try: + w = wotd.run_daily(conn, client=joy_client) + print(f"word: pool={w['pool']} picked={w['picked']}") + except Exception as exc: + print(f"word: skipped ({exc})") if not args.no_brief: today = local_today() diff --git a/goodnews/db.py b/goodnews/db.py index 100f0d7..33de3f9 100644 --- a/goodnews/db.py +++ b/goodnews/db.py @@ -304,6 +304,55 @@ CREATE TABLE IF NOT EXISTS daily_onthisday ( created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); +-- Quote of the Day: curated public-domain quotes; one picked per day. `meaning` is an +-- AI explainer of the (real) quote, filled lazily the first time it's shown. +CREATE TABLE IF NOT EXISTS quote_pool ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL DEFAULT 'curated', + ckey TEXT NOT NULL UNIQUE, + text TEXT NOT NULL, + author TEXT, + work TEXT, + year TEXT, + meaning TEXT, + shown_at TEXT, + blocked INTEGER NOT NULL DEFAULT 0, + featured INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS daily_quote ( + feature_date TEXT PRIMARY KEY, + pool_id INTEGER NOT NULL, + source TEXT NOT NULL DEFAULT 'curated', + text TEXT, author TEXT, work TEXT, year TEXT, meaning TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Word of the Day: LLM-proposed positive words, validated/enriched against a real +-- dictionary (definition, IPA, examples, cached audio clip); one picked per day. +CREATE TABLE IF NOT EXISTS wotd_pool ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL DEFAULT 'llm', + word TEXT NOT NULL UNIQUE, + part_of_speech TEXT, + phonetic TEXT, -- IPA + audio_file TEXT, -- our cached pronunciation clip (or null → browser TTS) + audio_url TEXT, -- source clip URL + definition TEXT NOT NULL, + examples TEXT, -- JSON array of example sentences + shown_at TEXT, + blocked INTEGER NOT NULL DEFAULT 0, + featured INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS daily_wotd ( + feature_date TEXT PRIMARY KEY, + pool_id INTEGER NOT NULL, + word TEXT, part_of_speech TEXT, phonetic TEXT, audio_file TEXT, + definition TEXT, examples TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + -- Privacy-respecting, first-party analytics. NO IP / user-agent / referrer / raw -- URL. visitor_hash is a hash of a random localStorage token (never email/IP). -- The UNIQUE key dedups to one row per (kind, article, visitor, day) — that both diff --git a/goodnews/quote.py b/goodnews/quote.py new file mode 100644 index 0000000..b60e03a --- /dev/null +++ b/goodnews/quote.py @@ -0,0 +1,118 @@ +"""Quote of the Day — a hopeful, public-domain quote a day. + +Curated, never LLM-invented (misattribution is the one thing that would burn trust). A +vetted starter set seeds the pool; admin grows it. The "what it means" explainer IS +LLM-generated, but it only *interprets a real, known quote* — low risk — and is filled +lazily the first time a quote is shown, then cached. + +Same lifecycle as the other small joys: pool → deterministic daily pick → cached row. +""" +from __future__ import annotations + +import sqlite3 + +from . import daily +from .localtime import local_today + +_NO_REPEAT_POOL = 60 + +# Public-domain (ancient / author died well over a century ago), uplifting. Admin curates. +SEED = [ + ("Very little is needed to make a happy life; it is all within yourself, in your way of thinking.", "Marcus Aurelius", "Meditations"), + ("The happiness of your life depends upon the quality of your thoughts.", "Marcus Aurelius", "Meditations"), + ("The journey of a thousand miles begins with a single step.", "Lao Tzu", "Tao Te Ching"), + ("Nature does not hurry, yet everything is accomplished.", "Lao Tzu", "Tao Te Ching"), + ("It does not matter how slowly you go as long as you do not stop.", "Confucius", None), + ("Knowing yourself is the beginning of all wisdom.", "Aristotle", None), + ("We suffer more often in imagination than in reality.", "Seneca", None), + ("It's not what happens to you, but how you react to it that matters.", "Epictetus", None), + ("The wound is the place where the Light enters you.", "Rumi", None), + ("Write it on your heart that every day is the best day in the year.", "Ralph Waldo Emerson", None), + ("Go confidently in the direction of your dreams. Live the life you have imagined.", "Henry David Thoreau", None), + ("Hope is the thing with feathers that perches in the soul.", "Emily Dickinson", None), + ("The best portion of a good man's life: his little, nameless, unremembered acts of kindness and of love.", "William Wordsworth", None), + ("Great things are done by a series of small things brought together.", "Vincent van Gogh", None), + ("I am not afraid of storms, for I am learning how to sail my ship.", "Louisa May Alcott", "Little Women"), + ("I exist as I am, that is enough.", "Walt Whitman", "Leaves of Grass"), +] + + +def seed(conn: sqlite3.Connection) -> int: + """Insert the curated starter quotes (idempotent via content key). Returns # added.""" + before = conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0] + conn.executemany( + "INSERT OR IGNORE INTO quote_pool (source, ckey, text, author, work) VALUES ('curated', ?, ?, ?, ?)", + [(daily.content_key(text, author), text, author, work) for text, author, work in SEED], + ) + conn.commit() + return conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0] - before + + +def _explain(client, text: str, author: str | None) -> str | None: + user = ( + "In one or two plain, warm sentences, explain what this quote means and why it's worth " + "remembering, for a general audience who may not be familiar with it. No preamble, no " + f'quoting it back.\n\nQuote: "{text}"' + (f"\n— {author}" if author else "") + ) + out = " ".join(client.chat_text([{"role": "user", "content": user}]).split()).strip() + return out or None + + +def _candidates(conn: sqlite3.Connection) -> 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] + + +def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, client=None, force: bool = False) -> 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) + if not ids: + return None + pick_id = daily.seeded_order(ids, feature_date)[0] + row = conn.execute("SELECT * FROM quote_pool WHERE id=?", (pick_id,)).fetchone() + meaning = row["meaning"] + if not meaning and client: # lazy, cached; LLM done before the write + try: + meaning = _explain(client, row["text"], row["author"]) + if meaning: + conn.execute("UPDATE quote_pool SET meaning=? WHERE id=?", (meaning, pick_id)) + except Exception: # noqa: BLE001 — explainer is optional + meaning = None + conn.execute( + "INSERT INTO daily_quote (feature_date, pool_id, source, text, author, work, year, meaning) " + "VALUES (?,?,?,?,?,?,?,?) " + "ON CONFLICT(feature_date) DO UPDATE SET pool_id=excluded.pool_id, text=excluded.text, " + "author=excluded.author, work=excluded.work, year=excluded.year, meaning=excluded.meaning", + (feature_date, row["id"], row["source"], row["text"], row["author"], row["work"], row["year"], meaning), + ) + conn.execute("UPDATE quote_pool SET shown_at=? WHERE id=?", (feature_date, pick_id)) + conn.commit() + return dict(conn.execute("SELECT * FROM daily_quote WHERE feature_date=?", (feature_date,)).fetchone()) + + +def get_today(conn: sqlite3.Connection, feature_date: str | None = None) -> dict | None: + if feature_date: + row = conn.execute("SELECT * FROM daily_quote WHERE feature_date=?", (feature_date,)).fetchone() + if row: + return dict(row) + row = conn.execute("SELECT * FROM daily_quote ORDER BY feature_date DESC LIMIT 1").fetchone() + return dict(row) if row else None + + +def run_daily(conn: sqlite3.Connection, client=None) -> dict: + if conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0] == 0: + seed(conn) + picked = pick_daily(conn, client=client) + return {"pool": conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0], + "picked": (picked or {}).get("author")} diff --git a/goodnews/wotd.py b/goodnews/wotd.py new file mode 100644 index 0000000..a0b235c --- /dev/null +++ b/goodnews/wotd.py @@ -0,0 +1,208 @@ +"""Word of the Day — an uplifting word a day, grounded in a real dictionary. + +"LLM proposes, dictionary disposes": the LLM suggests positive/calming words; each is +validated + enriched against the free Dictionary API (dictionaryapi.dev) for the REAL +definition, IPA pronunciation, example sentences, and a human pronunciation clip. That +rules out hallucinated definitions — the authoritative data is the dictionary's. The +audio clip (public-domain, usually Wiktionary) is cached to our origin; the page falls +back to the browser's speech synthesis when a word has no clip. + +All network/LLM work happens before the brief DB write. Same pick lifecycle as the +other small joys. +""" +from __future__ import annotations + +import json +import os +import re +import sqlite3 +import urllib.parse +import urllib.request +from pathlib import Path + +from . import daily +from .localtime import local_today + +DICT_BASE = "https://api.dictionaryapi.dev/api/v2/entries/en" +_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"} +_NO_REPEAT_POOL = 60 +_TARGET_POOL = 30 # keep harvesting (a batch/day) until the pool reaches this +_HARVEST_BATCH = 12 +_MIN_AUDIO_BYTES = 500 + + +def cache_dir() -> Path: + override = os.environ.get("GOODNEWS_WOTD_AUDIO") + d = Path(override) if override else Path(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3")).parent / "wotd_audio" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _http_bytes(url: str, timeout: int = 30) -> tuple[bytes, str]: + req = urllib.request.Request(url, headers=_UA) + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.read(), (r.headers.get("Content-Type") or "") + + +def _propose_words(client, n: int) -> list[str]: + 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": ["...", "..."]}' + ) + 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()] + + +def _lookup(word: str) -> dict | None: + """Validate + enrich a word via the dictionary. Returns None if it's not a real word.""" + try: + data = daily.http_json(f"{DICT_BASE}/{urllib.parse.quote(word)}") + except Exception: # noqa: BLE001 — unknown word / network → just skip it + return None + if not isinstance(data, list) or not data: + return None + entry = data[0] + 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: + return None + phonetic = entry.get("phonetic") + audio_url = None + for p in (entry.get("phonetics") or []): + if not phonetic and p.get("text"): + phonetic = p["text"] + if not audio_url and p.get("audio"): + audio_url = p["audio"] + examples = [] + for m in meanings: + 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"), + "phonetic": phonetic, + "audio_url": audio_url, + "definition": definition, + "examples": examples[:3], + } + + +def _cache_audio(audio_url: str | None, word: str) -> str | None: + """Download the pronunciation clip to our origin (atomic). Returns filename or None.""" + if not audio_url: + return None + if audio_url.startswith("//"): + audio_url = "https:" + audio_url + try: + data, ctype = _http_bytes(audio_url) + except Exception: # noqa: BLE001 + return None + if len(data) < _MIN_AUDIO_BYTES: + return None + ext = ".ogg" if ("ogg" in ctype or audio_url.endswith(".ogg")) else ".mp3" + fname = f"{word}{ext}" + cdir = cache_dir() + tmp = cdir / f".{word}.tmp" + try: + tmp.write_bytes(data) + os.replace(tmp, cdir / fname) + except OSError: + try: + tmp.unlink() + except OSError: + pass + return None + return fname + + +def _pool_count(conn: sqlite3.Connection) -> int: + return conn.execute("SELECT COUNT(*) FROM wotd_pool").fetchone()[0] + + +def harvest(conn: sqlite3.Connection, client, count: int = _HARVEST_BATCH) -> dict: + """Propose words → validate/enrich via dictionary → cache audio → add new ones. + All network up front; one brief write at the end.""" + try: + words = _propose_words(client, count) + except Exception: # noqa: BLE001 + return {"proposed": 0, "added": 0, "pool": _pool_count(conn)} + rows = [] + for w in words: + if not w.isalpha() or conn.execute("SELECT 1 FROM wotd_pool WHERE word=?", (w,)).fetchone(): + continue + info = _lookup(w) + if not info: + continue + audio_file = _cache_audio(info["audio_url"], info["word"]) + rows.append((info["word"], info["part_of_speech"], info["phonetic"], audio_file, + info["audio_url"], info["definition"], json.dumps(info["examples"]))) + before = _pool_count(conn) + conn.executemany( + "INSERT OR IGNORE INTO wotd_pool (source, word, part_of_speech, phonetic, audio_file, audio_url, definition, examples) " + "VALUES ('llm', ?, ?, ?, ?, ?, ?, ?)", rows, + ) + conn.commit() + after = _pool_count(conn) + return {"proposed": len(words), "added": after - before, "pool": after} + + +def _candidates(conn: sqlite3.Connection) -> 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] + + +def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, force: bool = False) -> 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) + if not ids: + return None + pick_id = daily.seeded_order(ids, feature_date)[0] + row = conn.execute("SELECT * FROM wotd_pool WHERE id=?", (pick_id,)).fetchone() + conn.execute( + "INSERT INTO daily_wotd (feature_date, pool_id, word, part_of_speech, phonetic, audio_file, definition, examples) " + "VALUES (?,?,?,?,?,?,?,?) " + "ON CONFLICT(feature_date) DO UPDATE SET pool_id=excluded.pool_id, word=excluded.word, " + "part_of_speech=excluded.part_of_speech, phonetic=excluded.phonetic, audio_file=excluded.audio_file, " + "definition=excluded.definition, examples=excluded.examples", + (feature_date, row["id"], row["word"], row["part_of_speech"], row["phonetic"], + row["audio_file"], row["definition"], row["examples"]), + ) + conn.execute("UPDATE wotd_pool SET shown_at=? WHERE id=?", (feature_date, pick_id)) + conn.commit() + return dict(conn.execute("SELECT * FROM daily_wotd WHERE feature_date=?", (feature_date,)).fetchone()) + + +def get_today(conn: sqlite3.Connection, feature_date: str | None = None) -> dict | None: + if feature_date: + row = conn.execute("SELECT * FROM daily_wotd WHERE feature_date=?", (feature_date,)).fetchone() + if row: + return dict(row) + row = conn.execute("SELECT * FROM daily_wotd ORDER BY feature_date DESC LIMIT 1").fetchone() + return dict(row) if row else None + + +def run_daily(conn: sqlite3.Connection, client=None) -> dict: + """Top the pool up toward _TARGET_POOL (a batch a day), then pick today's word.""" + harvested = None + if client and _pool_count(conn) < _TARGET_POOL: + harvested = harvest(conn, client) + picked = pick_daily(conn) + return {"pool": _pool_count(conn), "harvested": harvested, "picked": (picked or {}).get("word")} diff --git a/tests/test_quote.py b/tests/test_quote.py new file mode 100644 index 0000000..a716448 --- /dev/null +++ b/tests/test_quote.py @@ -0,0 +1,58 @@ +"""Quote of the Day: curated seed (idempotent), deterministic pick (idempotent, +blocked/featured), lazy AI meaning, never-empty get_today.""" +import pytest + +from goodnews import quote +from goodnews.db import connect, init_db + + +class FakeClient: + def chat_text(self, messages): + return "It means contentment is built from within, not from circumstance." + + +@pytest.fixture +def conn(): + c = connect(":memory:"); init_db(c) + yield c + c.close() + + +def test_seed_idempotent(conn): + assert quote.seed(conn) == len(quote.SEED) + assert quote.seed(conn) == 0 + assert conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0] == len(quote.SEED) + + +def test_pick_caches_marks_shown_idempotent(conn): + quote.seed(conn) + a = quote.pick_daily(conn, feature_date="2026-06-22") + assert a and a["text"] and a["author"] + shown = conn.execute("SELECT shown_at FROM quote_pool WHERE id=?", (a["pool_id"],)).fetchone()[0] + assert shown == "2026-06-22" + assert quote.pick_daily(conn, feature_date="2026-06-22")["pool_id"] == a["pool_id"] + + +def test_meaning_generated_lazily_and_cached(conn): + quote.seed(conn) + a = quote.pick_daily(conn, feature_date="2026-06-22", client=FakeClient()) + assert a["meaning"].startswith("It means") + pool_meaning = conn.execute("SELECT meaning FROM quote_pool WHERE id=?", (a["pool_id"],)).fetchone()[0] + assert pool_meaning == a["meaning"] # cached on the pool row, not regenerated + + +def test_featured_pinned(conn): + quote.seed(conn) + conn.execute("UPDATE quote_pool SET featured=1 WHERE author='Rumi'"); conn.commit() + assert quote.pick_daily(conn, feature_date="2026-06-22", force=True)["author"] == "Rumi" + + +def test_get_today_never_empty(conn): + quote.seed(conn) + a = quote.pick_daily(conn, feature_date="2026-06-22") + assert quote.get_today(conn, "2099-01-01")["pool_id"] == a["pool_id"] + + +def test_run_daily_seeds_then_picks(conn): + r = quote.run_daily(conn) + assert r["pool"] == len(quote.SEED) and r["picked"] diff --git a/tests/test_wotd.py b/tests/test_wotd.py new file mode 100644 index 0000000..af8e799 --- /dev/null +++ b/tests/test_wotd.py @@ -0,0 +1,66 @@ +"""Word of the Day: LLM-proposed words validated against the dictionary (mocked), +harvest dedupes + drops unknowns, audio cached when present, deterministic pick.""" +import json + +import pytest + +from goodnews import wotd +from goodnews.db import connect, init_db + +FAKE_DICT = { + "serene": {"word": "serene", "part_of_speech": "adjective", "phonetic": "/səˈriːn/", + "audio_url": "https://a/serene.mp3", "definition": "calm, peaceful, and untroubled", + "examples": ["a serene mountain lake"]}, + "dawn": {"word": "dawn", "part_of_speech": "noun", "phonetic": "/dɔːn/", + "audio_url": None, "definition": "the first appearance of light in the sky", "examples": []}, +} + + +class FakeClient: + def chat_text(self, messages): + return '{"words": ["serene", "dawn", "xyzzyq"]}' # xyzzyq isn't a real word + + +@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, "_cache_audio", lambda url, word: f"{word}.mp3" if url else None) + c = connect(":memory:"); init_db(c) + yield c + c.close() + + +def test_harvest_validates_dedupes_and_caches_audio(conn): + r = wotd.harvest(conn, FakeClient()) + assert r["added"] == 2 # serene + dawn; the nonsense word dropped + assert wotd.harvest(conn, FakeClient())["added"] == 0 # idempotent (word is UNIQUE) + assert conn.execute("SELECT audio_file FROM wotd_pool WHERE word='serene'").fetchone()[0] == "serene.mp3" + assert conn.execute("SELECT audio_file FROM wotd_pool WHERE word='dawn'").fetchone()[0] is None + + +def test_pick_caches_marks_shown_idempotent(conn): + wotd.harvest(conn, FakeClient()) + a = wotd.pick_daily(conn, feature_date="2026-06-22") + assert a and a["word"] in ("serene", "dawn") and a["definition"] + assert json.loads(a["examples"]) == FAKE_DICT[a["word"]]["examples"] + shown = conn.execute("SELECT shown_at FROM wotd_pool WHERE id=?", (a["pool_id"],)).fetchone()[0] + assert shown == "2026-06-22" + assert wotd.pick_daily(conn, feature_date="2026-06-22")["pool_id"] == a["pool_id"] + + +def test_featured_pinned(conn): + wotd.harvest(conn, FakeClient()) + conn.execute("UPDATE wotd_pool SET featured=1 WHERE word='dawn'"); conn.commit() + assert wotd.pick_daily(conn, feature_date="2026-06-22", force=True)["word"] == "dawn" + + +def test_get_today_never_empty(conn): + wotd.harvest(conn, FakeClient()) + a = wotd.pick_daily(conn, feature_date="2026-06-22") + assert wotd.get_today(conn, "2099-01-01")["pool_id"] == a["pool_id"] + + +def test_run_daily_bootstraps(conn): + r = wotd.run_daily(conn, client=FakeClient()) + assert r["pool"] == 2 and r["picked"] in ("serene", "dawn")