Admin: Daily Word pool curation (lookup + add/remove)
First games admin tool. A "Games" tab in the operator console for the Daily Word answer pool. * Lookup: is a word real (in the guess dictionary), the right length (5/6), and already in the pool — instant as you type. * Add: appends to the pool, enforcing the invariant (alpha · 5/6 letters · in the guess dict) so the daily answer is always guessable. Remove: drops admin-added words (curated static ones stay). * Additions persist in a new word_pool table (survives redeploys, unlike the baked-in JSON); the daily picker reads static pool ∪ DB additions. Guess dicts shipped with the package (goodnews/data/words-5/6.json) for server-side validation. Admin-gated endpoints + tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,17 +31,48 @@
|
|||||||
await loadStats();
|
await loadStats();
|
||||||
feedback = await getJSON('/api/admin/feedback');
|
feedback = await getJSON('/api/admin/feedback');
|
||||||
candidates = await getJSON('/api/admin/candidates');
|
candidates = await getJSON('/api/admin/candidates');
|
||||||
|
wpPool = await getJSON('/api/admin/word/pool');
|
||||||
} catch {
|
} catch {
|
||||||
error = "Couldn't load stats.";
|
error = "Couldn't load stats.";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Games: Daily Word pool ---
|
||||||
|
let wpWord = $state('');
|
||||||
|
let wpResult = $state(null); // lookup result for the current input
|
||||||
|
let wpPool = $state(null); // { '5': {curated, added[], total}, '6': {...} }
|
||||||
|
let wpMsg = $state('');
|
||||||
|
let wpTimer;
|
||||||
|
const canAddWord = $derived(!!wpResult && !!wpResult.variant && wpResult.in_dictionary && !wpResult.in_pool);
|
||||||
|
|
||||||
|
function onWpInput() {
|
||||||
|
wpMsg = '';
|
||||||
|
clearTimeout(wpTimer);
|
||||||
|
const w = wpWord.trim();
|
||||||
|
if (!w) { wpResult = null; return; }
|
||||||
|
wpTimer = setTimeout(async () => {
|
||||||
|
try { wpResult = await getJSON('/api/admin/word/lookup?word=' + encodeURIComponent(w)); }
|
||||||
|
catch { wpResult = null; }
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
async function addWord() {
|
||||||
|
if (!canAddWord) return;
|
||||||
|
try {
|
||||||
|
const res = await postJSON('/api/admin/word/pool', { word: wpWord.trim() });
|
||||||
|
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.'; }
|
||||||
|
}
|
||||||
|
async function removeWord(w) {
|
||||||
|
try { wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'overview', label: 'Overview' },
|
{ key: 'overview', label: 'Overview' },
|
||||||
{ key: 'content', label: 'Content' },
|
{ key: 'content', label: 'Content' },
|
||||||
{ key: 'sources', label: 'Sources' },
|
{ key: 'sources', label: 'Sources' },
|
||||||
{ key: 'audience', label: 'Audience' },
|
{ key: 'audience', label: 'Audience' },
|
||||||
{ key: 'feedback', label: 'Feedback' },
|
{ key: 'feedback', label: 'Feedback' },
|
||||||
|
{ key: 'games', label: 'Games' },
|
||||||
];
|
];
|
||||||
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
||||||
// Unknown ?section= values fall back to Overview so the page never renders blank.
|
// Unknown ?section= values fall back to Overview so the page never renders blank.
|
||||||
@@ -679,6 +710,47 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{:else}<p class="muted">Nothing in this filter.</p>{/if}
|
{:else}<p class="muted">Nothing in this filter.</p>{/if}
|
||||||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||||||
|
|
||||||
|
{:else if section === 'games'}
|
||||||
|
<h2>Daily Word pool</h2>
|
||||||
|
<p class="muted">Look up a word and add it to the answer pool. Only real, 5- or 6-letter
|
||||||
|
words in the guess dictionary qualify, so the daily answer is always solvable.</p>
|
||||||
|
|
||||||
|
<div class="wp-lookup">
|
||||||
|
<input type="text" bind:value={wpWord} oninput={onWpInput} maxlength="6" autocapitalize="off"
|
||||||
|
autocomplete="off" spellcheck="false" placeholder="Type a word to check…" />
|
||||||
|
{#if wpResult && wpResult.word}
|
||||||
|
{#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>
|
||||||
|
{: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>
|
||||||
|
{:else}
|
||||||
|
<span class="wp-tag bad">Not in the guess dictionary</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if wpMsg}<p class="wp-msg">{wpMsg}</p>{/if}
|
||||||
|
|
||||||
|
{#if wpPool}
|
||||||
|
<div class="wp-cols">
|
||||||
|
{#each ['5', '6'] as v (v)}
|
||||||
|
<div class="wp-col">
|
||||||
|
<h3>{v === '6' ? 'Long Word' : 'Daily Word'} <span class="count">· {v} letters · {wpPool[v].total} words</span></h3>
|
||||||
|
<p class="muted small">{wpPool[v].curated} curated + {wpPool[v].added.length} added by you</p>
|
||||||
|
{#if wpPool[v].added.length}
|
||||||
|
<ul class="wp-added">
|
||||||
|
{#each wpPool[v].added as w (w)}
|
||||||
|
<li>{w}<button class="x" onclick={() => removeWord(w)} aria-label={'Remove ' + w}>×</button></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}<p class="muted small">No words added yet — the curated pool is in use.</p>{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@@ -929,4 +1001,31 @@
|
|||||||
}
|
}
|
||||||
.factions .act:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
|
.factions .act:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
|
||||||
.factions .act.del:hover { color: #9a3b3b; border-bottom-color: #9a3b3b; }
|
.factions .act.del:hover { color: #9a3b3b; border-bottom-color: #9a3b3b; }
|
||||||
|
|
||||||
|
/* Games — Daily Word pool */
|
||||||
|
.wp-lookup { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 14px 0 6px; }
|
||||||
|
.wp-lookup input {
|
||||||
|
font: inherit; font-size: 1.05rem; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em; width: 210px; background: var(--surface); color: var(--ink);
|
||||||
|
}
|
||||||
|
.wp-tag { font-size: 0.84rem; font-weight: 600; padding: 4px 11px; border-radius: 999px; }
|
||||||
|
.wp-tag.ok { background: var(--accent-soft); color: var(--accent-deep); }
|
||||||
|
.wp-tag.bad { background: #f3e0e0; color: #9a3b3b; }
|
||||||
|
.wp-add { background: var(--accent); color: #fff; border: none; border-radius: 999px; padding: 8px 18px;
|
||||||
|
font: inherit; font-weight: 600; cursor: pointer; }
|
||||||
|
.wp-add:hover { background: var(--accent-deep); }
|
||||||
|
.wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; }
|
||||||
|
.wp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 22px; margin-top: 24px; }
|
||||||
|
.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 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;
|
||||||
|
font-size: 0.82rem; letter-spacing: 0.04em; color: var(--ink);
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -340,6 +340,10 @@ class WordGuessRequest(BaseModel):
|
|||||||
n: int = 1 # this guess's position (1-based); the answer is revealed only at n >= max
|
n: int = 1 # this guess's position (1-based); the answer is revealed only at n >= max
|
||||||
|
|
||||||
|
|
||||||
|
class WordPoolBody(BaseModel):
|
||||||
|
word: str
|
||||||
|
|
||||||
|
|
||||||
class EmailStartRequest(BaseModel):
|
class EmailStartRequest(BaseModel):
|
||||||
email: str
|
email: str
|
||||||
|
|
||||||
@@ -1448,6 +1452,37 @@ def create_app() -> FastAPI:
|
|||||||
raise HTTPException(status_code=400, detail=res["error"])
|
raise HTTPException(status_code=400, detail=res["error"])
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
# --- Admin: Daily Word pool curation ---
|
||||||
|
@app.get("/api/admin/word/lookup")
|
||||||
|
def admin_word_lookup(word: str, request: Request) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
_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"])
|
||||||
|
return res
|
||||||
|
|
||||||
|
@app.get("/api/admin/word/pool")
|
||||||
|
def admin_word_pool(request: Request) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
_require_admin(conn, request)
|
||||||
|
return games.pool_summary(conn)
|
||||||
|
|
||||||
|
@app.post("/api/admin/word/pool")
|
||||||
|
def admin_word_pool_add(body: WordPoolBody, request: Request) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
_require_admin(conn, request)
|
||||||
|
res = games.add_pool_word(conn, body.word)
|
||||||
|
if "error" in res:
|
||||||
|
raise HTTPException(status_code=400, detail=res["error"])
|
||||||
|
return {**res, "pool": games.pool_summary(conn)}
|
||||||
|
|
||||||
|
@app.delete("/api/admin/word/pool/{word}")
|
||||||
|
def admin_word_pool_remove(word: str, request: Request) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
_require_admin(conn, request)
|
||||||
|
games.remove_pool_word(conn, word)
|
||||||
|
return games.pool_summary(conn)
|
||||||
|
|
||||||
@app.get("/api/since", response_model=FeedResponse)
|
@app.get("/api/since", response_model=FeedResponse)
|
||||||
def feed_since(ts: str = Query(...), prefs: str | None = Query(None)) -> FeedResponse:
|
def feed_since(ts: str = Query(...), prefs: str | None = Query(None)) -> FeedResponse:
|
||||||
# A calm welcome-back cue: accepted/non-dup/visible articles discovered
|
# A calm welcome-back cue: accepted/non-dup/visible articles discovered
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 INDEX IF NOT EXISTS idx_feedback_replies_fid ON feedback_replies(feedback_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS word_pool (
|
||||||
|
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 (
|
CREATE TABLE IF NOT EXISTS daily_puzzles (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
puzzle_date TEXT NOT NULL,
|
puzzle_date TEXT NOT NULL,
|
||||||
|
|||||||
+65
-2
@@ -17,7 +17,11 @@ import re
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_POOL = json.loads((Path(__file__).parent / "data" / "wordpool.json").read_text())
|
_DATA = Path(__file__).parent / "data"
|
||||||
|
_POOL = json.loads((_DATA / "wordpool.json").read_text()) # curated static answer pool
|
||||||
|
# Guess dictionaries (same lists the client validates against) — used server-side to
|
||||||
|
# guarantee any admin-added answer is actually guessable.
|
||||||
|
_DICT = {v: set(json.loads((_DATA / f"words-{v}.json").read_text())) for v in ("5", "6")}
|
||||||
|
|
||||||
# Daily Word: 5 letters / 6 guesses · Long Word: 6 letters / 7 guesses.
|
# Daily Word: 5 letters / 6 guesses · Long Word: 6 letters / 7 guesses.
|
||||||
WORD_VARIANTS = {"5": {"length": 5, "guesses": 6}, "6": {"length": 6, "guesses": 7}}
|
WORD_VARIANTS = {"5": {"length": 5, "guesses": 6}, "6": {"length": 6, "guesses": 7}}
|
||||||
@@ -27,6 +31,65 @@ def _seed(*parts: str) -> int:
|
|||||||
return int(hashlib.sha256(":".join(parts).encode()).hexdigest(), 16)
|
return int(hashlib.sha256(":".join(parts).encode()).hexdigest(), 16)
|
||||||
|
|
||||||
|
|
||||||
|
def _db_pool(conn: sqlite3.Connection, variant: str) -> list[str]:
|
||||||
|
rows = conn.execute("SELECT word FROM word_pool 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)))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin: Daily Word pool curation -------------------------------------------
|
||||||
|
|
||||||
|
def lookup_word(word: str) -> dict:
|
||||||
|
"""Is this word a valid, guessable answer candidate?"""
|
||||||
|
w = (word or "").strip().lower()
|
||||||
|
variant = str(len(w))
|
||||||
|
return {
|
||||||
|
"word": w,
|
||||||
|
"length": len(w),
|
||||||
|
"alpha": bool(w) and w.isalpha(),
|
||||||
|
"variant": variant if variant in WORD_VARIANTS else None,
|
||||||
|
"in_dictionary": w.isalpha() and w in _DICT.get(variant, set()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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)):
|
||||||
|
return {"error": "already in the pool"}
|
||||||
|
conn.execute("INSERT OR IGNORE INTO word_pool (word, variant) VALUES (?, ?)", (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."""
|
||||||
|
w = (word or "").strip().lower()
|
||||||
|
cur = conn.execute("DELETE FROM word_pool WHERE variant=? AND word=?", (str(len(w)), w))
|
||||||
|
conn.commit()
|
||||||
|
return {"word": w, "removed": cur.rowcount > 0}
|
||||||
|
|
||||||
|
|
||||||
|
def pool_summary(conn: sqlite3.Connection) -> dict:
|
||||||
|
"""Pool counts + the removable (admin-added) 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))}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _recent_answers(conn: sqlite3.Connection, variant: str, limit: int) -> set[str]:
|
def _recent_answers(conn: sqlite3.Connection, variant: str, limit: int) -> set[str]:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT payload_json FROM daily_puzzles WHERE game='word' AND variant=? "
|
"SELECT payload_json FROM daily_puzzles WHERE game='word' AND variant=? "
|
||||||
@@ -43,7 +106,7 @@ def _recent_answers(conn: sqlite3.Connection, variant: str, limit: int) -> set[s
|
|||||||
|
|
||||||
|
|
||||||
def _pick_answer(conn: sqlite3.Connection, date: str, variant: str) -> str:
|
def _pick_answer(conn: sqlite3.Connection, date: str, variant: str) -> str:
|
||||||
pool = _POOL.get(variant, [])
|
pool = answer_pool(conn, variant)
|
||||||
recent = _recent_answers(conn, variant, max(1, len(pool) // 2))
|
recent = _recent_answers(conn, variant, max(1, len(pool) // 2))
|
||||||
start = _seed(date, "word", variant) % len(pool)
|
start = _seed(date, "word", variant) % len(pool)
|
||||||
for i in range(len(pool)):
|
for i in range(len(pool)):
|
||||||
|
|||||||
@@ -368,3 +368,25 @@ def test_wordsearch_thin_llm_falls_back(tmp_path, monkeypatch):
|
|||||||
rich = FakeClient("THEME: Rich Theme\nWORDS: " + ", ".join(rich_words))
|
rich = FakeClient("THEME: Rich Theme\nWORDS: " + ", ".join(rich_words))
|
||||||
p2 = games.generate_wordsearch_puzzle(c, "2026-07-02", client=rich)
|
p2 = games.generate_wordsearch_puzzle(c, "2026-07-02", client=rich)
|
||||||
assert p2["theme"] == "Rich Theme" and len(p2["words"]) >= games.WS_MIN_ACCEPT
|
assert p2["theme"] == "Rich Theme" and len(p2["words"]) >= games.WS_MIN_ACCEPT
|
||||||
|
|
||||||
|
|
||||||
|
def test_word_pool_admin(tmp_path, monkeypatch):
|
||||||
|
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}
|
||||||
|
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
|
||||||
|
res = tc.post("/api/admin/word/pool", json={"word": "PLUMB"}).json()
|
||||||
|
assert res["added"] is True and "plumb" in res["pool"]["5"]["added"]
|
||||||
|
assert tc.get("/api/admin/word/lookup?word=plumb").json()["in_pool"] is True
|
||||||
|
# rejections: non-dictionary word + wrong length + duplicate
|
||||||
|
assert tc.post("/api/admin/word/pool", json={"word": "qwxzv"}).status_code == 400
|
||||||
|
assert tc.post("/api/admin/word/pool", json={"word": "cat"}).status_code == 400
|
||||||
|
assert tc.post("/api/admin/word/pool", json={"word": "plumb"}).status_code == 400
|
||||||
|
# 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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user