Admin: Daily Word pool curation (lookup + add/remove)
First games admin tool. A "Games" tab in the operator console for the Daily Word answer pool. * Lookup: is a word real (in the guess dictionary), the right length (5/6), and already in the pool — instant as you type. * Add: appends to the pool, enforcing the invariant (alpha · 5/6 letters · in the guess dict) so the daily answer is always guessable. Remove: drops admin-added words (curated static ones stay). * Additions persist in a new word_pool table (survives redeploys, unlike the baked-in JSON); the daily picker reads static pool ∪ DB additions. Guess dicts shipped with the package (goodnews/data/words-5/6.json) for server-side validation. Admin-gated endpoints + tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -340,6 +340,10 @@ class WordGuessRequest(BaseModel):
|
||||
n: int = 1 # this guess's position (1-based); the answer is revealed only at n >= max
|
||||
|
||||
|
||||
class WordPoolBody(BaseModel):
|
||||
word: str
|
||||
|
||||
|
||||
class EmailStartRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
@@ -1448,6 +1452,37 @@ def create_app() -> FastAPI:
|
||||
raise HTTPException(status_code=400, detail=res["error"])
|
||||
return res
|
||||
|
||||
# --- Admin: Daily Word pool curation ---
|
||||
@app.get("/api/admin/word/lookup")
|
||||
def admin_word_lookup(word: str, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
res = games.lookup_word(word)
|
||||
res["in_pool"] = bool(res["variant"]) and res["word"] in games.answer_pool(conn, res["variant"])
|
||||
return res
|
||||
|
||||
@app.get("/api/admin/word/pool")
|
||||
def admin_word_pool(request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
return games.pool_summary(conn)
|
||||
|
||||
@app.post("/api/admin/word/pool")
|
||||
def admin_word_pool_add(body: WordPoolBody, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
res = games.add_pool_word(conn, body.word)
|
||||
if "error" in res:
|
||||
raise HTTPException(status_code=400, detail=res["error"])
|
||||
return {**res, "pool": games.pool_summary(conn)}
|
||||
|
||||
@app.delete("/api/admin/word/pool/{word}")
|
||||
def admin_word_pool_remove(word: str, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
games.remove_pool_word(conn, word)
|
||||
return games.pool_summary(conn)
|
||||
|
||||
@app.get("/api/since", response_model=FeedResponse)
|
||||
def feed_since(ts: str = Query(...), prefs: str | None = Query(None)) -> FeedResponse:
|
||||
# A calm welcome-back cue: accepted/non-dup/visible articles discovered
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -260,6 +260,13 @@ CREATE TABLE IF NOT EXISTS feedback_replies (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_replies_fid ON feedback_replies(feedback_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS word_pool (
|
||||
word TEXT NOT NULL,
|
||||
variant TEXT NOT NULL, -- '5' | '6'
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (variant, word)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_puzzles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
puzzle_date TEXT NOT NULL,
|
||||
|
||||
+65
-2
@@ -17,7 +17,11 @@ import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
_POOL = json.loads((Path(__file__).parent / "data" / "wordpool.json").read_text())
|
||||
_DATA = Path(__file__).parent / "data"
|
||||
_POOL = json.loads((_DATA / "wordpool.json").read_text()) # curated static answer pool
|
||||
# Guess dictionaries (same lists the client validates against) — used server-side to
|
||||
# guarantee any admin-added answer is actually guessable.
|
||||
_DICT = {v: set(json.loads((_DATA / f"words-{v}.json").read_text())) for v in ("5", "6")}
|
||||
|
||||
# Daily Word: 5 letters / 6 guesses · Long Word: 6 letters / 7 guesses.
|
||||
WORD_VARIANTS = {"5": {"length": 5, "guesses": 6}, "6": {"length": 6, "guesses": 7}}
|
||||
@@ -27,6 +31,65 @@ def _seed(*parts: str) -> int:
|
||||
return int(hashlib.sha256(":".join(parts).encode()).hexdigest(), 16)
|
||||
|
||||
|
||||
def _db_pool(conn: sqlite3.Connection, variant: str) -> list[str]:
|
||||
rows = conn.execute("SELECT word FROM word_pool WHERE variant=? ORDER BY word", (variant,)).fetchall()
|
||||
return [r["word"] for r in rows]
|
||||
|
||||
|
||||
def answer_pool(conn: sqlite3.Connection, variant: str) -> list[str]:
|
||||
"""The day's answer pool = curated static list ∪ admin-added (DB) words."""
|
||||
return sorted(set(_POOL.get(variant, [])) | set(_db_pool(conn, variant)))
|
||||
|
||||
|
||||
# --- Admin: Daily Word pool curation -------------------------------------------
|
||||
|
||||
def lookup_word(word: str) -> dict:
|
||||
"""Is this word a valid, guessable answer candidate?"""
|
||||
w = (word or "").strip().lower()
|
||||
variant = str(len(w))
|
||||
return {
|
||||
"word": w,
|
||||
"length": len(w),
|
||||
"alpha": bool(w) and w.isalpha(),
|
||||
"variant": variant if variant in WORD_VARIANTS else None,
|
||||
"in_dictionary": w.isalpha() and w in _DICT.get(variant, set()),
|
||||
}
|
||||
|
||||
|
||||
def add_pool_word(conn: sqlite3.Connection, word: str) -> dict:
|
||||
"""Add a word to the answer pool. Enforces the invariant (alpha, 5/6 letters,
|
||||
present in the guess dictionary) so the daily answer is always guessable."""
|
||||
w = (word or "").strip().lower()
|
||||
variant = str(len(w))
|
||||
if not w.isalpha() or variant not in WORD_VARIANTS:
|
||||
return {"error": "must be a 5- or 6-letter word"}
|
||||
if w not in _DICT[variant]:
|
||||
return {"error": "not in the guess dictionary — it wouldn't be guessable"}
|
||||
if w in set(_POOL.get(variant, [])) or w in set(_db_pool(conn, variant)):
|
||||
return {"error": "already in the pool"}
|
||||
conn.execute("INSERT OR IGNORE INTO word_pool (word, variant) VALUES (?, ?)", (w, variant))
|
||||
conn.commit()
|
||||
return {"word": w, "variant": variant, "added": True}
|
||||
|
||||
|
||||
def remove_pool_word(conn: sqlite3.Connection, word: str) -> dict:
|
||||
"""Remove an admin-added word. Curated static words can't be removed here."""
|
||||
w = (word or "").strip().lower()
|
||||
cur = conn.execute("DELETE FROM word_pool WHERE variant=? AND word=?", (str(len(w)), w))
|
||||
conn.commit()
|
||||
return {"word": w, "removed": cur.rowcount > 0}
|
||||
|
||||
|
||||
def pool_summary(conn: sqlite3.Connection) -> dict:
|
||||
"""Pool counts + the removable (admin-added) words, per variant."""
|
||||
out = {}
|
||||
for v in WORD_VARIANTS:
|
||||
added = _db_pool(conn, v)
|
||||
out[v] = {"curated": len(_POOL.get(v, [])), "added": added,
|
||||
"total": len(set(_POOL.get(v, [])) | set(added))}
|
||||
return out
|
||||
|
||||
|
||||
def _recent_answers(conn: sqlite3.Connection, variant: str, limit: int) -> set[str]:
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM daily_puzzles WHERE game='word' AND variant=? "
|
||||
@@ -43,7 +106,7 @@ def _recent_answers(conn: sqlite3.Connection, variant: str, limit: int) -> set[s
|
||||
|
||||
|
||||
def _pick_answer(conn: sqlite3.Connection, date: str, variant: str) -> str:
|
||||
pool = _POOL.get(variant, [])
|
||||
pool = answer_pool(conn, variant)
|
||||
recent = _recent_answers(conn, variant, max(1, len(pool) // 2))
|
||||
start = _seed(date, "word", variant) % len(pool)
|
||||
for i in range(len(pool)):
|
||||
|
||||
Reference in New Issue
Block a user