From 0ae789752ee0b7a31521950d9da748d923cebd65 Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 29 Jun 2026 05:39:06 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20QOTD/WOTD=20freshness=20=E2=80=94=20pick?= =?UTF-8?q?=20within=20the=20freshest=20cohort,=20not=20the=20rotated=20po?= =?UTF-8?q?ol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both selectors ordered candidates least-recently-shown, then daily.seeded_order() ROTATED the whole list and took [0] — an arbitrary date-hashed item, undoing the ordering. Result: repeats (quote id 2 on 6/28+6/29; word "harmony" on 6/25+6/28), no guarantee a pool item is shown before it recurs. Fix: daily.freshest(rows) returns the freshest cohort only — every NEVER-shown item while any remain, else the oldest-shown group. quote/wotd _candidates use it; seeded_order now picks deterministically WITHIN that cohort. So every pool item is featured once before any repeat, then cycles oldest-first. Dropped the unused _NO_REPEAT_POOL window. Tests: no-repeat-until-exhausted (quote + wotd) + a freshest() unit test. 428 backend tests green. (Separate follow-up: expand the QOTD pool from 16 → 90+ vetted public-domain quotes for a longer no-repeat window.) Co-Authored-By: Claude Opus 4.8 --- goodnews/daily.py | 15 +++++++++++++++ goodnews/quote.py | 11 ++++------- goodnews/wotd.py | 10 ++++------ tests/test_quote.py | 25 +++++++++++++++++++++++++ tests/test_wotd.py | 16 ++++++++++++++++ 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/goodnews/daily.py b/goodnews/daily.py index ddcbcb8..6cc175f 100644 --- a/goodnews/daily.py +++ b/goodnews/daily.py @@ -33,6 +33,21 @@ def seeded_order(ids: list, date_str: str) -> list: return ids[seed:] + ids[:seed] +def freshest(rows: list) -> list: + """The cohort to feature today, from pool rows carrying `id` + `shown_at`: every + NEVER-shown item (shown_at NULL) while any remain, else every item tied for the + OLDEST shown_at. Guarantees each pool item is featured ONCE before any repeat, then + cycles oldest-first. Pick deterministically *within* this cohort (seeded_order) — + NEVER across the whole pool, which re-feeds recent items (the QOTD/WOTD repeat bug).""" + never = [r["id"] for r in rows if r["shown_at"] is None] + if never: + return sorted(never) + if not rows: + return [] + oldest = min(r["shown_at"] for r in rows) + return sorted(r["id"] for r in rows if r["shown_at"] == oldest) + + def content_key(*parts) -> str: """A stable dedup key for a pool item (so re-harvesting never duplicates a row).""" raw = "|".join("" if p is None else str(p) for p in parts) diff --git a/goodnews/quote.py b/goodnews/quote.py index 9c1ba0d..8e009f0 100644 --- a/goodnews/quote.py +++ b/goodnews/quote.py @@ -14,8 +14,6 @@ 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"), @@ -65,11 +63,10 @@ def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int] if featured: 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] + # The freshest cohort only (never-shown, else the oldest-shown group) — picking + # across the whole pool is what re-fed recent quotes day to day. + rows = conn.execute("SELECT id, shown_at FROM quote_pool WHERE blocked=0").fetchall() + ids = daily.freshest(rows) if avoid is not None: ids = [i for i in ids if i != avoid] or ids return ids diff --git a/goodnews/wotd.py b/goodnews/wotd.py index 59d7f59..24f38e0 100644 --- a/goodnews/wotd.py +++ b/goodnews/wotd.py @@ -25,7 +25,6 @@ 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 @@ -222,11 +221,10 @@ def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int] if featured: 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] + # The freshest cohort only (never-shown, else the oldest-shown group) — picking + # across the whole pool is what re-fed recent words day to day. + rows = conn.execute("SELECT id, shown_at FROM wotd_pool WHERE blocked=0").fetchall() + ids = daily.freshest(rows) if avoid is not None: ids = [i for i in ids if i != avoid] or ids return ids diff --git a/tests/test_quote.py b/tests/test_quote.py index a716448..ebd0d17 100644 --- a/tests/test_quote.py +++ b/tests/test_quote.py @@ -56,3 +56,28 @@ def test_get_today_never_empty(conn): def test_run_daily_seeds_then_picks(conn): r = quote.run_daily(conn) assert r["pool"] == len(quote.SEED) and r["picked"] + + +def test_no_repeat_until_pool_exhausted(conn): + """Every quote is featured exactly once before ANY repeat; then the oldest-shown + repeats first. (Regression for the rotate-the-whole-pool selector bug.)""" + import datetime + quote.seed(conn) + n = len(quote.SEED) + d0 = datetime.date(2026, 1, 1) + picks = [quote.pick_daily(conn, feature_date=(d0 + datetime.timedelta(days=i)).isoformat())["pool_id"] + for i in range(n)] + assert len(set(picks)) == n # full coverage, no repeat within the pool + nxt = (d0 + datetime.timedelta(days=n)).isoformat() + assert quote.pick_daily(conn, feature_date=nxt)["pool_id"] == picks[0] # oldest repeats first + + +def test_freshest_cohort(): + from goodnews import daily + # never-shown win outright (the oldest shown item is ignored while any never-shown remain) + assert daily.freshest([{"id": 1, "shown_at": "2026-01-02"}, + {"id": 2, "shown_at": None}, {"id": 3, "shown_at": None}]) == [2, 3] + # all shown → only the oldest-shown cohort + assert daily.freshest([{"id": 1, "shown_at": "2026-01-03"}, + {"id": 2, "shown_at": "2026-01-01"}, {"id": 3, "shown_at": "2026-01-01"}]) == [2, 3] + assert daily.freshest([]) == [] diff --git a/tests/test_wotd.py b/tests/test_wotd.py index 784d1f4..d4aedc3 100644 --- a/tests/test_wotd.py +++ b/tests/test_wotd.py @@ -142,3 +142,19 @@ def test_polish_returns_none_with_empty_examples(): def chat_text(self, m): return '{"gloss": "A warm clear gloss.", "examples": []}' assert wotd._polish(C(), "serene", "adjective", "x") is None + + +def test_no_repeat_until_pool_exhausted(conn): + """Same freshness guarantee as QOTD: every word featured once before any repeat, + then the oldest-shown repeats first. (Regression for 'harmony' repeating after 3 days.)""" + import datetime + for w in ["alpha", "bravo", "charlie", "delta"]: + conn.execute("INSERT INTO wotd_pool (word, definition) VALUES (?, 'a definition')", (w,)) + conn.commit() + n = 4 + d0 = datetime.date(2026, 2, 1) + picks = [wotd.pick_daily(conn, feature_date=(d0 + datetime.timedelta(days=i)).isoformat())["pool_id"] + for i in range(n)] + assert len(set(picks)) == n + nxt = (d0 + datetime.timedelta(days=n)).isoformat() + assert wotd.pick_daily(conn, feature_date=nxt)["pool_id"] == picks[0]