Spaces:
Running
Running
| """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") | |
| 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 | |
| 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") | |
| def index(): | |
| spa = os.path.join(WEBAPP, "index.html") | |
| return send_file(spa) if os.path.exists(spa) else send_file(_COPILOT_HTML) | |
| def spa_assets(asset): | |
| return send_from_directory(os.path.join(WEBAPP, "assets"), asset) | |
| def classic(): | |
| return send_file(_COPILOT_HTML) | |
| def methods(): | |
| return send_file(_METHODS_HTML) | |
| def game(): | |
| if not os.path.exists(_GAME_HTML): | |
| return ("ืืืฃ ืืื ืืื ื ืืืื ืืืจืกื ืืฆืืืืจืืช.", 404) | |
| return send_file(_GAME_HTML) | |
| def health(): | |
| return jsonify( | |
| ok=True, models=([] if EMBED_ONLY else MODELS), encoders=ENCODER_KEYS, geo=GEO_ENC | |
| ) | |
| def deal(): | |
| b = probe.sample_board(random.Random()) | |
| return jsonify(words=b.words, roles=b.role) | |
| 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] | |
| 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"], | |
| ) | |
| 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}, | |
| ) | |
| 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, | |
| ) | |
| 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) | |