Small joys: Quote of the Day + Word of the Day engines
- 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 <noreply@anthropic.com>
This commit is contained in:
+36
-1
@@ -36,7 +36,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
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 .localtime import local_today
|
||||||
from .markup import reply_html_to_text, sanitize_reply_html
|
from .markup import reply_html_to_text, sanitize_reply_html
|
||||||
from .db import connect
|
from .db import connect
|
||||||
@@ -2304,6 +2304,41 @@ def create_app() -> FastAPI:
|
|||||||
"source": a["source"],
|
"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)
|
@app.get("/api/replacement", response_model=Article | None)
|
||||||
def replacement(
|
def replacement(
|
||||||
exclude: str = Query("", description="comma-separated article ids already shown"),
|
exclude: str = Query("", description="comma-separated article ids already shown"),
|
||||||
|
|||||||
+15
-3
@@ -13,7 +13,7 @@ from .games import generate_daily_puzzles
|
|||||||
from .localtime import local_today
|
from .localtime import local_today
|
||||||
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, cluster_duplicates, dedup as run_dedup
|
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, cluster_duplicates, dedup as run_dedup
|
||||||
from .geo import tag_articles as tag_geo
|
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 .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
|
||||||
from .summarize import generate_summary, get_summary
|
from .summarize import generate_summary, get_summary
|
||||||
from .feeds import (
|
from .feeds import (
|
||||||
@@ -561,13 +561,25 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"art: skipped ({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:
|
if not args.no_joys:
|
||||||
|
joy_client = LocalModelClient.from_env()
|
||||||
try:
|
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'}")
|
print(f"onthisday: md={o['md']} picked={'yes' if o['picked'] else 'no'}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"onthisday: skipped ({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:
|
if not args.no_brief:
|
||||||
today = local_today()
|
today = local_today()
|
||||||
|
|||||||
@@ -304,6 +304,55 @@ CREATE TABLE IF NOT EXISTS daily_onthisday (
|
|||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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
|
-- 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).
|
-- 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
|
-- The UNIQUE key dedups to one row per (kind, article, visitor, day) — that both
|
||||||
|
|||||||
@@ -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")}
|
||||||
@@ -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")}
|
||||||
@@ -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"]
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user