Play hub + Daily Word game (Phase 1 of the games feature)
A calm /play space — "after the brief, a small thing to enjoy." Framework-ready for more games (Word Search next; zen/coloring later). * Daily Word (5 letters / 6 guesses) + Long Word (6 / 7) — same Wordle mechanic, Upbeat Bytes flavor (no "Wordle" in the UI). Hopeful answers; after solving, a one-line "why this word matters." * LLM proposes, code disposes: answers are picked deterministically by date-seed from a hand-curated hopeful pool that's pre-validated ⊆ the guess dictionary (always typeable), avoiding recent repeats; the LLM only adds the optional "why" (with fallback). daily_puzzles(date, game, variant, payload) stores them so everyone gets the same daily; the cycle pre-generates with the "why". * Bundled guess dictionaries (words-5/6.json, ~12.6k/22.4k) for client-side guess validation — never the LLM. Answer lightly obfuscated (base64) in the payload. * Private, gentle stats (played/solved/streak, guess distribution); spoiler-free emoji-grid share. No leaderboard, no timer, no streak-loss drama. * Play in the bottom nav (replacing Browse, still on the lane rail) + the header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+9
-1
@@ -33,7 +33,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import auth, email_send, feeds, oauth_google, queries, share, sources, summarize
|
||||
from . import auth, email_send, feeds, games, oauth_google, queries, share, sources, summarize
|
||||
from .localtime import local_today
|
||||
from .markup import reply_html_to_text, sanitize_reply_html
|
||||
from .db import connect
|
||||
from .filters import filter_articles, prefs_from_json
|
||||
@@ -1422,6 +1423,13 @@ def create_app() -> FastAPI:
|
||||
items=[Article.from_row(r) for r in rows],
|
||||
)
|
||||
|
||||
@app.get("/api/puzzle/{game}")
|
||||
def daily_puzzle(game: str, variant: str = Query("5")) -> dict:
|
||||
if game != "word" or variant not in games.WORD_VARIANTS:
|
||||
raise HTTPException(status_code=404, detail="no such puzzle")
|
||||
with get_conn() as conn:
|
||||
return games.word_puzzle_response(conn, local_today(), variant)
|
||||
|
||||
@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
|
||||
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
from .briefs import build_daily_brief, show_brief
|
||||
from .db import connect, init_db
|
||||
from .digest import send_due_digests
|
||||
from .games import generate_daily_puzzles
|
||||
from .localtime import local_today
|
||||
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
|
||||
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
|
||||
@@ -512,6 +513,14 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
||||
except Exception as exc:
|
||||
print(f"digest: skipped ({exc})")
|
||||
|
||||
# Pre-generate today's daily puzzles (with the LLM 'why'); idempotent.
|
||||
try:
|
||||
made = generate_daily_puzzles(conn, local_today(), client=llm_client_from_args(args))
|
||||
if made:
|
||||
print(f"puzzles: generated {made}")
|
||||
except Exception as exc:
|
||||
print(f"puzzles: skipped ({exc})")
|
||||
|
||||
|
||||
def serve(args: argparse.Namespace) -> None:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"5": [
|
||||
"ample",
|
||||
"amply",
|
||||
"award",
|
||||
"bliss",
|
||||
"bloom",
|
||||
"bonus",
|
||||
"brave",
|
||||
"charm",
|
||||
"cheer",
|
||||
"clear",
|
||||
"dream",
|
||||
"favor",
|
||||
"fresh",
|
||||
"gifts",
|
||||
"glows",
|
||||
"grace",
|
||||
"green",
|
||||
"happy",
|
||||
"heals",
|
||||
"heart",
|
||||
"honor",
|
||||
"hopes",
|
||||
"ideal",
|
||||
"learn",
|
||||
"light",
|
||||
"lucky",
|
||||
"mends",
|
||||
"mercy",
|
||||
"merry",
|
||||
"noble",
|
||||
"peace",
|
||||
"prize",
|
||||
"quiet",
|
||||
"reach",
|
||||
"ready",
|
||||
"relax",
|
||||
"renew",
|
||||
"savor",
|
||||
"share",
|
||||
"shine",
|
||||
"smile",
|
||||
"still",
|
||||
"sunny",
|
||||
"teach",
|
||||
"thank",
|
||||
"treat",
|
||||
"trust",
|
||||
"truth",
|
||||
"unity",
|
||||
"value",
|
||||
"vital"
|
||||
],
|
||||
"6": [
|
||||
"beauty",
|
||||
"bestow",
|
||||
"bright",
|
||||
"credit",
|
||||
"devote",
|
||||
"esteem",
|
||||
"gather",
|
||||
"gentle",
|
||||
"giving",
|
||||
"growth",
|
||||
"health",
|
||||
"joyful",
|
||||
"kindly",
|
||||
"lively",
|
||||
"lovely",
|
||||
"mellow",
|
||||
"mended",
|
||||
"nature",
|
||||
"praise",
|
||||
"reborn",
|
||||
"repair",
|
||||
"rescue",
|
||||
"revive",
|
||||
"reward",
|
||||
"secure",
|
||||
"serene",
|
||||
"simple",
|
||||
"smiled",
|
||||
"soothe",
|
||||
"spirit",
|
||||
"steady",
|
||||
"strong",
|
||||
"summit",
|
||||
"superb",
|
||||
"tender",
|
||||
"thanks",
|
||||
"thrive",
|
||||
"united",
|
||||
"uplift",
|
||||
"valued",
|
||||
"vision",
|
||||
"warmth",
|
||||
"wisdom",
|
||||
"wonder"
|
||||
]
|
||||
}
|
||||
@@ -260,6 +260,16 @@ 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 daily_puzzles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
puzzle_date TEXT NOT NULL,
|
||||
game TEXT NOT NULL, -- 'word' | 'wordsearch'
|
||||
variant TEXT NOT NULL DEFAULT '',
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (puzzle_date, game, variant)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_follows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Daily puzzles for the calm Play hub.
|
||||
|
||||
Principle: **the LLM proposes, code disposes.** The LLM only contributes
|
||||
creative flavor (a one-line "why today's word matters"); the daily answer is
|
||||
picked deterministically by code from a pre-validated hopeful pool (every word
|
||||
is guaranteed to be in the guess dictionary, so it's always typeable). Puzzles
|
||||
are stored per (date, game, variant) so everyone gets the same one and shares
|
||||
are comparable. Generation never blocks on or trusts the LLM for correctness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
_POOL = json.loads((Path(__file__).parent / "data" / "wordpool.json").read_text())
|
||||
|
||||
# Daily Word: 5 letters / 6 guesses · Long Word: 6 letters / 7 guesses.
|
||||
WORD_VARIANTS = {"5": {"length": 5, "guesses": 6}, "6": {"length": 6, "guesses": 7}}
|
||||
|
||||
|
||||
def _seed(*parts: str) -> int:
|
||||
return int(hashlib.sha256(":".join(parts).encode()).hexdigest(), 16)
|
||||
|
||||
|
||||
def _recent_answers(conn: sqlite3.Connection, variant: str, limit: int) -> set[str]:
|
||||
rows = conn.execute(
|
||||
"SELECT payload_json FROM daily_puzzles WHERE game='word' AND variant=? "
|
||||
"ORDER BY puzzle_date DESC LIMIT ?",
|
||||
(variant, limit),
|
||||
).fetchall()
|
||||
out = set()
|
||||
for r in rows:
|
||||
try:
|
||||
out.add(json.loads(r["payload_json"])["answer"])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _pick_answer(conn: sqlite3.Connection, date: str, variant: str) -> str:
|
||||
pool = _POOL.get(variant, [])
|
||||
recent = _recent_answers(conn, variant, max(1, len(pool) // 2))
|
||||
start = _seed(date, "word", variant) % len(pool)
|
||||
for i in range(len(pool)):
|
||||
cand = pool[(start + i) % len(pool)]
|
||||
if cand not in recent:
|
||||
return cand
|
||||
return pool[start] # pool fully cycled — allow a repeat rather than fail
|
||||
|
||||
|
||||
def _why(client, word: str) -> str | None:
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
msg = [
|
||||
{"role": "system", "content": "You write one short, warm, plain sentence (no preamble, no quotes) "
|
||||
"on why a given word is a hopeful or uplifting one to sit with."},
|
||||
{"role": "user", "content": f"Word: {word}"},
|
||||
]
|
||||
text = (client.chat_text(msg) or "").strip().strip('"').replace("\n", " ")
|
||||
return text[:200] or None
|
||||
except Exception: # noqa: BLE001 — flavor only; never block puzzle creation
|
||||
return None
|
||||
|
||||
|
||||
def generate_word_puzzle(conn: sqlite3.Connection, date: str, variant: str, client=None) -> dict:
|
||||
"""Ensure a Daily/Long Word puzzle exists for (date, variant). Idempotent.
|
||||
Code picks the answer; the LLM only adds the optional 'why' (with fallback)."""
|
||||
if variant not in WORD_VARIANTS:
|
||||
variant = "5"
|
||||
existing = conn.execute(
|
||||
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?",
|
||||
(date, variant),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return json.loads(existing["payload_json"])
|
||||
answer = _pick_answer(conn, date, variant)
|
||||
payload = {
|
||||
"answer": answer,
|
||||
"why": _why(client, answer),
|
||||
"length": WORD_VARIANTS[variant]["length"],
|
||||
"guesses": WORD_VARIANTS[variant]["guesses"],
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'word', ?, ?)",
|
||||
(date, variant, json.dumps(payload)),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?",
|
||||
(date, variant),
|
||||
).fetchone()
|
||||
return json.loads(row["payload_json"])
|
||||
|
||||
|
||||
def _b64(s: str | None) -> str | None:
|
||||
return base64.b64encode(s.encode()).decode() if s else None
|
||||
|
||||
|
||||
def word_puzzle_response(conn: sqlite3.Connection, date: str, variant: str) -> dict:
|
||||
"""API shape: answer/why lightly obfuscated (base64) so the day's word isn't
|
||||
sitting in plain network view. It's a calm, non-competitive game — not Fort Knox."""
|
||||
p = generate_word_puzzle(conn, date, variant) # create on demand (no LLM) if missing
|
||||
return {
|
||||
"game": "word",
|
||||
"variant": variant,
|
||||
"date": date,
|
||||
"length": p["length"],
|
||||
"guesses": p["guesses"],
|
||||
"answer": _b64(p["answer"]),
|
||||
"why": _b64(p.get("why")),
|
||||
}
|
||||
|
||||
|
||||
def generate_daily_puzzles(conn: sqlite3.Connection, date: str, client=None) -> int:
|
||||
"""Cycle hook: pre-generate today's word puzzles (with the LLM 'why')."""
|
||||
made = 0
|
||||
for variant in WORD_VARIANTS:
|
||||
before = conn.execute(
|
||||
"SELECT 1 FROM daily_puzzles WHERE puzzle_date=? AND game='word' AND variant=?", (date, variant)
|
||||
).fetchone()
|
||||
if not before:
|
||||
generate_word_puzzle(conn, date, variant, client=client)
|
||||
made += 1
|
||||
return made
|
||||
Reference in New Issue
Block a user