89c0fbe1f6
The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.
SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
(a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
rep (URL stability) -> quality score, so an accepted page never retires to a
rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).
Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
picker (bundled data, no CDN) for the blurb editor.
Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.
Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
318 lines
13 KiB
Python
318 lines
13 KiB
Python
"""Bloom — the daily word wheel (Center Circle / Wild Bloom).
|
||
|
||
DESIGN and ACCEPTANCE are decoupled:
|
||
|
||
• DESIGN (wheel selection, tiers, pangram, the Full-Bloom target) uses the small
|
||
COMMON list only — deterministic, stored in daily_puzzles, and unaffected by
|
||
curation. Tiers are scored on COMMON so "Flourishing" is always reachable with
|
||
everyday vocabulary, and "Full Bloom" = finding the whole *designed* puzzle
|
||
(the broad bonus words are extra credit beyond it, never required).
|
||
|
||
• ACCEPTANCE is BROAD and DYNAMIC — every valid dictionary word buildable from
|
||
the wheel, computed at RESPONSE TIME as: broad dict ∪ {allow} − {block}, where
|
||
allow/block are runtime admin overrides (bloom_word_overrides). So a missed
|
||
word can be allowed (or a junk word blocked) with NO deploy or regeneration.
|
||
|
||
Accept words never sit in the network response: clients validate against salted
|
||
hashes and compute their own score/tier/pangram from the 7 letters.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import random
|
||
import sqlite3
|
||
from itertools import combinations
|
||
from pathlib import Path
|
||
|
||
_DATA = Path(__file__).parent / "data"
|
||
_W = json.loads((_DATA / "bloom_words.json").read_text())
|
||
ACCEPT: list[str] = _W["accept"] # broad: all valid dictionary words
|
||
_COMMON: set[str] = set(_W["common"]) # tight: design / tiers / pangrams only
|
||
_COMMON_LS: list[tuple[str, frozenset]] = [(w, frozenset(w)) for w in _COMMON]
|
||
_AVOID: set[str] = set(json.loads((_DATA / "bloom_avoid.json").read_text()))
|
||
|
||
# Broad accept words bucketed by distinct-letter set, so the accepted set for a
|
||
# 7-letter wheel is gathered by unioning its ≤127 letter-subsets (fast) — no scan
|
||
# of the whole ~68k list per request.
|
||
_BY_SET: dict[frozenset, list[str]] = {}
|
||
for _w in ACCEPT:
|
||
_BY_SET.setdefault(frozenset(_w), []).append(_w)
|
||
|
||
# Candidate wheels = letter-sets of 7-distinct-letter COMMON words (every wheel
|
||
# has ≥1 recognizable pangram). Sorted for deterministic order.
|
||
_PANGRAM_SETS: dict[frozenset, list[str]] = {}
|
||
for _w in _COMMON:
|
||
_s = frozenset(_w)
|
||
if len(_s) == 7:
|
||
_PANGRAM_SETS.setdefault(_s, []).append(_w)
|
||
_CANDIDATES: list[frozenset] = sorted(_PANGRAM_SETS, key=lambda s: "".join(sorted(s)))
|
||
|
||
MIN_COMMON_WORDS, MAX_COMMON_WORDS = 14, 45
|
||
PANGRAM_BONUS = 7
|
||
# 8 / 30 / 70 — Flourishing at 70% keeps Bloom from becoming a completionist
|
||
# grind. Do NOT raise Flourishing above 0.70 (Codex).
|
||
TIER_PCTS: tuple[tuple[str, float], ...] = (
|
||
("Sprouting", 0.0), ("Budding", 0.08), ("Blooming", 0.30), ("Flourishing", 0.70),
|
||
)
|
||
TOP_TIER_PCT = 0.70
|
||
|
||
|
||
def score_word(word: str) -> int:
|
||
"""4-letter word = 1 point; longer = its length. Pangram bonus added on top."""
|
||
return 1 if len(word) == 4 else len(word)
|
||
|
||
|
||
def score_words(payload: dict, words) -> int:
|
||
"""Score found words for a wheel (pangram = uses all 7 letters). Used for the
|
||
player's running score AND the Full-Bloom check (vs the design's max_score)."""
|
||
letters = frozenset(payload["center"]) | frozenset(payload["outer"])
|
||
total = 0
|
||
for w in words:
|
||
total += score_word(w)
|
||
if frozenset(w) == letters:
|
||
total += PANGRAM_BONUS
|
||
return total
|
||
|
||
|
||
# --- DESIGN: common-only, deterministic, stored --------------------------------
|
||
|
||
def tiers_for(common_max: int) -> list[dict]:
|
||
return [{"name": n, "score": int(p * common_max)} for n, p in TIER_PCTS]
|
||
|
||
|
||
def _design(letters: frozenset, center: str):
|
||
"""Center-mode design from the COMMON list only."""
|
||
commons = [w for (w, s) in _COMMON_LS if center in w and s <= letters]
|
||
pangrams = [w for w in commons if frozenset(w) == letters]
|
||
common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams)
|
||
display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p))
|
||
return commons, display, common_max
|
||
|
||
|
||
def _design_wild(letters: frozenset):
|
||
"""Wild design (no required center) from the COMMON list only."""
|
||
commons = [w for (w, s) in _COMMON_LS if s <= letters]
|
||
pangrams = [w for w in commons if frozenset(w) == letters]
|
||
common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams)
|
||
display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p))
|
||
vowels = [c for c in sorted(letters) if c in "aeiou"]
|
||
return commons, display, common_max, (vowels[0] if vowels else sorted(letters)[0])
|
||
|
||
|
||
def _payload(letters: frozenset, center: str, display, common_max: int) -> dict:
|
||
return {
|
||
"center": center,
|
||
"outer": sorted(letters - {center}),
|
||
"pangram": display[0],
|
||
"tiers": tiers_for(common_max),
|
||
# Full Bloom = finding the whole designed (common) puzzle; broad bonus
|
||
# words push score past this but are never required.
|
||
"max_score": common_max,
|
||
}
|
||
|
||
|
||
def _generate(seed_str: str, fmt: str) -> dict:
|
||
"""Deterministically pick a wheel design for a seed + format."""
|
||
rng = random.Random(int(hashlib.sha256(seed_str.encode()).hexdigest(), 16))
|
||
order = _CANDIDATES[:]
|
||
rng.shuffle(order)
|
||
for letters in order:
|
||
if fmt == "wild":
|
||
commons, display, cmax, center = _design_wild(letters)
|
||
if len(commons) >= MIN_COMMON_WORDS and display:
|
||
return _payload(letters, center, display, cmax)
|
||
else:
|
||
centers = sorted(letters)
|
||
rng.shuffle(centers)
|
||
for center in centers:
|
||
commons, display, cmax = _design(letters, center)
|
||
if MIN_COMMON_WORDS <= len(commons) <= MAX_COMMON_WORDS and display:
|
||
return _payload(letters, center, display, cmax)
|
||
raise RuntimeError("bloom: no valid wheel found") # impossible with the vendored dict
|
||
|
||
|
||
def build_puzzle(date: str) -> dict:
|
||
"""The day's shared Center Circle wheel design (deterministic by date)."""
|
||
return {"date": date, **_generate(f"bloom:{date}", "center")}
|
||
|
||
|
||
def build_free(seed: str, fmt: str = "center") -> dict:
|
||
"""A free-play wheel design (deterministic by seed) — Center Circle or Wild."""
|
||
fmt = "wild" if fmt == "wild" else "center"
|
||
return {"seed": seed, "format": fmt, **_generate(f"free:{fmt}:{seed}", fmt)}
|
||
|
||
|
||
# --- ACCEPTANCE: broad + runtime overrides, computed at response time ----------
|
||
|
||
def overrides(conn: sqlite3.Connection) -> tuple[set, set]:
|
||
allow, block = set(), set()
|
||
for r in conn.execute("SELECT word, action FROM bloom_word_overrides"):
|
||
(allow if r["action"] == "allow" else block).add(r["word"])
|
||
return allow, block
|
||
|
||
|
||
def _broad_words_for(letters: frozenset) -> list[str]:
|
||
"""Every broad-dictionary word buildable from `letters` (distinct-set ⊆ letters)."""
|
||
ls = sorted(letters)
|
||
out = []
|
||
for r in range(1, len(ls) + 1):
|
||
for combo in combinations(ls, r):
|
||
out.extend(_BY_SET.get(frozenset(combo), ()))
|
||
return out
|
||
|
||
|
||
def accepted_words(conn: sqlite3.Connection, center: str, outer, require_center: bool) -> list[str]:
|
||
"""The wheel's accepted set RIGHT NOW: broad words buildable from the letters
|
||
(optionally requiring the center), plus allow-overrides, minus block-overrides."""
|
||
letters = frozenset(outer) | {center}
|
||
allow, block = overrides(conn)
|
||
seen, out = set(), []
|
||
for w in _broad_words_for(letters):
|
||
if w in seen or w in block:
|
||
continue
|
||
if require_center and center not in w:
|
||
continue
|
||
seen.add(w)
|
||
out.append(w)
|
||
for w in allow: # allow words that may not be in the broad dict
|
||
if w in seen or w in block or len(w) < 4 or "s" in w:
|
||
continue
|
||
if not (frozenset(w) <= letters) or (require_center and center not in w):
|
||
continue
|
||
seen.add(w)
|
||
out.append(w)
|
||
return sorted(out)
|
||
|
||
|
||
# --- daily_puzzles storage -----------------------------------------------------
|
||
|
||
def generate_bloom_puzzle(conn: sqlite3.Connection, date: str) -> dict:
|
||
"""Ensure the day's Bloom DESIGN exists in daily_puzzles. Idempotent, pure code."""
|
||
existing = conn.execute(
|
||
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
|
||
).fetchone()
|
||
if existing:
|
||
return json.loads(existing["payload_json"])
|
||
payload = build_puzzle(date)
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'bloom', '', ?)",
|
||
(date, json.dumps(payload)),
|
||
)
|
||
conn.commit()
|
||
row = conn.execute(
|
||
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
|
||
).fetchone()
|
||
return json.loads(row["payload_json"])
|
||
|
||
|
||
def stored_payload(conn: sqlite3.Connection, date: str) -> dict | None:
|
||
"""The day's design IF it already exists — never generates (used by the state
|
||
sanitizer, which must not trigger generation)."""
|
||
row = conn.execute(
|
||
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
|
||
).fetchone()
|
||
return json.loads(row["payload_json"]) if row else None
|
||
|
||
|
||
def word_hash(salt: str, word: str) -> str:
|
||
return hashlib.sha256(f"{salt}:{word}".encode()).hexdigest()
|
||
|
||
|
||
def _response(salt: str, p: dict, words: list[str], extra: dict) -> dict:
|
||
return {
|
||
"game": "bloom",
|
||
"center": p["center"],
|
||
"outer": p["outer"],
|
||
"accepted": [word_hash(salt, w) for w in words], # NO plaintext words leak
|
||
"max_score": p["max_score"], # Full Bloom = designed puzzle
|
||
"tiers": p["tiers"],
|
||
**extra,
|
||
}
|
||
|
||
|
||
def bloom_response(conn: sqlite3.Connection, date: str) -> dict:
|
||
"""Daily Center Circle — accepted set computed live (broad + overrides)."""
|
||
p = generate_bloom_puzzle(conn, date)
|
||
words = accepted_words(conn, p["center"], p["outer"], require_center=True)
|
||
return _response(date, p, words, {"date": date})
|
||
|
||
|
||
def bloom_free_response(conn: sqlite3.Connection, seed: str, fmt: str) -> dict:
|
||
"""Free-play wheel keyed by `seed` (resumable). Accepted set computed live."""
|
||
p = build_free(seed, fmt)
|
||
words = accepted_words(conn, p["center"], p["outer"], require_center=p["format"] != "wild")
|
||
return _response(seed, p, words, {"mode": "free", "format": p["format"], "seed": p["seed"]})
|
||
|
||
|
||
# --- runtime curation: overrides + player reports ------------------------------
|
||
|
||
def set_override(conn: sqlite3.Connection, word: str, action: str, reason: str | None = None,
|
||
by: str | None = None) -> bool:
|
||
word = (word or "").strip().lower()
|
||
if not (word.isalpha() and action in ("allow", "block")):
|
||
return False
|
||
# An ALLOW that violates Bloom's hard rules (≥4 letters, no 'S') could never
|
||
# count — reject it rather than store an inert override. BLOCK stays permissive.
|
||
if action == "allow" and (len(word) < 4 or "s" in word):
|
||
return False
|
||
conn.execute(
|
||
"INSERT INTO bloom_word_overrides (word, action, reason, created_by) VALUES (?,?,?,?) "
|
||
"ON CONFLICT(word) DO UPDATE SET action=excluded.action, reason=excluded.reason, "
|
||
"created_by=excluded.created_by, created_at=CURRENT_TIMESTAMP",
|
||
(word, action, reason, by),
|
||
)
|
||
conn.commit()
|
||
return True
|
||
|
||
|
||
def clear_override(conn: sqlite3.Connection, word: str) -> None:
|
||
conn.execute("DELETE FROM bloom_word_overrides WHERE word=?", ((word or "").strip().lower(),))
|
||
conn.commit()
|
||
|
||
|
||
def list_overrides(conn: sqlite3.Connection) -> list[dict]:
|
||
return [dict(r) for r in conn.execute(
|
||
"SELECT word, action, reason, created_by, created_at FROM bloom_word_overrides ORDER BY created_at DESC")]
|
||
|
||
|
||
def add_report(conn: sqlite3.Connection, word: str, puzzle_date, mode, fmt, letters, reason) -> bool:
|
||
word = (word or "").strip().lower()
|
||
if not (word.isalpha() and 4 <= len(word) <= 24):
|
||
return False
|
||
# Don't pile up duplicate pending reports for the same word.
|
||
dup = conn.execute(
|
||
"SELECT 1 FROM bloom_word_reports WHERE word=? AND status='pending'", (word,)).fetchone()
|
||
if dup:
|
||
return True
|
||
conn.execute(
|
||
"INSERT INTO bloom_word_reports (word, puzzle_date, mode, format, letters, reason) "
|
||
"VALUES (?,?,?,?,?,?)",
|
||
(word, str(puzzle_date or "")[:16], str(mode or "")[:8], str(fmt or "")[:8],
|
||
str(letters or "")[:16], str(reason or "")[:60]),
|
||
)
|
||
conn.commit()
|
||
return True
|
||
|
||
|
||
def list_reports(conn: sqlite3.Connection, status: str = "pending", limit: int = 100) -> list[dict]:
|
||
return [dict(r) for r in conn.execute(
|
||
"SELECT id, word, puzzle_date, mode, format, letters, reason, status, created_at "
|
||
"FROM bloom_word_reports WHERE status=? ORDER BY created_at DESC LIMIT ?", (status, limit))]
|
||
|
||
|
||
def resolve_report(conn: sqlite3.Connection, report_id: int, action: str, by: str | None = None) -> bool:
|
||
"""action: 'approve' (→ allow override) | 'block' (→ block override) | 'dismiss'."""
|
||
status = {"approve": "approved", "block": "blocked", "dismiss": "dismissed"}.get(action)
|
||
row = conn.execute("SELECT word FROM bloom_word_reports WHERE id=?", (report_id,)).fetchone()
|
||
if not row or not status:
|
||
return False
|
||
if action == "approve":
|
||
if not set_override(conn, row["word"], "allow", reason="report", by=by):
|
||
return False # can't allow (hard rule) — leave pending; dismiss instead
|
||
elif action == "block":
|
||
set_override(conn, row["word"], "block", reason="report", by=by)
|
||
conn.execute("UPDATE bloom_word_reports SET status=? WHERE id=?", (status, report_id))
|
||
conn.commit()
|
||
return True
|