Pool admin: delete any word (tombstones + restore) + bulk import
Daily Word pool curation, full add/delete/import — no redeploys to fix tone: - Remove ANY pool word, curated or admin-added, via a word_pool_removed tombstone table. Runtime pool = (static ∪ added) − removed, so even a baked-in word can be pulled on negative feedback. Reversible: a "Removed" list with one-tap Restore lifts the tombstone. Lookup now surfaces a Remove button when in-pool, Restore when removed. - Import a vetted list (paste or .txt/.csv upload, read client-side): validates each word (alpha · 5–6 · in guess dictionary), ignores duplicates, and reports rejects with reasons. Re-adding/importing a removed word lifts its tombstone. - Word Search theme delete already existed (Edit/Remove per theme) — verified. Pool stays the clean 251/224; today's noisy LLM enrichment is discarded. Tests: +tests/test_pool_admin.py, extended test_word_pool_admin. 222 pytest + 11 vitest green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -344,6 +344,11 @@ class WordPoolBody(BaseModel):
|
||||
word: str
|
||||
|
||||
|
||||
class WordPoolImportBody(BaseModel):
|
||||
text: str = ""
|
||||
words: list[str] = []
|
||||
|
||||
|
||||
class ClientErrorBody(BaseModel):
|
||||
reason: str = ""
|
||||
path: str = ""
|
||||
@@ -1499,6 +1504,7 @@ def create_app() -> FastAPI:
|
||||
_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"])
|
||||
res["removed"] = bool(res["variant"]) and res["word"] in games._db_removed(conn, res["variant"])
|
||||
return res
|
||||
|
||||
@app.get("/api/admin/word/pool")
|
||||
@@ -1523,6 +1529,23 @@ def create_app() -> FastAPI:
|
||||
games.remove_pool_word(conn, word)
|
||||
return games.pool_summary(conn)
|
||||
|
||||
@app.post("/api/admin/word/pool/restore")
|
||||
def admin_word_pool_restore(body: WordPoolBody, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
games.restore_pool_word(conn, body.word)
|
||||
return games.pool_summary(conn)
|
||||
|
||||
@app.post("/api/admin/word/pool/import")
|
||||
def admin_word_pool_import(body: WordPoolImportBody, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
words = list(body.words)
|
||||
if body.text:
|
||||
words += re.findall(r"[A-Za-z]+", body.text)
|
||||
res = games.import_pool_words(conn, words)
|
||||
return {**res, "pool": games.pool_summary(conn)}
|
||||
|
||||
# --- Admin: Word Search theme authoring ---
|
||||
@app.get("/api/admin/wordsearch/themes")
|
||||
def admin_ws_themes(request: Request) -> list[dict]:
|
||||
|
||||
@@ -283,6 +283,13 @@ CREATE TABLE IF NOT EXISTS word_pool (
|
||||
PRIMARY KEY (variant, word)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS word_pool_removed (
|
||||
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,
|
||||
|
||||
+102
-16
@@ -36,9 +36,21 @@ def _db_pool(conn: sqlite3.Connection, variant: str) -> list[str]:
|
||||
return [r["word"] for r in rows]
|
||||
|
||||
|
||||
def _db_removed(conn: sqlite3.Connection, variant: str) -> list[str]:
|
||||
rows = conn.execute(
|
||||
"SELECT word FROM word_pool_removed 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)))
|
||||
"""The day's answer pool = (curated static ∪ admin-added) − admin-removed.
|
||||
|
||||
Removals are tombstones, so even a baked-in curated word can be pulled
|
||||
(e.g. on negative feedback) without editing the JSON or redeploying — and
|
||||
a removal is reversible by lifting the tombstone (restore)."""
|
||||
pool = (set(_POOL.get(variant, [])) | set(_db_pool(conn, variant))) - set(_db_removed(conn, variant))
|
||||
return sorted(pool)
|
||||
|
||||
|
||||
# --- Admin: Daily Word pool curation -------------------------------------------
|
||||
@@ -56,37 +68,111 @@ def lookup_word(word: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _validate_word(conn: sqlite3.Connection, w: str) -> tuple[str | None, str | None]:
|
||||
"""Return (variant, error). variant is set only when w is a valid candidate."""
|
||||
if not w.isalpha() or str(len(w)) not in WORD_VARIANTS:
|
||||
return None, "not a 5- or 6-letter word"
|
||||
variant = str(len(w))
|
||||
if w not in _DICT[variant]:
|
||||
return None, "not in the dictionary — it wouldn't be guessable"
|
||||
return variant, None
|
||||
|
||||
|
||||
def _put_word(conn: sqlite3.Connection, w: str, variant: str) -> None:
|
||||
"""Make w a live answer: lift any tombstone, and record it as an addition
|
||||
only if it isn't already part of the curated static list."""
|
||||
if w not in set(_POOL.get(variant, [])):
|
||||
conn.execute("INSERT OR IGNORE INTO word_pool (word, variant) VALUES (?, ?)", (w, variant))
|
||||
conn.execute("DELETE FROM word_pool_removed WHERE variant=? AND word=?", (variant, w))
|
||||
|
||||
|
||||
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)):
|
||||
variant, err = _validate_word(conn, w)
|
||||
if err:
|
||||
return {"error": err}
|
||||
live = (set(_POOL.get(variant, [])) | set(_db_pool(conn, variant))) - set(_db_removed(conn, variant))
|
||||
if w in live:
|
||||
return {"error": "already in the pool"}
|
||||
conn.execute("INSERT OR IGNORE INTO word_pool (word, variant) VALUES (?, ?)", (w, variant))
|
||||
_put_word(conn, 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."""
|
||||
"""Remove ANY pool word — curated or admin-added — by laying down a tombstone.
|
||||
Reversible via restore_pool_word. The daily picker subtracts tombstones."""
|
||||
w = (word or "").strip().lower()
|
||||
cur = conn.execute("DELETE FROM word_pool WHERE variant=? AND word=?", (str(len(w)), w))
|
||||
variant = str(len(w))
|
||||
if variant not in WORD_VARIANTS:
|
||||
return {"word": w, "removed": False}
|
||||
conn.execute("INSERT OR IGNORE INTO word_pool_removed (word, variant) VALUES (?, ?)", (w, variant))
|
||||
conn.commit()
|
||||
return {"word": w, "removed": cur.rowcount > 0}
|
||||
return {"word": w, "variant": variant, "removed": True}
|
||||
|
||||
|
||||
def restore_pool_word(conn: sqlite3.Connection, word: str) -> dict:
|
||||
"""Undo a removal: lift the tombstone so the word rejoins the pool."""
|
||||
w = (word or "").strip().lower()
|
||||
variant = str(len(w))
|
||||
cur = conn.execute("DELETE FROM word_pool_removed WHERE variant=? AND word=?", (variant, w))
|
||||
conn.commit()
|
||||
return {"word": w, "variant": variant, "restored": cur.rowcount > 0}
|
||||
|
||||
|
||||
def import_pool_words(conn: sqlite3.Connection, words: list[str]) -> dict:
|
||||
"""Bulk-add a vetted list. Validates each word (alpha · 5–6 · in dictionary),
|
||||
ignores duplicates (already-live words), and reports rejects with reasons.
|
||||
A word currently tombstoned is treated as importable (re-adding lifts it)."""
|
||||
static = {v: set(_POOL.get(v, [])) for v in WORD_VARIANTS}
|
||||
added = {v: set(_db_pool(conn, v)) for v in WORD_VARIANTS}
|
||||
removed = {v: set(_db_removed(conn, v)) for v in WORD_VARIANTS}
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
rejected: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for raw in words:
|
||||
w = (raw or "").strip().lower()
|
||||
if not w or w in seen:
|
||||
continue
|
||||
seen.add(w)
|
||||
variant, err = _validate_word(conn, w)
|
||||
if err:
|
||||
rejected.append({"word": w, "reason": err})
|
||||
continue
|
||||
live = (static[variant] | added[variant]) - removed[variant]
|
||||
if w in live:
|
||||
duplicates.append(w)
|
||||
continue
|
||||
_put_word(conn, w, variant)
|
||||
added[variant].add(w)
|
||||
removed[variant].discard(w)
|
||||
accepted.append(w)
|
||||
conn.commit()
|
||||
return {
|
||||
"added": accepted,
|
||||
"duplicates": duplicates,
|
||||
"rejected": rejected,
|
||||
"counts": {"added": len(accepted), "duplicates": len(duplicates), "rejected": len(rejected)},
|
||||
}
|
||||
|
||||
|
||||
def pool_summary(conn: sqlite3.Connection) -> dict:
|
||||
"""Pool counts + the removable (admin-added) words, per variant."""
|
||||
"""Pool counts + the admin-added words and the removed (tombstoned) 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))}
|
||||
static = set(_POOL.get(v, []))
|
||||
removed = set(_db_removed(conn, v))
|
||||
added = [w for w in _db_pool(conn, v) if w not in removed]
|
||||
total = len((static | set(added)) - removed)
|
||||
out[v] = {
|
||||
"curated": len(static),
|
||||
"added": sorted(added),
|
||||
"removed": sorted(removed),
|
||||
"total": total,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user