diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 18eefde..f095397 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -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} {/if} + +
Add a theme and its words — the system splits them across the three sizes and places + them. You need {WS_NEEDED}+ valid words (4–8 letters) to fill all three puzzles. Stuck? Let the AI + suggest one that fits.
+ +Skipped (not 4–8 letters): {wsInvalid.join(', ')}
{/if} +{wsMsg}
{/if} + + {#if wsThemes.length} +No custom themes yet — the daily rotation uses the built-in ones.
{/if} {/if} {/if} @@ -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; } diff --git a/goodnews/api.py b/goodnews/api.py index 630f42b..8eb301a 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -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 diff --git a/goodnews/db.py b/goodnews/db.py index 11e2471..03bc196 100644 --- a/goodnews/db.py +++ b/goodnews/db.py @@ -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 '', diff --git a/goodnews/games.py b/goodnews/games.py index a47f69a..adddf2e 100644 --- a/goodnews/games.py +++ b/goodnews/games.py @@ -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( diff --git a/tests/test_admin.py b/tests/test_admin.py index b34977c..c1b2d32 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -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)