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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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, 4–8 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 (4–8 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(
|
||||
|
||||
Reference in New Issue
Block a user