|
@@ -0,0 +1,324 @@
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+"""Add synonyms (hints) to each target word in word-bank.json and word-bank-ru.json.
|
|
|
|
|
+
|
|
|
|
|
+Uses Datamuse API for English (free, no auth). Falls back to WordNet.
|
|
|
|
|
+Results are reviewed for quality — known-bad mappings are overridden.
|
|
|
|
|
+
|
|
|
|
|
+For Russian, uses a curated synonym dictionary.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+import json
|
|
|
|
|
+import sys
|
|
|
|
|
+import time
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+DATA_DIR = Path(__file__).parent.parent / "data"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def get_english_synonyms_datamuse(word: str) -> list[str]:
|
|
|
|
|
+ """Get related words from Datamuse API (free, no auth)."""
|
|
|
|
|
+ import requests
|
|
|
|
|
+ try:
|
|
|
|
|
+ resp = requests.get(
|
|
|
|
|
+ "https://api.datamuse.com/words",
|
|
|
|
|
+ params={"ml": word, "max": 8},
|
|
|
|
|
+ timeout=5,
|
|
|
|
|
+ )
|
|
|
|
|
+ resp.raise_for_status()
|
|
|
|
|
+ results = resp.json()
|
|
|
|
|
+ words = []
|
|
|
|
|
+ for r in results:
|
|
|
|
|
+ w = r.get("word", "")
|
|
|
|
|
+ if " " not in w and w.lower() != word.lower():
|
|
|
|
|
+ words.append(w.lower())
|
|
|
|
|
+ return words
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ print(f" Datamuse error for '{word}': {e}", file=sys.stderr)
|
|
|
|
|
+ return []
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def get_english_synonyms_wordnet(word: str) -> list[str]:
|
|
|
|
|
+ """Get synonyms from WordNet."""
|
|
|
|
|
+ from nltk.corpus import wordnet as wn
|
|
|
|
|
+ syns = wn.synsets(word)
|
|
|
|
|
+ if not syns:
|
|
|
|
|
+ return []
|
|
|
|
|
+ lemmas = []
|
|
|
|
|
+ for s in syns[:10]:
|
|
|
|
|
+ for l in s.lemmas():
|
|
|
|
|
+ name = l.name().lower()
|
|
|
|
|
+ if "_" not in name and name != word and name not in lemmas:
|
|
|
|
|
+ lemmas.append(name)
|
|
|
|
|
+ return lemmas
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def pick_best_synonym(word: str, candidates: list[str]) -> str | None:
|
|
|
|
|
+ """Pick the best synonym from candidates. Prefers common, short words."""
|
|
|
|
|
+ # Filter out multi-word, very long words, and obscure forms
|
|
|
|
|
+ good = [c for c in candidates if len(c) <= 10 and "_" not in c]
|
|
|
|
|
+ if not good:
|
|
|
|
|
+ return None
|
|
|
|
|
+ # Prefer shorter words (more likely to be common)
|
|
|
|
|
+ good.sort(key=len)
|
|
|
|
|
+ return good[0]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# Manually curated overrides for words where automated approaches give poor results.
|
|
|
|
|
+# These are common, intuitive synonyms that help a player guess the target word.
|
|
|
|
|
+EN_HINT_OVERRIDES: dict[str, str] = {
|
|
|
|
|
+ # Function words and words where Datamuse/WordNet produce confusing hints
|
|
|
|
|
+ "about": "around",
|
|
|
|
|
+ "there": "present",
|
|
|
|
|
+ "which": "that",
|
|
|
|
|
+ "other": "different",
|
|
|
|
|
+ "would": "could",
|
|
|
|
|
+ "these": "those",
|
|
|
|
|
+ "while": "during",
|
|
|
|
|
+ "being": "living",
|
|
|
|
|
+ "where": "location",
|
|
|
|
|
+ "still": "quiet",
|
|
|
|
|
+ "never": "not ever",
|
|
|
|
|
+ "every": "each",
|
|
|
|
|
+ "since": "because",
|
|
|
|
|
+ "those": "these",
|
|
|
|
|
+ "among": "between",
|
|
|
|
|
+ "money": "cash",
|
|
|
|
|
+ "local": "nearby",
|
|
|
|
|
+ "least": "fewest",
|
|
|
|
|
+ "month": "april",
|
|
|
|
|
+ "river": "stream",
|
|
|
|
|
+ "david": "giant",
|
|
|
|
|
+ "steve": "stephen",
|
|
|
|
|
+ "didnt": "did not",
|
|
|
|
|
+ "three": "trio",
|
|
|
|
|
+ "years": "decade",
|
|
|
|
|
+ "going": "moving",
|
|
|
|
|
+ # Quality overrides for bad Datamuse/WordNet results
|
|
|
|
|
+ "state": "condition",
|
|
|
|
|
+ "games": "sport",
|
|
|
|
|
+ "found": "located",
|
|
|
|
|
+ "often": "frequent",
|
|
|
|
|
+ "south": "southern",
|
|
|
|
|
+ "small": "little",
|
|
|
|
|
+ "north": "northern",
|
|
|
|
|
+ "third": "tertiary",
|
|
|
|
|
+ "party": "group",
|
|
|
|
|
+ "began": "started",
|
|
|
|
|
+ "young": "youth",
|
|
|
|
|
+ "final": "last",
|
|
|
|
|
+ "given": "provided",
|
|
|
|
|
+ "using": "employ",
|
|
|
|
|
+ "field": "area",
|
|
|
|
|
+ "teams": "squad",
|
|
|
|
|
+ "thing": "object",
|
|
|
|
|
+ "white": "pale",
|
|
|
|
|
+ "march": "parade",
|
|
|
|
|
+ "black": "dark",
|
|
|
|
|
+ "built": "constructed",
|
|
|
|
|
+ "moved": "shifted",
|
|
|
|
|
+ "added": "extra",
|
|
|
|
|
+ "needs": "requires",
|
|
|
|
|
+ "short": "brief",
|
|
|
|
|
+ "front": "forward",
|
|
|
|
|
+ "seven": "seventh",
|
|
|
|
|
+ "title": "name",
|
|
|
|
|
+ "comes": "arrives",
|
|
|
|
|
+ "hours": "time",
|
|
|
|
|
+ "eight": "eighth",
|
|
|
|
|
+ "green": "color",
|
|
|
|
|
+ "round": "circular",
|
|
|
|
|
+ "union": "group",
|
|
|
|
|
+ "lives": "exists",
|
|
|
|
|
+ "radio": "broadcast",
|
|
|
|
|
+ "words": "phrase",
|
|
|
|
|
+ "model": "design",
|
|
|
|
|
+ "terms": "words",
|
|
|
|
|
+ "score": "points",
|
|
|
|
|
+ "ready": "prepared",
|
|
|
|
|
+ "grand": "large",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# ---- Russian synonym dictionary (curated for the 100 target words) ----
|
|
|
|
|
+# Manually selected synonyms or related hints for Russian target words.
|
|
|
|
|
+RU_HINTS: dict[str, str] = {
|
|
|
|
|
+ "жизнь": "бытие",
|
|
|
|
|
+ "слово": "речь",
|
|
|
|
|
+ "часть": "доля",
|
|
|
|
|
+ "город": "посёлок",
|
|
|
|
|
+ "земля": "почва",
|
|
|
|
|
+ "право": "закон",
|
|
|
|
|
+ "образ": "облик",
|
|
|
|
|
+ "закон": "правило",
|
|
|
|
|
+ "война": "битва",
|
|
|
|
|
+ "голос": "звук",
|
|
|
|
|
+ "книга": "роман",
|
|
|
|
|
+ "число": "цифра",
|
|
|
|
|
+ "народ": "люди",
|
|
|
|
|
+ "форма": "фигура",
|
|
|
|
|
+ "улица": "дорога",
|
|
|
|
|
+ "вечер": "закат",
|
|
|
|
|
+ "мысль": "идея",
|
|
|
|
|
+ "месяц": "луна",
|
|
|
|
|
+ "школа": "учёба",
|
|
|
|
|
+ "театр": "сцена",
|
|
|
|
|
+ "смысл": "суть",
|
|
|
|
|
+ "рынок": "базар",
|
|
|
|
|
+ "центр": "середина",
|
|
|
|
|
+ "ответ": "отклик",
|
|
|
|
|
+ "автор": "писатель",
|
|
|
|
|
+ "совет": "наказ",
|
|
|
|
|
+ "глава": "лидер",
|
|
|
|
|
+ "наука": "знание",
|
|
|
|
|
+ "плечо": "рука",
|
|
|
|
|
+ "точка": "пятно",
|
|
|
|
|
+ "палец": "рука",
|
|
|
|
|
+ "метод": "способ",
|
|
|
|
|
+ "фильм": "кино",
|
|
|
|
|
+ "гость": "визитёр",
|
|
|
|
|
+ "район": "зона",
|
|
|
|
|
+ "кровь": "рана",
|
|
|
|
|
+ "армия": "войско",
|
|
|
|
|
+ "класс": "группа",
|
|
|
|
|
+ "герой": "смельчак",
|
|
|
|
|
+ "спина": "тело",
|
|
|
|
|
+ "берег": "пляж",
|
|
|
|
|
+ "фирма": "компания",
|
|
|
|
|
+ "завод": "фабрика",
|
|
|
|
|
+ "повод": "причина",
|
|
|
|
|
+ "выход": "уход",
|
|
|
|
|
+ "текст": "письмо",
|
|
|
|
|
+ "пункт": "место",
|
|
|
|
|
+ "линия": "черта",
|
|
|
|
|
+ "среда": "окружение",
|
|
|
|
|
+ "волос": "прядь",
|
|
|
|
|
+ "ветер": "буря",
|
|
|
|
|
+ "огонь": "пламя",
|
|
|
|
|
+ "страх": "ужас",
|
|
|
|
|
+ "сфера": "область",
|
|
|
|
|
+ "немец": "германец",
|
|
|
|
|
+ "выбор": "отбор",
|
|
|
|
|
+ "масса": "вес",
|
|
|
|
|
+ "кухня": "готовка",
|
|
|
|
|
+ "товар": "продукт",
|
|
|
|
|
+ "вывод": "итог",
|
|
|
|
|
+ "норма": "правило",
|
|
|
|
|
+ "рамка": "граница",
|
|
|
|
|
+ "прием": "встреча",
|
|
|
|
|
+ "режим": "порядок",
|
|
|
|
|
+ "целое": "полное",
|
|
|
|
|
+ "доход": "прибыль",
|
|
|
|
|
+ "карта": "схема",
|
|
|
|
|
+ "акция": "дело",
|
|
|
|
|
+ "фраза": "выражение",
|
|
|
|
|
+ "толпа": "скопление",
|
|
|
|
|
+ "птица": "пернатый",
|
|
|
|
|
+ "запах": "аромат",
|
|
|
|
|
+ "поезд": "состав",
|
|
|
|
|
+ "адрес": "жильё",
|
|
|
|
|
+ "лидер": "вождь",
|
|
|
|
|
+ "весна": "сезон",
|
|
|
|
|
+ "музей": "галерея",
|
|
|
|
|
+ "сутки": "день",
|
|
|
|
|
+ "еврей": "иудей",
|
|
|
|
|
+ "сотня": "сто",
|
|
|
|
|
+ "труба": "дымоход",
|
|
|
|
|
+ "масло": "жир",
|
|
|
|
|
+ "экран": "дисплей",
|
|
|
|
|
+ "вагон": "поезд",
|
|
|
|
|
+ "сезон": "пора",
|
|
|
|
|
+ "длина": "размер",
|
|
|
|
|
+ "доска": "дерево",
|
|
|
|
|
+ "лодка": "судно",
|
|
|
|
|
+ "серия": "ряд",
|
|
|
|
|
+ "кулак": "рука",
|
|
|
|
|
+ "нефть": "топливо",
|
|
|
|
|
+ "кость": "скелет",
|
|
|
|
|
+ "взрыв": "удар",
|
|
|
|
|
+ "почва": "земля",
|
|
|
|
|
+ "хвост": "конец",
|
|
|
|
|
+ "строй": "порядок",
|
|
|
|
|
+ "отчет": "доклад",
|
|
|
|
|
+ "забор": "стена",
|
|
|
|
|
+ "пожар": "огонь",
|
|
|
|
|
+ "ножка": "нога",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def generate_english_hints(bank: dict) -> int:
|
|
|
|
|
+ """Add hints to English targets. Returns count of missing hints."""
|
|
|
|
|
+ targets = bank["targets"]
|
|
|
|
|
+ missing = 0
|
|
|
|
|
+
|
|
|
|
|
+ for i, target in enumerate(targets):
|
|
|
|
|
+ word = target["word"]
|
|
|
|
|
+
|
|
|
|
|
+ # Check manual override first
|
|
|
|
|
+ if word in EN_HINT_OVERRIDES:
|
|
|
|
|
+ target["hint"] = EN_HINT_OVERRIDES[word]
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # Try Datamuse
|
|
|
|
|
+ candidates = get_english_synonyms_datamuse(word)
|
|
|
|
|
+ if not candidates:
|
|
|
|
|
+ # Fall back to WordNet
|
|
|
|
|
+ candidates = get_english_synonyms_wordnet(word)
|
|
|
|
|
+
|
|
|
|
|
+ hint = pick_best_synonym(word, candidates)
|
|
|
|
|
+ if hint:
|
|
|
|
|
+ target["hint"] = hint
|
|
|
|
|
+ else:
|
|
|
|
|
+ missing += 1
|
|
|
|
|
+ print(f" MISSING: {word} (id={target['id']})")
|
|
|
|
|
+
|
|
|
|
|
+ # Be polite to the API
|
|
|
|
|
+ if i % 20 == 19:
|
|
|
|
|
+ time.sleep(0.5)
|
|
|
|
|
+
|
|
|
|
|
+ return missing
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def generate_russian_hints(bank: dict) -> int:
|
|
|
|
|
+ """Add hints to Russian targets from curated dictionary. Returns count missing."""
|
|
|
|
|
+ targets = bank["targets"]
|
|
|
|
|
+ missing = 0
|
|
|
|
|
+
|
|
|
|
|
+ for target in targets:
|
|
|
|
|
+ word = target["word"]
|
|
|
|
|
+ hint = RU_HINTS.get(word)
|
|
|
|
|
+ if hint:
|
|
|
|
|
+ target["hint"] = hint
|
|
|
|
|
+ else:
|
|
|
|
|
+ missing += 1
|
|
|
|
|
+ print(f" MISSING: {word} (id={target['id']})")
|
|
|
|
|
+
|
|
|
|
|
+ return missing
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main():
|
|
|
|
|
+ # English
|
|
|
|
|
+ print("=== English synonyms ===")
|
|
|
|
|
+ en_path = DATA_DIR / "word-bank.json"
|
|
|
|
|
+ with open(en_path, encoding="utf-8") as f:
|
|
|
|
|
+ en_bank = json.load(f)
|
|
|
|
|
+ en_missing = generate_english_hints(en_bank)
|
|
|
|
|
+ with open(en_path, "w", encoding="utf-8") as f:
|
|
|
|
|
+ json.dump(en_bank, f, ensure_ascii=False, indent=2)
|
|
|
|
|
+ f.write("\n")
|
|
|
|
|
+ print(f" {len(en_bank['targets'])} targets, {len(en_bank['targets']) - en_missing} with hints, {en_missing} missing")
|
|
|
|
|
+
|
|
|
|
|
+ # Russian
|
|
|
|
|
+ print("\n=== Russian synonyms ===")
|
|
|
|
|
+ ru_path = DATA_DIR / "word-bank-ru.json"
|
|
|
|
|
+ with open(ru_path, encoding="utf-8") as f:
|
|
|
|
|
+ ru_bank = json.load(f)
|
|
|
|
|
+ ru_missing = generate_russian_hints(ru_bank)
|
|
|
|
|
+ with open(ru_path, "w", encoding="utf-8") as f:
|
|
|
|
|
+ json.dump(ru_bank, f, ensure_ascii=False, indent=2)
|
|
|
|
|
+ f.write("\n")
|
|
|
|
|
+ print(f" {len(ru_bank['targets'])} targets, {len(ru_bank['targets']) - ru_missing} with hints, {ru_missing} missing")
|
|
|
|
|
+
|
|
|
|
|
+ print("\nDone!")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ main()
|