shmulc's picture
deploy via make deploy
852d295 verified
Raw
History Blame Contribute Delete
36.3 kB
"""Local server for the Hebrew Codenames AI co-pilot.
The co-pilot (served at `/`) is the product: a human plays either seat and the
assistant coaches it โ€” best clue when you're ืจื‘ ื”ืžืจื’ืœื™ื, best guesses when you're
the ืžื ื—ืฉ โ€” and *shows its reasoning* (the geometry shortlist, which candidate
DictaLM picked and why, the operative-eye reading of the clue, danger flags).
.venv/bin/python app.py # http://127.0.0.1:7860
The bot-vs-bot research game is still reachable at `/game`.
Default engine is **geometry** โ€” pure fastText embeddings + DictaBERT legality, no
generative LLM in the loop (lighter, instant, fully offline). The clue word comes from
a broad noun/adjective/proper-noun frequency band of the vocabulary, and the rationale is derived from
the geometry itself. DictaLM is optional (engines `hybrid`/`llm`) and loads lazily only
when selected. Encoders and the clue vocabulary load lazily on first use.
"""
import os
# Force the hub online: feedback persistence (the CommitScheduler) pushes via huggingface_hub,
# which HF_HUB_OFFLINE=1 would gag. Models never need the hub at runtime โ€” they load from the
# local cache (local_files_only=True in morph), so nothing here reaches out except feedback.
os.environ["HF_HUB_OFFLINE"] = "0"
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
import hashlib
import json
import random
import shutil
import threading
import time
import numpy as np
from flask import Flask, abort, jsonify, request, send_file, send_from_directory
from . import PROJECT_ROOT, morph, probe
app = Flask(__name__)
# Public deploy: embedding-only. No generative LLM is offered (the geometry engine
# is fastText + DictaBERT legality + a NeoDictaBERT second opinion). When set, the
# server ignores any llm/hybrid engine a request might ask for and never advertises
# DictaLM models, so a direct API call can't trip the (uninstalled) LLM path.
EMBED_ONLY = os.environ.get("EMBED_ONLY", "").lower() in ("1", "true", "yes")
# The cross-encoder "second opinion" (NeoDictaBERT) is optional; the lean public deploy
# drops it (SECOND_OPINION=0) so the image needs only fastText + DictaBERT-lex.
SECOND_OPINION = os.environ.get("SECOND_OPINION", "1").lower() not in ("0", "false", "no")
# How daring the spymaster is. Risk = two knobs: how many team words to reach for (m) and
# how hard to avoid enemy/neutral/assassin words (lam_*). Cautious only plays rock-solid
# clues (and refuses more); bold reaches for more words and tolerates a tighter enemy.
RISK_PROFILES = {
# Scoring weights (m, lam_*, safe_margin) validated on the real serve_clue path โ€” bench_clue.py
# plus a neutral LLM-guesser eval. balanced and cautious share conservative coverage (m=2,
# safe_margin=0.05); together with the listener-danger term below this keeps the claimed words to
# what a strong human-proxy guesser recovers and best avoids the assassin. They differ only in
# enemy avoidance (cautious uses higher lam_*); bold reaches furthest (m=4). The count-trim `keep`
# (keep_rel) claims a team word only when its similarity is >= keep ยท the top target's; keep
# ordering stays cautious >= balanced >= bold.
"cautious": dict(m=2, lam_a=3.0, lam_opp=1.3, lam_neu=0.7, keep=0.68, safe_margin=0.05),
"balanced": dict(m=2, lam_a=2.5, lam_opp=0.9, lam_neu=0.6, keep=0.68, safe_margin=0.05),
"bold": dict(m=4, lam_a=1.8, lam_opp=0.7, lam_neu=0.4, keep=0.62, safe_margin=0.0),
}
_CAND_KEYS = ("m", "lam_a", "lam_opp", "lam_neu", "safe_margin")
LAM_F = 0.14 # weight on the mid-frequency (DETECT-FREQ) prior in candidate scoring
# Listener-competition (RSA-style) danger term: penalise clues by the softmax share a literal
# guesser would put on the assassin/opponent words (see probe._listener_danger). One global pair
# across risk profiles; SOFT_TAU is the softmax temperature. LAM_SOFT=0 restores hinge-only scoring.
LAM_SOFT = 1.0
SOFT_TAU = 0.10
# Shortlist diversity (MMR): when picking the browsable options, penalise a candidate by how much
# its target set overlaps ones already chosen, so a tight board pair (e.g. ื—ื•ืœืฆื”/ืžื›ื ืกื™ื™ื) can't fill
# the whole shortlist with near-duplicate clues. 0 restores plain top-n-by-score.
LAM_DIV = 0.5
# Cohesion: a counted word must cohere (cosine >= COH_FLOOR) with the cluster's *head* (strongest)
# word, not merely with the clue โ€” so the number reflects a real cluster, not passengers riding
# along on a clueโ†”word similarity (radioโ†’milk), nor a noise pair chaining to each other
# (foodโ†’{beauty,freedom}). Tuned by a production-path sweep (shortlist + 1-word-demoting ordering)
# over fresh boards + the feedback set: floor 0.20 + head mode sheds noise tails while keeping the
# ๐Ÿ‘ clusters (ื™ืจืšยท3, ืขื“ื›ื•ืŸยท2), ~doubling guesser safety and holding 1-word clues to ~2%. Head mode
# (vs link-to-any) additionally kills noise sub-clusters โ€” e.g. ืžื—ืฉื‘ riding into sports via ืฉื—ืžื˜.
COH_FLOOR, COH_MODE = 0.20, "head"
# Optional, fail-soft feedback: ๐Ÿ‘/๐Ÿ‘Ž on clues. Rows are appended locally and, if a dataset +
# token are configured, mirrored to a private HF Dataset on a schedule. Nothing here can take
# the co-pilot down โ€” every step is wrapped and the app serves regardless.
FEEDBACK_DIR = os.environ.get("FEEDBACK_DIR", "feedback")
FEEDBACK_DATASET = os.environ.get("FEEDBACK_DATASET") # e.g. "shmulc/codenames-feedback"
_FB_SALT = os.environ.get(
"FEEDBACK_SALT", "cn-feedback-v1"
) # salts the IP hash (coarse anti-evasion signal, never the raw IP)
_fb_lock = threading.Lock()
_fb_scheduler = None
def _init_feedback():
"""Start a CommitScheduler that mirrors the local feedback log to a private HF Dataset.
Best-effort: any failure leaves feedback as local-only and the app unaffected."""
global _fb_scheduler
os.makedirs(FEEDBACK_DIR, exist_ok=True)
if FEEDBACK_DATASET and os.environ.get("HF_TOKEN"):
# Space storage is ephemeral: seed the local log from the dataset on boot so the
# scheduler re-commits the full history instead of overwriting it with only new rows.
local_fb = os.path.join(FEEDBACK_DIR, "feedback.jsonl")
if not os.path.exists(local_fb):
try:
from huggingface_hub import hf_hub_download
src = hf_hub_download(
FEEDBACK_DATASET,
"data/feedback.jsonl",
repo_type="dataset",
token=os.environ["HF_TOKEN"],
)
shutil.copyfile(src, local_fb)
app.logger.info("seeded local feedback log from dataset")
except Exception:
app.logger.info("no existing feedback in dataset to seed โ€” starting fresh")
try:
from huggingface_hub import CommitScheduler
_fb_scheduler = CommitScheduler(
repo_id=FEEDBACK_DATASET,
repo_type="dataset",
folder_path=FEEDBACK_DIR,
path_in_repo="data",
every=1,
private=True,
token=os.environ["HF_TOKEN"],
squash_history=True,
)
app.logger.info("feedback mirrored to dataset %s", FEEDBACK_DATASET)
except Exception:
app.logger.exception("feedback scheduler init failed โ€” logging locally only")
@app.errorhandler(Exception)
def on_error(e):
"""Answer the client with JSON so the UI can recover instead of hanging. Routing/HTTP
errors (e.g. a 404 for /favicon.ico) pass through with their own status โ€” no 500, no
traceback noise in the logs."""
from werkzeug.exceptions import HTTPException
if isinstance(e, HTTPException):
return e
app.logger.exception("request failed")
return jsonify(error=f"ืฉื’ื™ืืช ืฉืจืช: {e}"), 500
@app.get("/favicon.ico")
def favicon():
return ("", 204)
MODELS = [
{"id": probe.LLM_FAST, "label": "1.7B (ืžื”ื™ืจ)"},
{"id": probe.LLM_BIG, "label": "12B (ืื™ื›ื•ืชื™)"},
]
ENCODER_KEYS = list(probe.ENCODERS.keys())
GEO_ENC = "blend_0.7_0.3" # Concatenated L2-normalized fastText + Numberbatch blend
XENC = "neodictabert" # cross-engine second opinion for the operative (no LLM)
_llms: dict = {}
_encs: dict = {}
_clue_vocab: dict[str, list[str]] = {}
_clue_freq: dict[str, np.ndarray] = {}
_clue_lemmas: dict[str, list[str]] = {}
_clue_emb: dict[str, dict[str, np.ndarray]] = {} # mode -> encoder -> embedding
_ambiguity_lexicon = None
_DEFAULT_AMBIGUITY = {
"ืขืœื”": {"ambiguity": 0.95, "senses": ["leaf", "rose/ascended"]},
"ืคืจื—": {"ambiguity": 0.95, "senses": ["flower", "flourished/youth"]},
}
def get_llm(mid):
mid = mid or probe.LLM_FAST
if mid not in (
probe.LLM_FAST,
probe.LLM_BIG,
): # never hand an arbitrary client string to mlx_lm.load
abort(400, f"unknown model {mid!r}")
if mid not in _llms:
app.logger.info("loading LLM %s ...", mid)
_llms[mid] = probe.HebrewLLM(mid)
return _llms[mid]
def get_enc(key):
if key not in _encs:
app.logger.info("loading encoder %s ...", key)
_encs[key] = probe.make_encoder(key)
return _encs[key]
def _load_ambiguity_lexicon():
"""Load static ambiguity warnings; never call an external model while serving."""
global _ambiguity_lexicon
if _ambiguity_lexicon is None:
path = os.path.join(probe.DATA, "ambiguity_he_openai.json")
try:
with open(path, encoding="utf-8") as f:
document = json.load(f)
_ambiguity_lexicon = dict(_DEFAULT_AMBIGUITY)
if isinstance(document, dict):
_ambiguity_lexicon.update(document.get("entries", {}))
curated_path = os.path.join(probe.DATA, "clue_vocab_openai.json")
if os.path.exists(curated_path):
with open(curated_path, encoding="utf-8") as f:
curated = json.load(f)
for word, meta in curated.get("entries", {}).items():
flags = meta.get("flags", [])
if "ambiguous" in flags or "translation_sensitive" in flags:
_ambiguity_lexicon[word] = {
"ambiguity": float(meta.get("ambiguity") or 0.7),
"senses": [],
"flags": flags,
}
except (FileNotFoundError, OSError, json.JSONDecodeError):
_ambiguity_lexicon = dict(_DEFAULT_AMBIGUITY)
return _ambiguity_lexicon
def geo_assets(mode: str = "curated"):
"""(vocab, embedding, lemmas, freq_scores) for the geometry spymaster โ€” a mid-frequency
noun/adjective/etc. band of the clue vocabulary, embedded and FREQ-scored once. The vocab is
lemmatised so legality catches prefixed forms (e.g. ื‘ืกื™ืจ โ†’ ืกื™ืจ) that share a board lemma."""
global _clue_vocab, _clue_freq, _clue_lemmas, _clue_emb
if mode not in _clue_vocab:
# Load filtered vocab according to the mode
if mode == "curated":
curated_vocab = os.path.join(probe.DATA, "clue_vocab_openai.json")
if os.path.exists(curated_vocab):
with open(curated_vocab, encoding="utf-8") as f:
saved = json.load(f)
rows = [
[word, meta["count"], meta["pos"]] for word, meta in saved["entries"].items()
]
vocab = [row[0] for row in rows]
counts = np.asarray([row[1] for row in rows], dtype=np.float32)
freq = probe.freq_scores(counts, lo=500, hi=50000)
else:
# Fallback to broad if curated file is missing
vocab, counts = probe.clue_vocab_band(20000, mode="broad")
freq = probe.freq_scores(counts, lo=500, hi=50000)
else:
vocab, counts = probe.clue_vocab_band(20000, mode=mode)
# Calculate freq scores using adjusted floors for the mode
if mode == "conservative":
freq = probe.freq_scores(counts, lo=1500, hi=40000)
elif mode == "broad":
freq = probe.freq_scores(counts, lo=500, hi=50000)
elif mode == "experimental":
freq = probe.freq_scores(counts, lo=200, hi=75000)
else:
freq = probe.freq_scores(counts, lo=1000, hi=80000)
_clue_vocab[mode] = vocab
_clue_lemmas[mode] = list(vocab)
_clue_freq[mode] = freq
app.logger.info("clue vocab mode %s: %d words", mode, len(vocab))
if mode not in _clue_emb:
_clue_emb[mode] = {}
if GEO_ENC not in _clue_emb[mode]:
_clue_emb[mode][GEO_ENC] = get_enc(GEO_ENC).embed(_clue_vocab[mode])
return _clue_vocab[mode], _clue_emb[mode][GEO_ENC], _clue_lemmas[mode], _clue_freq[mode]
def _geo_reason(intended, board: probe.Board, read) -> str:
"""A rationale derived from the geometry itself (no LLM): what the clue connects and
the nearest non-team word it risks."""
conn = " ยท ".join(intended) if intended else "โ€”"
danger = next((r["word"] for r in read if r["role"] != "my"), None)
txt = f"ื”ื›ื™ ืงืจื•ื‘ ืœืžื™ืœื™ื {conn}"
if board.assassin:
txt += f", ื•ืžืจื•ื—ืง ืžื”ืžืชื ืงืฉ ({board.assassin})"
if danger:
txt += f". ื”ืกื›ื ื” ื”ืงืจื•ื‘ื” ื‘ื™ื•ืชืจ: {danger}"
return txt
_VALID_ROLES = {"my", "opp", "neutral", "assassin"}
def board_from(j) -> probe.Board:
words = list(j.get("words") or [])
if not words:
abort(400, "board has no words")
roles = j.get("roles") or {w: "neutral" for w in words}
role = {}
for w in words:
r = roles.get(w, "neutral")
if r not in _VALID_ROLES: # reject unknown roles: a typo must not silently
abort(400, f"invalid role {r!r} for word {w!r}") # hide a word from the safety terms
role[w] = r
return probe.Board(words=words, role=role)
def _conf(sims: dict) -> dict:
"""Min-max normalise a {word: cosine} map to a 0..1 confidence for bars."""
vals = list(sims.values())
lo, hi = min(vals), max(vals)
span = (hi - lo) or 1.0
return {w: (s - lo) / span for w, s in sims.items()}
def _read_clue(board: probe.Board, clue: str):
"""How the geometry reads a clue over the 25 board words: ordered words with
role + cosine + confidence. The operative-eye view that powers the danger panel."""
order, sims = probe.encoder_rank(get_enc(GEO_ENC), board, clue)
conf = _conf(sims)
return [
{
"word": w,
"role": board.role.get(w, "neutral"),
"sim": round(sims[w], 4),
"conf": round(conf[w], 4),
}
for w in order
]
def _whiten_abtt(X: np.ndarray, k: int = 3) -> np.ndarray:
"""All-but-the-top (Mu & Viswanath 2018): mean-center, remove the top-k principal
directions, re-normalise. On fastText the leading components track a frequency/length
cone shared by all words; stripping them lets the map show *semantic* spread instead
(see project memory: latent-space-anisotropy-whitening)."""
mu = X.mean(0, keepdims=True)
Xc = X - mu
k = min(k, min(Xc.shape) - 1)
if k > 0:
_, _, Vt = np.linalg.svd(Xc, full_matrices=False)
comps = Vt[:k] # (k, d) leading directions
Xc = Xc - (Xc @ comps.T) @ comps # project them out
Xc /= np.linalg.norm(Xc, axis=1, keepdims=True) + 1e-9
return Xc
def _classical_mds(D: np.ndarray, dim: int = 2) -> np.ndarray:
"""Classical (Torgerson) MDS in pure numpy: double-centre the squared-distance matrix
B = -0.5 ยท JยทDยฒยทJ and take the top-`dim` eigenvectors scaled by โˆšeigenvalue."""
n = D.shape[0]
J = np.eye(n) - np.ones((n, n)) / n
B = -0.5 * J @ (D**2) @ J
w, V = np.linalg.eigh((B + B.T) / 2) # symmetric โ†’ real eigenpairs
idx = np.argsort(-w)[:dim]
L = np.sqrt(np.clip(w[idx], 0.0, None))
return V[:, idx] * L
# --------------------------------------------------------------------------- #
# Pages
# --------------------------------------------------------------------------- #
# Served assets live at the project root (see codenames/__init__.py), alongside data/ โ€” the
# webapp build and the static HTML โ€” resolved absolutely so serving is cwd-independent.
WEBAPP = os.path.join(PROJECT_ROOT, "webapp")
_COPILOT_HTML = os.path.join(PROJECT_ROOT, "copilot.html")
_METHODS_HTML = os.path.join(PROJECT_ROOT, "methods.html")
_GAME_HTML = os.path.join(PROJECT_ROOT, "codenames_latent_space.html")
@app.get("/")
def index():
spa = os.path.join(WEBAPP, "index.html")
return send_file(spa) if os.path.exists(spa) else send_file(_COPILOT_HTML)
@app.get("/assets/<path:asset>")
def spa_assets(asset):
return send_from_directory(os.path.join(WEBAPP, "assets"), asset)
@app.get("/classic")
def classic():
return send_file(_COPILOT_HTML)
@app.get("/methods")
def methods():
return send_file(_METHODS_HTML)
@app.get("/game")
def game():
if not os.path.exists(_GAME_HTML):
return ("ื”ื“ืฃ ื”ื–ื” ืื™ื ื• ื–ืžื™ืŸ ื‘ื’ืจืกื” ื”ืฆื™ื‘ื•ืจื™ืช.", 404)
return send_file(_GAME_HTML)
@app.get("/api/health")
def health():
return jsonify(
ok=True, models=([] if EMBED_ONLY else MODELS), encoders=ENCODER_KEYS, geo=GEO_ENC
)
@app.get("/api/deal")
def deal():
b = probe.sample_board(random.Random())
return jsonify(words=b.words, roles=b.role)
@app.post("/api/space")
def space():
"""2D latent-space coordinates for the board (+ optional clue) โ€” the picture behind the
method: a good clue lands at the centre of your words and far from the rest.
Embeds words and clue together with the geometry encoder (fastText), optionally strips the
dominant frequency/length cone with all-but-top-k whitening, then projects the cosine-distance
matrix to 2D with classical MDS (numpy only). Read-only; no engine state touched."""
j = request.get_json(force=True)
board = board_from(j)
vocab_mode = j.get("vocab_mode") or j.get("mode") or "curated"
if vocab_mode not in ("conservative", "broad", "experimental", "curated"):
vocab_mode = "curated"
clue = (j.get("clue") or "").strip() or None
whiten = j.get("whiten", True)
points = list(board.words) + ([clue] if clue else [])
X = get_enc(GEO_ENC).embed(points)
if whiten:
X = _whiten_abtt(X, k=3)
sims = np.clip(X @ X.T, -1.0, 1.0)
D = 1.0 - sims # cosine distance
Y = _classical_mds(D, dim=2)
# normalise into a tidy [-1, 1] box so the client can scale to any canvas.
# scale by a high percentile (not the max) so a couple of far outliers don't crush the
# whole cloud into a tiny central blob; the few points beyond are clipped to the edge.
scale = float(np.percentile(np.abs(Y), 90)) or float(np.abs(Y).max()) or 1.0
Y = np.clip(Y / scale, -1.0, 1.0)
coords = {
w: [round(float(Y[i, 0]), 4), round(float(Y[i, 1]), 4)] for i, w in enumerate(board.words)
}
clue_xy = [round(float(Y[-1, 0]), 4), round(float(Y[-1, 1]), 4)] if clue else None
return jsonify(coords=coords, roles=board.role, clue=clue, clue_xy=clue_xy)
# --------------------------------------------------------------------------- #
# Co-pilot
# --------------------------------------------------------------------------- #
def _analyze_clue(
board: probe.Board,
word: str,
targets,
count,
score,
focus,
reason: str = "",
keep_rel: float = 0.66,
max_count: int | None = None,
) -> dict:
"""Full operative-eye analysis of one candidate clue: how the board reads, the *safe run*
(team words a guesser reaches before any enemy), what it leaks, assassin proximity, a
geometry rationale, and an honest no-clue verdict. Each entry in the spymaster `options`
carries this so the UI can browse alternatives instantly without another round-trip.
`targets` are the words the candidate was optimised for (focus / best-m); leak & risk are
judged against them. The *recommended* number and the lit-up words, though, are the full
safe run, capped by `max_count` when a risk profile sets a maximum claim."""
read = _read_clue(board, word)
target_sims = [r["sim"] for r in read if r["word"] in targets]
floor = min(target_sims) if target_sims else -1.0
leak = [r for r in read if r["role"] != "my" and r["sim"] >= floor]
aw = board.assassin
arank = next((i for i, r in enumerate(read) if r["word"] == aw), -1)
asim = next((r["sim"] for r in read if r["word"] == aw), None)
# safe run = the team words the guesser reaches before any non-team word
safe_words = []
for r in read:
if r["role"] == "my":
safe_words.append(r["word"])
else:
break
safe = len(safe_words)
ROLE_HE = {"opp": "ืฉืœ ื”ื™ืจื™ื‘", "neutral": "ื ื™ื˜ืจืœื™", "assassin": "ื”ืžืชื ืงืฉ"}
# Honest verdict: refuse outright (no_clue) when nothing safe connects the team, or
# flag a clue as risky (leaky) when an enemy word ranks among/above your targets.
no_clue, risky, note = False, False, ""
ambiguity = _load_ambiguity_lexicon().get(word, {})
ambiguity_score = float(ambiguity.get("ambiguity", 0.0) or 0.0)
ambiguity_warning = ambiguity_score >= 0.7
if ambiguity_warning:
risky = True
senses = ", ".join(str(s) for s in ambiguity.get("senses", [])[:3])
flags = ", ".join(str(s) for s in ambiguity.get("flags", [])[:3])
detail = senses or flags or "multiple plausible readings"
note = f"โš  ืจืžื– ื“ื•ึพืžืฉืžืขื™/ืชืœื•ื™ึพืชืจื’ื•ื: {word} โ€” {detail}"
if read and read[0]["role"] != "my":
no_clue = True
note = f"ื”ืžื™ืœื” ื”ื›ื™ ืงืจื•ื‘ื” ืœืจืžื– ื”ื™ื '{read[0]['word']}' โ€” ืœื ืฉืœืš. ืื™ืŸ ืžื™ืœื” ืฉืžืงืฉืจืช ืืช ื”ืฆื•ื•ืช ืฉืœืš ื‘ืœื™ ืœืกื›ืŸ ืžื™ืœื” ื–ืจื”."
elif asim is not None and asim >= floor:
no_clue = True
note = f"ื›ืœ ืจืžื– ืฉืžืงืจื‘ ืืช ื”ืžื™ืœื™ื ืฉืœืš ืžืงืจื‘ ื’ื ืืช ื”ืžืชื ืงืฉ ({aw}). ืžืกื•ื›ืŸ ืžื“ื™."
elif safe < 2 and not focus:
no_clue = True
note = "ืœื ื ืžืฆืื” ืžื™ืœื” ืื—ืช ืฉืžื—ื‘ืจืช ื‘ื™ืŸ ืฉืชื™ื™ื ืื• ื™ื•ืชืจ ืžืžื™ืœื•ืช ื”ืฆื•ื•ืช ืฉืœืš. ื ืกื” ืœื‘ื—ื•ืจ ื™ืขื“ื™ื ืื—ืจื™ื ืื• ืœื—ืœืง ืœืชื•ืจื•ืช."
elif leak:
risky = True
e = leak[0]
note = (
f"โš  ื–ื”ื™ืจื•ืช: '{e['word']}' ({ROLE_HE.get(e['role'], 'ื–ืจื”')}) ืงืจื•ื‘ื” ืœืจืžื– ื›ืžืขื˜ "
f"ื›ืžื• ื”ืžื™ืœื™ื ืฉืœืš โ€” ืžื ื—ืฉ ืขืœื•ืœ ืœื‘ื—ื•ืจ ื‘ื”. ื‘ื˜ื•ื— ืœ-{safe} ื‘ืœื‘ื“."
)
focusset = set(focus or [])
disp_intended = []
if not no_clue:
encoder = get_enc(GEO_ENC)
cliff_factor = 0.4 if getattr(encoder, "model_id", "").startswith("blend_") else 0.5
coh_floor = 0.15 if getattr(encoder, "model_id", "").startswith("blend_") else COH_FLOOR
disp_intended = probe.served_count(
read,
keep_rel=keep_rel,
pin=focusset,
enc=encoder,
cohesion_floor=coh_floor,
cohesion_mode=COH_MODE,
cliff=cliff_factor,
)
if max_count is not None:
disp_intended = disp_intended[:max_count]
disp_count = len(disp_intended)
reason = reason or _geo_reason(disp_intended or targets, board, read)
return {
"word": word,
"count": disp_count,
"intended": disp_intended,
"score": score,
"reason": reason,
"read": read,
"leak": leak,
"safe": safe,
"assassin": {"word": aw, "rank": arank, "sim": asim},
"ambiguity": {
"score": ambiguity_score,
"senses": ambiguity.get("senses", []),
"flags": ambiguity.get("flags", []),
},
"no_clue": no_clue,
"risky": risky,
"note": note,
}
def _risk_order(options: list[dict], risk: str) -> list[int]:
"""Order analyzed geometry options by the risk *policy* (not just the scoring weight):
refuse-clues always sink last; a single-word clue is a last resort, so any multi-word clue
outranks it; bold then maximises coverage (count) then safety; cautious and balanced put
safety first, then coverage. Returns option indices best-first."""
def single(i: int) -> int:
return 1 if options[i]["count"] <= 1 else 0
def key(i: int):
if risk == "bold":
# bold maximises coverage (count) then safety
return (
1 if options[i]["no_clue"] else 0,
single(i),
-options[i]["count"],
-options[i]["safe"],
-options[i]["score"],
)
# cautious/balanced put safety first, then coverage
return (
1 if options[i]["no_clue"] else 0,
single(i),
1 if options[i]["risky"] else 0,
-options[i]["safe"],
-options[i]["count"],
-options[i]["score"],
)
return sorted(range(len(options)), key=key)
def serve_clue(
board: probe.Board,
risk: str = "balanced",
focus=None,
profile=None,
vocab_mode: str = "curated",
):
"""The geometry engine's clue options for a board, ordered exactly as
/api/coach/spymaster serves them (best first). Pure โ€” no request context โ€” so the
endpoint, the benchmarks, and tests all measure the identical served clue.
`profile` overrides RISK_PROFILES[risk] (for tuning); `risk` still selects the ordering
policy. Returns (options, shortlist) where options[0] is the recommended clue."""
prof = profile or RISK_PROFILES[risk]
focus = [w for w in (focus or []) if w in board.my] or None
vocab, emb, lems, freq = geo_assets(vocab_mode)
cands = probe.encoder_clue_candidates(
get_enc(GEO_ENC),
board,
vocab,
emb,
vocab_lemmas=lems,
vocab_freq=freq,
lam_f=LAM_F,
lam_soft=LAM_SOFT,
soft_tau=SOFT_TAU,
lam_div=LAM_DIV,
n=10,
targets=focus,
**{k: prof[k] for k in _CAND_KEYS},
)
options = [
_analyze_clue(
board,
c["word"],
c["intended"],
c["count"],
c["score"],
focus,
keep_rel=prof["keep"],
max_count=prof["m"],
)
for c in cands
]
order = _risk_order(options, risk)
return [options[i] for i in order], [cands[i] for i in order]
@app.post("/api/coach/spymaster")
def coach_spymaster():
"""Best clue for the marked board + a browsable shortlist, each with its own reasoning.
`options` is the list the UI cycles through (a "next option" button); `picked` is the
one to show first. The top-level fields mirror `options[picked]` for convenience."""
j = request.get_json(force=True)
board = board_from(j)
vocab_mode = j.get("vocab_mode") or j.get("mode") or "curated"
if vocab_mode not in ("conservative", "broad", "experimental", "curated"):
vocab_mode = "curated"
if not board.my:
abort(400, "board has no team (my) words")
engine = "geometry" if EMBED_ONLY else (j.get("engine") or "geometry")
mid = j.get("model")
focus = [
w for w in (j.get("focus") or []) if w in board.my
] or None # optional target subset (team only)
risk = j.get("risk") if j.get("risk") in RISK_PROFILES else "balanced"
prof = RISK_PROFILES[risk]
cand_kw = {k: prof[k] for k in _CAND_KEYS}
keep_rel = prof["keep"]
shortlist, picked = [], 0
if engine == "llm":
clue = probe.llm_spymaster(get_llm(mid), board)
if not clue or probe.llm_root_conflicts(get_llm(mid), [clue.word], board.words):
return jsonify(error="DictaLM ืœื ื”ืฆืœื™ื— ืœื”ื—ื–ื™ืจ ืจืžื– ื—ื•ืงื™, ื ืกื” ืฉื•ื‘ ืื• ืขื‘ื•ืจ ืœื’ืื•ืžื˜ืจื™ื”")
options = [
_analyze_clue(
board,
clue.word,
clue.intended,
clue.count,
clue.margin,
focus,
reason=clue.reason,
keep_rel=keep_rel,
max_count=prof["m"],
)
]
elif engine == "hybrid": # geometry proposes a legal shortlist, DictaLM gates + picks first
vocab, emb, lems, freq = geo_assets(vocab_mode)
cands = probe.encoder_clue_candidates(
get_enc(GEO_ENC),
board,
vocab,
emb,
vocab_lemmas=lems,
vocab_freq=freq,
lam_f=LAM_F,
lam_soft=LAM_SOFT,
soft_tau=SOFT_TAU,
lam_div=LAM_DIV,
n=10,
targets=focus,
**cand_kw,
)
bad = probe.llm_root_conflicts(get_llm(mid), [c["word"] for c in cands], board.words)
cands = [c for c in cands if c["word"] not in bad] or cands # keep >=1
shortlist = cands
chosen = probe.llm_pick_clue(get_llm(mid), board, cands)
picked = next((i for i, c in enumerate(cands) if c["word"] == chosen.word), 0)
options = [
_analyze_clue(
board,
c["word"],
c["intended"],
c["count"],
c["score"],
focus,
keep_rel=keep_rel,
max_count=prof["m"],
)
for c in cands
]
else: # geometry: the same ordered options serve_clue / the benchmark use
options, shortlist = serve_clue(board, risk, focus, vocab_mode=vocab_mode)
options, shortlist = options[:10], shortlist[:10]
if not options:
return jsonify(error="ืœื ื ืžืฆื ืจืžื– ื—ื•ืงื™ ืœืœื•ื— ื”ื–ื”", no_clue=True, options=[]), 200
top = options[picked]
return jsonify(
engine=engine,
options=options,
picked=picked,
shortlist=shortlist,
clue=top["word"],
count=top["count"],
intended=top["intended"],
reason=top["reason"],
read=top["read"],
leak=top["leak"],
assassin=top["assassin"],
no_clue=top["no_clue"],
risky=top["risky"],
safe=top["safe"],
note=top["note"],
)
@app.post("/api/coach/check")
def coach_check():
"""Evaluate a clue the human is considering: which words it lights up, how long
the safe run is, the danger words, and assassin proximity. 'Test before you play.'"""
j = request.get_json(force=True)
board = board_from(j)
vocab_mode = j.get("vocab_mode") or j.get("mode") or "curated"
if vocab_mode not in ("conservative", "broad", "experimental", "curated"):
vocab_mode = "curated"
clue = j["clue"].strip()
# Legality (offline, no LLM): a clue is illegal if it is a board word / an inflection of one
# (DictaBERT lemma), or shares a root (Wiktionary lexicon) with a board word it is transparent
# to (fastText cosine). The optional DictaLM root-judge adds extra coverage on opt-in.
illegal = probe.shares_lemma(clue, board, enc=get_enc(GEO_ENC))
if (
not illegal and j.get("use_llm") and not EMBED_ONLY
): # embed-only deploy never touches the LLM
illegal = bool(probe.llm_root_conflicts(get_llm(j.get("model")), [clue], board.words))
read = _read_clue(board, clue)
safe = 0 # team words from the top before any non-team word
for r in read:
if r["role"] == "my":
safe += 1
else:
break
first_danger = next((r for r in read if r["role"] != "my"), None)
assassin_word = board.assassin
arank = next((i for i, r in enumerate(read) if r["word"] == assassin_word), -1)
return jsonify(
clue=clue,
illegal=illegal,
read=read,
safe=safe,
first_danger=first_danger,
assassin={"word": assassin_word, "rank": arank},
)
@app.post("/api/coach/operative")
def coach_operative():
"""Best guesses for a clue + count, with confidence and a geometry second opinion."""
j = request.get_json(force=True)
board = board_from(j)
vocab_mode = j.get("vocab_mode") or j.get("mode") or "curated"
if vocab_mode not in ("conservative", "broad", "experimental", "curated"):
vocab_mode = "curated"
clue = j["clue"].strip()
count = max(1, min(9, int(j.get("count") or 1)))
engine = "geometry" if EMBED_ONLY else (j.get("engine") or "geometry")
mid = j.get("model")
_, geo_sims = probe.encoder_rank(get_enc(GEO_ENC), board, clue)
geo_conf = _conf(geo_sims)
geo_order = sorted(board.words, key=lambda w: -geo_sims[w])
agree, agree_with = None, None
if engine == "geometry":
order = geo_order
if SECOND_OPINION:
try: # honest second opinion: an independent encoder (no LLM)
_, x_sims = probe.encoder_rank(get_enc(XENC), board, clue)
x_order = sorted(board.words, key=lambda w: -x_sims[w])
agree = len(set(order[:count]) & set(x_order[:count]))
agree_with = "NeoDictaBERT"
except Exception:
app.logger.exception("cross-encoder second opinion failed")
else:
order = probe.llm_guess_ranking(get_llm(mid), board, clue)
agree = len(set(order[:count]) & set(geo_order[:count]))
agree_with = "ื’ืื•ืžื˜ืจื™ื”"
ranking = [
{"word": w, "sim": round(geo_sims[w], 4), "conf": round(geo_conf[w], 4), "rank": i}
for i, w in enumerate(order)
]
picks = order[:count]
return jsonify(
engine=engine,
clue=clue,
count=count,
ranking=ranking,
picks=picks,
geo_order=geo_order,
agreement=agree,
agree_with=agree_with,
)
@app.post("/api/feedback")
def feedback():
"""Record a ๐Ÿ‘/๐Ÿ‘Ž (and optional comment) on a clue. Stores the full board + clue option so
every row is reproducible/debuggable, plus an anonymous client id and a salted IP hash for
spam cleanup. Append-only; never fails the caller."""
j = request.get_json(force=True, silent=True) or {}
xff = request.headers.get("X-Forwarded-For", "") or (request.remote_addr or "")
ip = xff.split(",")[0].strip()
ipsig = hashlib.sha256((_FB_SALT + ip).encode()).hexdigest()[:12] if ip else ""
row = {
"ts": round(time.time(), 1),
"uid": (j.get("uid") or "")[:64],
"ipsig": ipsig,
"verdict": j.get("verdict"),
"comment": (j.get("comment") or "")[:500],
"mode": j.get("mode"),
"risk": j.get("risk"),
"side": j.get("side"),
"clue": j.get("clue"),
"count": j.get("count"),
"intended": j.get("intended"),
"focus": j.get("focus"), # targets the user pinned โ€” needed to reproduce the clue
"why": (j.get("why") or "")[
:40
], # structured ๐Ÿ‘Ž reason tag (opposite/vague/wrong/risky/overreach)
"board": j.get("board"), # {words, roles} โ€” the full board + colors
"revealed": j.get("revealed"), # cards already flipped (excluded from the engine board)
"option": j.get("option"),
} # full clue option: reason, leak, assassin, score, readโ€ฆ
try:
with (
_fb_lock,
open(os.path.join(FEEDBACK_DIR, "feedback.jsonl"), "a", encoding="utf-8") as f,
):
f.write(json.dumps(row, ensure_ascii=False) + "\n")
except Exception:
app.logger.exception("feedback write failed")
return jsonify(ok=True)
if __name__ == "__main__":
_init_feedback()
if os.environ.get("WARMUP", "").lower() in ("1", "true", "yes"):
app.logger.info("warming up geometry assets ...")
geo_assets("curated") # load fastText + embed the clue vocab before serving
morph.lemmas(["ืžื™ืœื”"]) # preload DictaBERT-lex (legality) so the first clue isn't slow
host = os.environ.get("HOST", "127.0.0.1")
port = int(os.environ.get("PORT", "7860"))
app.run(host=host, port=port, debug=False, threaded=True)