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:
jay
2026-06-11 17:17:16 -04:00
parent fb781f48b8
commit 2461584052
6 changed files with 352 additions and 19 deletions
+109 -2
View File
@@ -66,8 +66,52 @@
wpPool = res.pool; wpWord = ''; wpResult = null; wpMsg = `Added “${res.word}” to the ${res.variant}-letter pool.`;
} catch (e) { wpMsg = e.message || 'Could not add that word.'; }
}
// Refresh the live lookup tag after a pool mutation, so the button flips Remove↔Restore↔Add.
async function refreshLookup() {
const w = wpWord.trim();
if (!w) { wpResult = null; return; }
try { wpResult = await getJSON('/api/admin/word/lookup?word=' + encodeURIComponent(w)); }
catch { wpResult = null; }
}
async function removeWord(w) {
try { wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); } catch { /* ignore */ }
try {
wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w));
wpMsg = `Removed “${w}”. It wont appear as an answer — restore it any time below.`;
await refreshLookup();
} catch { /* ignore */ }
}
async function restoreWord(w) {
try {
wpPool = await postJSON('/api/admin/word/pool/restore', { word: w });
wpMsg = `Restored “${w}” to the pool.`;
await refreshLookup();
} catch { /* ignore */ }
}
// --- Games: bulk import (paste or .txt/.csv upload) ---
let wpImportText = $state('');
let wpImportResult = $state(null); // { counts, added[], duplicates[], rejected[] }
let wpImporting = $state(false);
function onImportFile(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result || '');
wpImportText = wpImportText.trim() ? wpImportText.trim() + '\n' + text : text;
};
reader.readAsText(file);
e.target.value = ''; // allow re-picking the same file
}
async function importWords() {
if (!wpImportText.trim() || wpImporting) return;
wpImporting = true; wpImportResult = null;
try {
const res = await postJSON('/api/admin/word/pool/import', { text: wpImportText });
wpPool = res.pool; wpImportResult = res; wpImportText = '';
await refreshLookup();
} catch (e) { wpImportResult = { error: e.message || 'Import failed.' }; }
finally { wpImporting = false; }
}
// --- Games: Word Search themes ---
@@ -783,7 +827,11 @@
{#if !wpResult.variant}
<span class="wp-tag bad">Must be 5 or 6 letters</span>
{:else if wpResult.in_pool}
<span class="wp-tag ok">Already in the pool</span>
<span class="wp-tag ok">In the pool</span>
<button class="act del" onclick={() => removeWord(wpResult.word)}>Remove</button>
{:else if wpResult.removed}
<span class="wp-tag bad">Removed</span>
<button class="wp-add" onclick={() => restoreWord(wpResult.word)}>Restore</button>
{:else if wpResult.in_dictionary}
<span class="wp-tag ok">Valid {wpResult.variant}-letter word</span>
<button class="wp-add" onclick={addWord}>Add to pool</button>
@@ -807,11 +855,52 @@
{/each}
</ul>
{:else}<p class="muted small">No words added yet — the curated pool is in use.</p>{/if}
{#if wpPool[v].removed && wpPool[v].removed.length}
<p class="muted small" style="margin-top:10px">Removed ({wpPool[v].removed.length}) — tap to restore:</p>
<ul class="wp-added removed">
{#each wpPool[v].removed as w (w)}
<li>{w}<button class="x" onclick={() => restoreWord(w)} aria-label={'Restore ' + w}></button></li>
{/each}
</ul>
{/if}
</div>
{/each}
</div>
{/if}
<h3 style="margin-top:24px">Import a vetted list</h3>
<p class="muted small">Paste words (any separators) or upload a .txt/.csv. Each is checked —
real word · 56 letters · in the guess dictionary — and duplicates are ignored.</p>
<div class="wp-import">
<textarea bind:value={wpImportText} rows="4"
placeholder="clear, bloom, kindle, ripple… (or upload a file below)"></textarea>
<div class="wp-import-row">
<label class="act file">Choose file…
<input type="file" accept=".txt,.csv,text/plain,text/csv" onchange={onImportFile} hidden />
</label>
<button class="wp-add" onclick={importWords} disabled={!wpImportText.trim() || wpImporting}>
{wpImporting ? 'Importing…' : 'Import'}
</button>
</div>
{#if wpImportResult}
{#if wpImportResult.error}
<p class="wp-msg">{wpImportResult.error}</p>
{:else}
<p class="wp-msg">Added {wpImportResult.counts.added} · ignored {wpImportResult.counts.duplicates} duplicate(s) · rejected {wpImportResult.counts.rejected}.</p>
{#if wpImportResult.rejected.length}
<details class="wp-rejects">
<summary>{wpImportResult.rejected.length} rejected — see why</summary>
<ul class="muted small">
{#each wpImportResult.rejected as r (r.word)}
<li>{r.word}{r.reason}</li>
{/each}
</ul>
</details>
{/if}
{/if}
{/if}
</div>
<h2 style="margin-top:38px">Word Search themes</h2>
<p class="muted">Add a theme and its words — the system splits them across the three sizes and places
them. You need {WS_NEEDED}+ valid words (48 letters) to fill all three puzzles. Stuck? Let the AI
@@ -1139,6 +1228,24 @@
.wp-added .x { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1.15rem;
line-height: 1; padding: 0 5px; border-radius: 50%; }
.wp-added .x:hover { color: #9a3b3b; }
.wp-added.removed li { opacity: 0.7; border-style: dashed; }
.wp-added.removed .x { font-size: 0.95rem; }
.wp-added.removed .x:hover { color: var(--accent-deep); }
.wp-lookup .act { font: inherit; font-size: 0.82rem; background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 7px 16px; cursor: pointer; color: #9a3b3b; }
.wp-lookup .act:hover { border-color: #9a3b3b; }
/* Bulk import */
.wp-import { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 10px 0 6px; }
.wp-import textarea { font: inherit; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
background: var(--surface); color: var(--ink); resize: vertical; line-height: 1.6;
text-transform: uppercase; letter-spacing: 0.03em; }
.wp-import-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.act.file { font: inherit; font-size: 0.84rem; background: var(--accent-soft); color: var(--accent-deep);
border: none; border-radius: 999px; padding: 8px 16px; cursor: pointer; }
.act.file:hover { filter: brightness(0.97); }
.wp-rejects { margin: 4px 0 0; }
.wp-rejects summary { cursor: pointer; color: var(--accent-deep); font-size: 0.84rem; }
.wp-rejects ul { margin: 8px 0 0; padding-left: 18px; }
/* Word Search theme authoring */
.ws-form { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 14px 0 6px; }
+23
View File
@@ -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]:
+7
View File
@@ -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
View File
@@ -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 · 56 · 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
+18 -1
View File
@@ -371,12 +371,14 @@ def test_wordsearch_thin_llm_falls_back(tmp_path, monkeypatch):
def test_word_pool_admin(tmp_path, monkeypatch):
from goodnews import games
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
assert TestClient(app).get("/api/admin/word/pool").status_code == 401 # gated
tc = _signin(app, api, "boss@x.com")
# lookup: valid dict word vs non-word vs wrong length
assert tc.get("/api/admin/word/lookup?word=thrive").json() == {
"word": "thrive", "length": 6, "alpha": True, "variant": "6", "in_dictionary": True, "in_pool": True}
"word": "thrive", "length": 6, "alpha": True, "variant": "6",
"in_dictionary": True, "in_pool": True, "removed": False}
assert tc.get("/api/admin/word/lookup?word=zzzzz").json()["in_dictionary"] is False
assert tc.get("/api/admin/word/lookup?word=cat").json()["variant"] is None
# add a valid word, then it shows in the pool + lookup
@@ -390,6 +392,21 @@ def test_word_pool_admin(tmp_path, monkeypatch):
# remove the admin-added word
tc.delete("/api/admin/word/pool/plumb")
assert "plumb" not in tc.get("/api/admin/word/pool").json()["5"]["added"]
# remove a CURATED word (only a tombstone can pull it) → restore brings it back
tc.delete("/api/admin/word/pool/thrive")
look = tc.get("/api/admin/word/lookup?word=thrive").json()
assert look["in_pool"] is False and look["removed"] is True
assert "thrive" in tc.get("/api/admin/word/pool").json()["6"]["removed"]
tc.post("/api/admin/word/pool/restore", json={"word": "thrive"})
assert tc.get("/api/admin/word/lookup?word=thrive").json()["in_pool"] is True
# bulk import: validates, dedupes, reports rejects
fresh5 = next(w for w in sorted(games._DICT["5"]) if w not in set(games._POOL["5"]))
imp = tc.post("/api/admin/word/pool/import",
json={"text": f"{fresh5.upper()}, {fresh5}, thrive, qwxzv, hi"}).json()
assert fresh5 in imp["added"] and imp["counts"]["added"] == 1
assert imp["counts"]["duplicates"] >= 1 # thrive already present
assert {r["word"] for r in imp["rejected"]} == {"qwxzv", "hi"}
assert fresh5 in tc.get("/api/admin/word/pool").json()["5"]["added"]
def test_client_error_telemetry(tmp_path, monkeypatch):
+93
View File
@@ -0,0 +1,93 @@
"""Admin curation of the Daily Word answer pool: add, remove (tombstone any
word curated or added), restore, and bulk import with dedupe + validation."""
import pytest
from goodnews import games
from goodnews.db import connect, init_db
@pytest.fixture
def conn(tmp_path):
c = connect(str(tmp_path / "t.db"))
init_db(c)
return c
def _a_curated(variant):
return games._POOL[variant][0] # a known baked-in word
def _fresh(variant, n=1):
"""n dictionary words of the given length that are NOT already in the pool."""
pool = set(games._POOL[variant])
out = [w for w in sorted(games._DICT[variant]) if w not in pool and w.isalpha()]
return out[:n]
def test_add_and_remove_added_word(conn):
w = _fresh("5")[0]
res = games.add_pool_word(conn, w)
assert res.get("added") and res["variant"] == "5"
assert w in games.answer_pool(conn, "5")
# Removing an added word tombstones it → drops out of the live pool.
games.remove_pool_word(conn, w)
assert w not in games.answer_pool(conn, "5")
# ...and is reversible.
games.restore_pool_word(conn, w)
assert w in games.answer_pool(conn, "5")
def test_remove_curated_word_via_tombstone(conn):
w = _a_curated("5")
assert w in games.answer_pool(conn, "5")
games.remove_pool_word(conn, w) # curated word — only a tombstone can pull it
assert w not in games.answer_pool(conn, "5")
summary = games.pool_summary(conn)
assert w in summary["5"]["removed"]
games.restore_pool_word(conn, w)
assert w in games.answer_pool(conn, "5")
def test_add_rejects_non_dictionary_and_bad_length(conn):
assert "error" in games.add_pool_word(conn, "zzzzz") # not in dict
assert "error" in games.add_pool_word(conn, "hi") # wrong length
assert "error" in games.add_pool_word(conn, _a_curated("5")) # already in pool
def test_add_relifts_tombstone(conn):
w = _a_curated("6")
games.remove_pool_word(conn, w)
assert w not in games.answer_pool(conn, "6")
# Re-adding a removed word lifts the tombstone rather than erroring.
res = games.add_pool_word(conn, w)
assert res.get("added")
assert w in games.answer_pool(conn, "6")
def test_import_dedupes_and_validates(conn):
new5 = _fresh("5", 2) # two fresh 5-letter words
added_word = new5[0]
games.add_pool_word(conn, added_word)
existing_curated = _a_curated("5")
res = games.import_pool_words(conn, [
new5[1], # valid new
new5[1].upper(), # same word, different case → counted once
added_word, # dup (already added)
existing_curated, # dup (curated)
"zzzzz", # rejected: not in dictionary
"hi", # rejected: wrong length
" ", # blank → ignored
])
assert set(res["added"]) == {new5[1]}
assert res["counts"]["added"] == 1
assert res["counts"]["duplicates"] >= 2
assert {r["word"] for r in res["rejected"]} == {"zzzzz", "hi"}
assert new5[1] in games.answer_pool(conn, "5")
def test_import_relifts_removed_word(conn):
w = _a_curated("5")
games.remove_pool_word(conn, w)
res = games.import_pool_words(conn, [w])
assert w in res["added"]
assert w in games.answer_pool(conn, "5")