Spaces:
Running
Running
File size: 36,292 Bytes
852d295 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 | """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)
|