Admin: Word Search theme authoring + tidy word-pool chips

* New "Word Search themes" panel in the Games tab: enter a theme name + words,
  with live validation (4–8 letters, alpha, deduped) and a count vs the 28 needed
  to fill all three sizes. An " Suggest a word" button asks the LLM for one
  fresh word that fits the theme. Save/edit/remove; authored themes join the daily
  fallback rotation alongside the curated ones (wordsearch_themes table). The
  system still handles word distribution across sizes + placement.
* Daily Word pool's added-word chips now scroll within a bounded area so the
  console stays tidy as the list grows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-11 13:36:07 -04:00
parent 61f575ba6d
commit f71e760847
5 changed files with 268 additions and 2 deletions
+99 -1
View File
@@ -33,6 +33,7 @@
candidates = await getJSON('/api/admin/candidates');
wpPool = await getJSON('/api/admin/word/pool');
clientErrors = await getJSON('/api/admin/client-errors');
wsThemes = await getJSON('/api/admin/wordsearch/themes');
} catch {
error = "Couldn't load stats.";
}
@@ -69,6 +70,43 @@
try { wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); } catch { /* ignore */ }
}
// --- Games: Word Search themes ---
const WS_NEEDED = 28;
let wsThemes = $state([]);
let wsTheme = $state('');
let wsWordsText = $state('');
let wsEditId = $state(null);
let wsMsg = $state('');
let wsSuggesting = $state(false);
function parseWords(text) {
return [...new Set((text || '').split(/[\s,]+/).map((w) => w.trim().toUpperCase()).filter(Boolean))];
}
let wsParsed = $derived(parseWords(wsWordsText));
let wsValid = $derived(wsParsed.filter((w) => /^[A-Z]{4,8}$/.test(w)));
let wsInvalid = $derived(wsParsed.filter((w) => !/^[A-Z]{4,8}$/.test(w)));
async function loadWsThemes() { try { wsThemes = await getJSON('/api/admin/wordsearch/themes'); } catch { /* ignore */ } }
async function suggestWsWord() {
if (!wsTheme.trim()) { wsMsg = 'Enter a theme first.'; return; }
wsSuggesting = true; wsMsg = '';
try {
const r = await postJSON('/api/admin/wordsearch/suggest', { theme: wsTheme.trim(), existing: wsValid });
wsWordsText = (wsWordsText.trim() + ' ' + r.word).trim();
} catch (e) { wsMsg = e.message || 'No suggestion available.'; }
finally { wsSuggesting = false; }
}
async function saveWsTheme() {
try {
const res = await postJSON('/api/admin/wordsearch/themes', { theme: wsTheme.trim(), words: wsValid, id: wsEditId });
wsThemes = res.themes; wsMsg = `Saved “${wsTheme.trim()}” (${res.count} words).`;
wsTheme = ''; wsWordsText = ''; wsEditId = null;
} catch (e) { wsMsg = e.message || 'Could not save.'; }
}
function editWsTheme(t) { wsEditId = t.id; wsTheme = t.theme; wsWordsText = t.words.join(' '); wsMsg = ''; }
function cancelWsEdit() { wsEditId = null; wsTheme = ''; wsWordsText = ''; wsMsg = ''; }
async function removeWsTheme(t) { try { wsThemes = await delJSON('/api/admin/wordsearch/themes/' + t.id); } catch { /* ignore */ } }
const TABS = [
{ key: 'overview', label: 'Overview' },
{ key: 'content', label: 'Content' },
@@ -773,6 +811,45 @@
{/each}
</div>
{/if}
<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
suggest one that fits.</p>
<div class="ws-form">
<input class="ws-name" type="text" bind:value={wsTheme} maxlength="40"
placeholder="Theme name (e.g. In the kitchen)" />
<textarea class="ws-words" bind:value={wsWordsText} rows="4"
placeholder="Words, separated by spaces or commas…"></textarea>
<div class="ws-row">
<span class="ws-count" class:ok={wsValid.length >= WS_NEEDED}>
{wsValid.length} / {WS_NEEDED} valid{#if wsInvalid.length} · {wsInvalid.length} skipped{/if}
</span>
<button class="ws-ai" onclick={suggestWsWord} disabled={wsSuggesting}>
{wsSuggesting ? 'Thinking…' : '✨ Suggest a word'}
</button>
<button class="wp-add" onclick={saveWsTheme} disabled={wsValid.length < WS_NEEDED || !wsTheme.trim()}>
{wsEditId ? 'Update theme' : 'Save theme'}
</button>
{#if wsEditId}<button class="act" onclick={cancelWsEdit}>Cancel</button>{/if}
</div>
{#if wsInvalid.length}<p class="muted small">Skipped (not 48 letters): {wsInvalid.join(', ')}</p>{/if}
</div>
{#if wsMsg}<p class="wp-msg">{wsMsg}</p>{/if}
{#if wsThemes.length}
<ul class="ws-themes">
{#each wsThemes as t (t.id)}
<li>
<span class="wt-name">{t.theme}</span>
<span class="wt-count">{t.count} words</span>
<button class="act" onclick={() => editWsTheme(t)}>Edit</button>
<button class="act del" onclick={() => removeWsTheme(t)}>Remove</button>
</li>
{/each}
</ul>
{:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/if}
{/if}
{/if}
</main>
@@ -1052,7 +1129,8 @@
.wp-col h3 { margin: 0 0 2px; font-size: 1.05rem; }
.wp-col .count { font-size: 0.85rem; }
.small { font-size: 0.82rem; }
.wp-added { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; padding: 0; margin: 10px 0 0; }
.wp-added { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; padding: 0; margin: 10px 0 0;
max-height: 230px; overflow-y: auto; }
.wp-added li {
display: inline-flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 4px 6px 4px 12px; font-family: var(--label); text-transform: uppercase;
@@ -1061,4 +1139,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; }
/* Word Search theme authoring */
.ws-form { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 14px 0 6px; }
.ws-name { font: inherit; font-size: 1.05rem; padding: 10px 14px; border: 1px solid var(--line);
border-radius: 10px; background: var(--surface); color: var(--ink); }
.ws-words { 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; }
.ws-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.ws-count { font-size: 0.86rem; font-weight: 600; color: var(--muted); }
.ws-count.ok { color: var(--accent-deep); }
.ws-ai { background: var(--accent-soft); color: var(--accent-deep); border: none; border-radius: 999px;
padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
.ws-ai:hover:not(:disabled) { filter: brightness(0.97); }
.ws-ai:disabled, .wp-add:disabled { opacity: 0.5; cursor: default; }
.ws-themes { list-style: none; padding: 0; margin: 14px 0 0; display: flex; flex-direction: column; gap: 8px; max-width: 560px; }
.ws-themes li { display: flex; align-items: center; gap: 12px; background: var(--surface);
border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
.wt-name { font-weight: 600; }
.wt-count { color: var(--muted); font-size: 0.84rem; margin-right: auto; }
</style>
+48
View File
@@ -350,6 +350,17 @@ class ClientErrorBody(BaseModel):
version: str = ""
class WordsearchThemeBody(BaseModel):
theme: str
words: list[str] = []
id: int | None = None
class WordsearchSuggestBody(BaseModel):
theme: str
existing: list[str] = []
class EmailStartRequest(BaseModel):
email: str
@@ -1512,6 +1523,43 @@ def create_app() -> FastAPI:
games.remove_pool_word(conn, word)
return games.pool_summary(conn)
# --- Admin: Word Search theme authoring ---
@app.get("/api/admin/wordsearch/themes")
def admin_ws_themes(request: Request) -> list[dict]:
with get_conn() as conn:
_require_admin(conn, request)
return games.list_wordsearch_themes(conn)
@app.post("/api/admin/wordsearch/themes")
def admin_ws_theme_save(body: WordsearchThemeBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
res = games.save_wordsearch_theme(conn, body.theme, body.words, body.id)
if "error" in res:
raise HTTPException(status_code=400, detail=res["error"])
return {**res, "themes": games.list_wordsearch_themes(conn)}
@app.delete("/api/admin/wordsearch/themes/{tid}")
def admin_ws_theme_remove(tid: int, request: Request) -> list[dict]:
with get_conn() as conn:
_require_admin(conn, request)
games.remove_wordsearch_theme(conn, tid)
return games.list_wordsearch_themes(conn)
@app.post("/api/admin/wordsearch/suggest")
def admin_ws_suggest(body: WordsearchSuggestBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
from .llm import LocalModelClient
try:
client = LocalModelClient.from_env()
except Exception: # noqa: BLE001
client = None
res = games.suggest_wordsearch_word(client, body.theme, body.existing)
if "error" in res:
raise HTTPException(status_code=503, detail=res["error"])
return res
@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
+7
View File
@@ -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 wordsearch_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
theme TEXT NOT NULL,
words_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS client_errors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reason TEXT NOT NULL DEFAULT '',
+93 -1
View File
@@ -270,6 +270,93 @@ _WS_FALLBACKS = [
]
WS_WORD_MIN, WS_WORD_MAX = 4, 8
def _ws_clean_words(words: list[str]) -> list[str]:
"""Validate/clean a word-search list: alpha, 48 letters, uppercase, deduped."""
out, seen = [], set()
for w in words or []:
w = (w or "").strip().upper()
if w.isalpha() and WS_WORD_MIN <= len(w) <= WS_WORD_MAX and w not in seen:
seen.add(w)
out.append(w)
return out
def _ws_theme_bank(conn: sqlite3.Connection) -> list[tuple[str, list[str]]]:
"""Admin-authored themes (join the daily fallback rotation alongside curated)."""
out = []
for r in conn.execute("SELECT theme, words_json FROM wordsearch_themes ORDER BY id").fetchall():
try:
out.append((r["theme"], json.loads(r["words_json"])))
except (ValueError, TypeError):
pass
return out
def list_wordsearch_themes(conn: sqlite3.Connection) -> list[dict]:
rows = conn.execute(
"SELECT id, theme, words_json, created_at FROM wordsearch_themes ORDER BY id DESC"
).fetchall()
res = []
for r in rows:
try:
w = json.loads(r["words_json"])
except (ValueError, TypeError):
w = []
res.append({"id": r["id"], "theme": r["theme"], "words": w, "count": len(w), "created_at": r["created_at"]})
return res
def save_wordsearch_theme(conn: sqlite3.Connection, theme: str, words: list[str], tid: int | None = None) -> dict:
"""Add or update an admin theme. Requires a name and >= WS_NEEDED valid words
(enough for the three disjoint puzzles)."""
theme = (theme or "").strip()[:40]
clean = _ws_clean_words(words)
if not theme:
return {"error": "Give the theme a name."}
if len(clean) < WS_NEEDED:
return {"error": f"Need at least {WS_NEEDED} valid words (48 letters); you have {len(clean)}."}
if tid:
conn.execute("UPDATE wordsearch_themes SET theme=?, words_json=? WHERE id=?",
(theme, json.dumps(clean), tid))
else:
conn.execute("INSERT INTO wordsearch_themes (theme, words_json) VALUES (?, ?)",
(theme, json.dumps(clean)))
conn.commit()
return {"saved": True, "count": len(clean)}
def remove_wordsearch_theme(conn: sqlite3.Connection, tid: int) -> dict:
conn.execute("DELETE FROM wordsearch_themes WHERE id=?", (tid,))
conn.commit()
return {"removed": True}
def suggest_wordsearch_word(client, theme: str, existing: list[str]) -> dict:
"""AI assist: propose ONE fresh, valid word that fits the theme."""
if client is None:
return {"error": "AI assist isn't available right now."}
have = {(w or "").strip().upper() for w in (existing or [])}
try:
msg = [
{"role": "system", "content": "Reply with ONE single real English word, 4-8 letters, lowercase, "
"no punctuation or explanation."},
{"role": "user", "content": f"Give one word (4-8 letters) that fits a word-search puzzle themed "
f"'{theme}'. Avoid: {', '.join(sorted(have)[:80]).lower()}."},
]
for _ in range(4):
words = re.findall(r"[A-Za-z]+", client.chat_text(msg) or "")
hit = next((w.upper() for w in words
if w.isalpha() and WS_WORD_MIN <= len(w) <= WS_WORD_MAX and w.upper() not in have), None)
if hit:
return {"word": hit}
except Exception: # noqa: BLE001
pass
return {"error": "Couldn't find a fresh one — try adding it yourself."}
def _ws_propose(client) -> tuple[str, list[str]] | None:
"""LLM proposes a theme + words; code disposes (alpha / length / dedup)."""
if client is None:
@@ -310,7 +397,12 @@ def generate_wordsearch_puzzle(conn: sqlite3.Connection, date: str, client=None)
return json.loads(existing["payload_json"])
rng = random.Random(_seed(date, "wordsearch"))
proposed = _ws_propose(client)
theme, words = proposed if proposed else _WS_FALLBACKS[rng.randrange(len(_WS_FALLBACKS))]
if proposed:
theme, words = proposed
else:
# Fallback rotation = curated themes + admin-authored bank.
bank = _WS_FALLBACKS + _ws_theme_bank(conn)
theme, words = bank[rng.randrange(len(bank))]
words = [w.upper() for w in dict.fromkeys(words) if w.isalpha() and 4 <= len(w) <= 8]
payload = {"theme": theme, "words": words}
conn.execute(
+21
View File
@@ -402,3 +402,24 @@ def test_client_error_telemetry(tmp_path, monkeypatch):
assert len(rows) == 1 and rows[0]["reason"] == "boot-timeout" and rows[0]["path"] == "/play"
assert rows[0]["user_agent"] # captured from the request header
assert tc.get("/api/admin/stats").json()["client_errors"]["today"] == 1
def test_wordsearch_theme_admin(tmp_path, monkeypatch):
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
assert TestClient(app).get("/api/admin/wordsearch/themes").status_code == 401 # gated
tc = _signin(app, api, "boss@x.com")
w28 = ["table", "chair", "clock", "shelf", "couch", "pillow", "window", "carpet", "mirror", "candle",
"kettle", "drawer", "closet", "curtain", "cushion", "basket", "bottle", "towel", "broom", "ladder",
"stairs", "pantry", "blanket", "vase", "hallway", "doorway", "mantel", "hamper"]
# too few valid words → 400
assert tc.post("/api/admin/wordsearch/themes", json={"theme": "X", "words": ["cat", "dog"]}).status_code == 400
# save ok (>= 28); listed with the right count
res = tc.post("/api/admin/wordsearch/themes", json={"theme": "My House", "words": w28}).json()
assert res["saved"] and any(t["theme"] == "My House" and t["count"] == 28 for t in res["themes"])
tid = next(t["id"] for t in res["themes"] if t["theme"] == "My House")
# edit/update keeps the same id
upd = tc.post("/api/admin/wordsearch/themes", json={"theme": "House Stuff", "words": w28, "id": tid}).json()
assert any(t["id"] == tid and t["theme"] == "House Stuff" for t in upd["themes"])
# remove
left = tc.delete(f"/api/admin/wordsearch/themes/{tid}").json()
assert not any(t["id"] == tid for t in left)