Spaces:
Sleeping
Sleeping
| """Etymology Redactle: guess modern EN/ES ends from a shared non-proto ancestor.""" | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import re | |
| import unicodedata | |
| from pathlib import Path | |
| from backend.atlas import Atlas, _bridge_label | |
| from backend.define import lookup_definitions | |
| ROOT = Path(__file__).resolve().parents[1] | |
| DECK_PATH = ROOT / "data" / "game" / "en_es_pairs.json" | |
| # Visible by default in glosses / redacted prose (Redactle-style crumbs). | |
| STOPWORDS = frozenset( | |
| """ | |
| a an the and or but of to in on for from by with as at into onto over | |
| under between among through during before after about against without | |
| within is are was were be been being am do does did done have has had | |
| having will would can could should may might must shall not no nor | |
| this that these those it its itself he she they them their we us our | |
| you your i my me which who whom whose what when where why how | |
| also than then so if than thus yet both either neither each every | |
| some any all most more less few other another such same own | |
| de del la el los las un una y o en por con sin para que | |
| """.split() | |
| ) | |
| TOKEN_RE = re.compile( | |
| r"[A-Za-zÀ-ÿĀ-žɑ-ʸʰʷ\*]+(?:['\u2019][A-Za-zÀ-ÿ]+)?|[0-9]+|[^\s]", | |
| re.UNICODE, | |
| ) | |
| SEED_PAIRS: list[dict] = [ | |
| {"en": "family", "es": "familia", "source": "seed"}, | |
| {"en": "nation", "es": "nación", "source": "seed"}, | |
| {"en": "name", "es": "nombre", "source": "seed"}, | |
| {"en": "star", "es": "estrella", "source": "seed"}, | |
| {"en": "school", "es": "escuela", "source": "seed"}, | |
| {"en": "music", "es": "música", "source": "seed"}, | |
| {"en": "animal", "es": "animal", "source": "seed"}, | |
| {"en": "hospital", "es": "hospital", "source": "seed"}, | |
| {"en": "nature", "es": "naturaleza", "source": "seed"}, | |
| {"en": "important", "es": "importante", "source": "seed"}, | |
| {"en": "private", "es": "privado", "source": "seed"}, | |
| {"en": "tradition", "es": "tradición", "source": "seed"}, | |
| {"en": "enormous", "es": "enorme", "source": "seed"}, | |
| {"en": "convention", "es": "convención", "source": "seed"}, | |
| ] | |
| def _load_deck() -> list[dict]: | |
| pairs = list(SEED_PAIRS) | |
| if DECK_PATH.exists(): | |
| try: | |
| data = json.loads(DECK_PATH.read_text()) | |
| for row in data.get("pairs") or []: | |
| en = (row.get("en") or "").strip() | |
| es = (row.get("es") or "").strip() | |
| if en and es: | |
| pairs.append({"en": en, "es": es, "source": row.get("source") or "deck"}) | |
| except (json.JSONDecodeError, OSError): | |
| pass | |
| seen: set[tuple[str, str]] = set() | |
| out: list[dict] = [] | |
| for p in pairs: | |
| key = (p["en"].casefold(), p["es"].casefold()) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| out.append(p) | |
| return out | |
| def _is_proto_lang(lang: str) -> bool: | |
| lk = (lang or "").casefold().strip() | |
| if not lk: | |
| return False | |
| if lk.startswith("proto") or lk.startswith("pre-proto"): | |
| return True | |
| if "proto-" in lk or lk.startswith("pie"): | |
| return True | |
| # Reconstructed stages we treat like proto for this game. | |
| if lk in {"indo-european", "common germanic", "common slavic"}: | |
| return True | |
| return False | |
| def _norm_key(text: str) -> str: | |
| """Casefold + strip combining marks for fuzzy guess matching.""" | |
| s = unicodedata.normalize("NFKD", (text or "").strip()) | |
| s = "".join(ch for ch in s if not unicodedata.combining(ch)) | |
| return s.casefold() | |
| def _glosses(atlas: Atlas, term: str, lang: str, *, limit: int = 3) -> list[str]: | |
| assert atlas.conn is not None | |
| payload = lookup_definitions(atlas.conn, term=term, lang=lang, lang_meta=atlas.lang_meta) | |
| out: list[str] = [] | |
| for sense in payload.get("senses") or []: | |
| for g in sense.get("glosses") or []: | |
| g = (g or "").strip() | |
| if g and g not in out: | |
| out.append(g) | |
| if len(out) >= limit: | |
| return out | |
| return out | |
| def _display_lang(atlas: Atlas, lang: str) -> str: | |
| meta = atlas.lang_meta.get(lang) or {} | |
| return meta.get("display") or (lang or "").replace("-", " ").title() | |
| def _pack_node(atlas: Atlas, n: dict, *, gloss_limit: int = 2) -> dict: | |
| term = n["term"] | |
| lang = n["lang"] | |
| gloss_list = _glosses(atlas, term, lang, limit=gloss_limit) | |
| return { | |
| "term": term, | |
| "lang": lang, | |
| "lang_display": n.get("lang_display") or _display_lang(atlas, lang), | |
| "gloss": gloss_list[0] if gloss_list else "", | |
| "glosses": gloss_list, | |
| } | |
| def _round_id(en: str, es: str) -> str: | |
| return hashlib.sha1(f"{en}\0{es}".encode()).hexdigest()[:12] | |
| def _default_visible_token(token: str) -> bool: | |
| """Tokens shown before any guesses (structure crumbs, not solutions).""" | |
| if not token: | |
| return True | |
| if len(token) == 1 and not token.isalpha(): | |
| return True # punctuation | |
| key = _norm_key(token) | |
| if not key: | |
| return True | |
| if key in STOPWORDS: | |
| return True | |
| if key.isdigit(): | |
| return True | |
| # Tiny connectors stay visible; real lemmas stay hidden. | |
| if len(key) <= 1: | |
| return True | |
| return False | |
| def tokenize_text(text: str) -> list[dict]: | |
| """Split prose into Redactle tokens with default visibility.""" | |
| parts: list[dict] = [] | |
| for m in TOKEN_RE.finditer(text or ""): | |
| raw = m.group(0) | |
| if raw.isspace(): | |
| continue | |
| wordish = bool(re.search(r"[A-Za-zÀ-ÿĀ-žɑ-ʸ]", raw)) | |
| parts.append( | |
| { | |
| "t": raw, | |
| "k": _norm_key(raw) if wordish else "", | |
| "word": wordish, | |
| "open": (not wordish) or _default_visible_token(raw), | |
| } | |
| ) | |
| return parts | |
| def _path_root_to_end(modern_to_lca: list[dict]) -> list[dict]: | |
| return list(reversed(modern_to_lca or [])) | |
| def build_round(atlas: Atlas, en: str, es: str, *, source: str = "custom") -> dict: | |
| related = atlas.relate(en, "english", es, "spanish", max_depth=12) | |
| if not related.get("ok"): | |
| return {"ok": False, "error": related.get("error") or "unrelated", "en": en, "es": es} | |
| lca = related["lca"] | |
| if _is_proto_lang(lca.get("lang") or ""): | |
| return {"ok": False, "error": "proto_ancestor", "en": en, "es": es} | |
| path_a = related["path_a"] # modern EN → LCA | |
| path_b = related["path_b"] # modern ES → LCA | |
| root_to_a = _path_root_to_end(path_a) | |
| root_to_b = _path_root_to_end(path_b) | |
| if len(root_to_a) < 2 or len(root_to_b) < 2: | |
| return {"ok": False, "error": "trivial_path", "en": en, "es": es} | |
| bridge = _bridge_label(lca["lang"]) | |
| root = { | |
| **_pack_node(atlas, lca, gloss_limit=3), | |
| "bridge": bridge, | |
| # Root is the open clue: client shows term + glosses in the clear. | |
| "visible": True, | |
| } | |
| def branch_nodes(path: list[dict], *, end_role: str) -> list[dict]: | |
| # path is root → … → modern end | |
| nodes: list[dict] = [] | |
| for i, n in enumerate(path): | |
| if i == 0: | |
| continue # root handled separately | |
| packed = _pack_node(atlas, n, gloss_limit=2) | |
| is_end = i == len(path) - 1 | |
| packed["role"] = end_role if is_end else "hop" | |
| packed["depth"] = i | |
| # Ends + hops: terms redacted by default; langs always shown in UI. | |
| packed["visible"] = False | |
| nodes.append(packed) | |
| return nodes | |
| branch_a = branch_nodes(root_to_a, end_role="end_a") | |
| branch_b = branch_nodes(root_to_b, end_role="end_b") | |
| end_a = next(n for n in branch_a if n["role"] == "end_a") | |
| end_b = next(n for n in branch_b if n["role"] == "end_b") | |
| # Corpus for Redactle reveals: hop terms + all glosses (not the open root term, | |
| # which is already clear — but include root glosses so guessing sense words helps). | |
| corpus_bits: list[str] = [] | |
| corpus_bits.extend(root.get("glosses") or []) | |
| for n in [*branch_a, *branch_b]: | |
| corpus_bits.append(n["term"]) | |
| corpus_bits.extend(n.get("glosses") or []) | |
| tokens: list[dict] = [] | |
| for bit in corpus_bits: | |
| tokens.extend(tokenize_text(bit)) | |
| tokens.append({"t": " ", "k": "", "word": False, "open": True, "sep": True}) | |
| # Unique guessable keys present in the document (for hint pool). | |
| hidden_keys = sorted( | |
| {t["k"] for t in tokens if t.get("word") and t.get("k") and not t.get("open")} | |
| ) | |
| return { | |
| "ok": True, | |
| "id": _round_id(en, es), | |
| "source": source, | |
| "mode": "redactle_tree", | |
| "root": root, | |
| "branch_a": branch_a, | |
| "branch_b": branch_b, | |
| # Targets (also embedded in branches); used for win checks. | |
| "end_a": { | |
| "term": end_a["term"], | |
| "lang": end_a["lang"], | |
| "lang_display": end_a["lang_display"], | |
| "gloss": end_a.get("gloss") or "", | |
| "glosses": end_a.get("glosses") or [], | |
| }, | |
| "end_b": { | |
| "term": end_b["term"], | |
| "lang": end_b["lang"], | |
| "lang_display": end_b["lang_display"], | |
| "gloss": end_b.get("gloss") or "", | |
| "glosses": end_b.get("glosses") or [], | |
| }, | |
| "a": end_a, | |
| "b": end_b, | |
| "help": { | |
| "prompt": "A shared historical ancestor is open. Fill the redacted tree — guess the modern English and Spanish ends.", | |
| "tip": ( | |
| f"Both words descend from {bridge} {root['term']}. " | |
| "Type words to uncover them wherever they appear in definitions and forms. " | |
| "Language labels and small function words are already visible." | |
| ), | |
| }, | |
| "defaults": { | |
| "root_open": True, | |
| "langs_open": True, | |
| "stopwords_open": True, | |
| }, | |
| "hint_pool_size": len(hidden_keys), | |
| "answer": { | |
| "bridge": bridge, | |
| "en": end_a["term"], | |
| "es": end_b["term"], | |
| "en_key": _norm_key(end_a["term"]), | |
| "es_key": _norm_key(end_b["term"]), | |
| "lca": root, | |
| "path_a": [_pack_node(atlas, n) for n in root_to_a], | |
| "path_b": [_pack_node(atlas, n) for n in root_to_b], | |
| "hidden_keys": hidden_keys, | |
| }, | |
| } | |
| def next_round(atlas: Atlas, *, avoid: set[str] | None = None) -> dict: | |
| deck = _load_deck() | |
| avoid = avoid or set() | |
| def priority(p: dict) -> int: | |
| src = p.get("source") or "" | |
| # Latin-shared pairs almost always meet on a non-proto stage. | |
| if src == "latin": | |
| return 0 | |
| if src == "seed": | |
| return 1 | |
| if src == "iecor": | |
| return 2 | |
| return 3 | |
| buckets: dict[int, list[dict]] = {0: [], 1: [], 2: [], 3: []} | |
| for p in deck: | |
| rid = _round_id(p["en"], p["es"]) | |
| if rid in avoid: | |
| continue | |
| buckets[priority(p)].append(p) | |
| pool: list[dict] = [] | |
| pool.extend(buckets[0] * 3) | |
| pool.extend(buckets[1] * 2) | |
| pool.extend(buckets[2]) | |
| pool.extend(buckets[3]) | |
| if not pool: | |
| pool = list(deck) | |
| import random | |
| random.shuffle(pool) | |
| last_err = "empty_deck" | |
| for p in pool[:120]: | |
| rnd = build_round(atlas, p["en"], p["es"], source=p.get("source") or "deck") | |
| if rnd.get("ok"): | |
| return rnd | |
| last_err = rnd.get("error") or last_err | |
| return {"ok": False, "error": last_err} | |
| def apply_guess(round_payload: dict, word: str, revealed: list[str] | None = None) -> dict: | |
| """Apply one Redactle guess against the round's hidden corpus + ends.""" | |
| ans = round_payload.get("answer") or {} | |
| key = _norm_key(word) | |
| prev = {_norm_key(x) for x in (revealed or []) if x} | |
| if not key: | |
| return { | |
| "ok": False, | |
| "error": "empty", | |
| "revealed": sorted(prev), | |
| "hit": False, | |
| "en_ok": ans.get("en_key") in prev, | |
| "es_ok": ans.get("es_key") in prev, | |
| } | |
| hidden = set(ans.get("hidden_keys") or []) | |
| hit = key in hidden or key == ans.get("en_key") or key == ans.get("es_key") | |
| nxt = set(prev) | |
| if hit: | |
| nxt.add(key) | |
| en_ok = ans.get("en_key") in nxt | |
| es_ok = ans.get("es_key") in nxt | |
| return { | |
| "ok": True, | |
| "guess": word.strip(), | |
| "key": key, | |
| "hit": hit, | |
| "revealed": sorted(nxt), | |
| "en_ok": en_ok, | |
| "es_ok": es_ok, | |
| "solved": en_ok and es_ok, | |
| "new": key not in prev and hit, | |
| } | |
| def score_guess( | |
| round_payload: dict, | |
| en_guess: str | None = None, | |
| es_guess: str | None = None, | |
| *, | |
| # Legacy no-ops kept so old clients do not 500 | |
| branch_a_guess: list[str] | None = None, | |
| branch_b_guess: list[str] | None = None, | |
| bridge_guess: str | None = None, | |
| chain_guess: list[str] | None = None, | |
| guess_count: int | None = None, | |
| ) -> dict: | |
| ans = round_payload.get("answer") or {} | |
| en_ok = _norm_key(en_guess or "") == (ans.get("en_key") or _norm_key(ans.get("en") or "")) | |
| es_ok = _norm_key(es_guess or "") == (ans.get("es_key") or _norm_key(ans.get("es") or "")) | |
| solved = en_ok and es_ok | |
| guesses = int(guess_count or 0) | |
| # Fewer guesses is better; award a descending score once solved. | |
| max_score = 20 | |
| score = max(0, max_score - guesses) if solved else (int(en_ok) + int(es_ok)) | |
| return { | |
| "en_ok": en_ok, | |
| "es_ok": es_ok, | |
| "solved": solved, | |
| "guess_count": guesses, | |
| "score": score, | |
| "max_score": max_score, | |
| "answer": ans, | |
| # Legacy fields some UI still reads | |
| "bridge_ok": True, | |
| "chain_ok": solved, | |
| "branches_ok": solved, | |
| "correct_slots": int(en_ok) + int(es_ok), | |
| "slot_count": 2, | |
| } | |
| def pick_hint(round_payload: dict, revealed: list[str] | None = None) -> dict: | |
| """Reveal one still-hidden corpus token that is not an unsolved end (prefer gloss crumbs).""" | |
| ans = round_payload.get("answer") or {} | |
| have = {_norm_key(x) for x in (revealed or []) if x} | |
| ends = {ans.get("en_key"), ans.get("es_key")} - {None, ""} | |
| pool = [k for k in (ans.get("hidden_keys") or []) if k not in have and k not in ends] | |
| if not pool: | |
| # Fall back to revealing an end key as a strong hint. | |
| pool = [k for k in ends if k not in have] | |
| if not pool: | |
| return {"ok": False, "error": "nothing_left", "revealed": sorted(have)} | |
| # Prefer mid-length tokens (more informative than "of"-adjacent crumbs). | |
| pool.sort(key=lambda k: (abs(len(k) - 6), len(k), k)) | |
| choice = pool[0] | |
| have.add(choice) | |
| return { | |
| "ok": True, | |
| "key": choice, | |
| "revealed": sorted(have), | |
| "en_ok": ans.get("en_key") in have, | |
| "es_ok": ans.get("es_key") in have, | |
| "solved": ans.get("en_key") in have and ans.get("es_key") in have, | |
| } | |