add-synonyms.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. #!/usr/bin/env python3
  2. """Add synonyms (hints) to each target word in word-bank.json and word-bank-ru.json.
  3. Uses Datamuse API for English (free, no auth). Falls back to WordNet.
  4. Results are reviewed for quality — known-bad mappings are overridden.
  5. For Russian, uses a curated synonym dictionary.
  6. """
  7. import json
  8. import sys
  9. import time
  10. from pathlib import Path
  11. DATA_DIR = Path(__file__).parent.parent / "data"
  12. def get_english_synonyms_datamuse(word: str) -> list[str]:
  13. """Get related words from Datamuse API (free, no auth)."""
  14. import requests
  15. try:
  16. resp = requests.get(
  17. "https://api.datamuse.com/words",
  18. params={"ml": word, "max": 8},
  19. timeout=5,
  20. )
  21. resp.raise_for_status()
  22. results = resp.json()
  23. words = []
  24. for r in results:
  25. w = r.get("word", "")
  26. if " " not in w and w.lower() != word.lower():
  27. words.append(w.lower())
  28. return words
  29. except Exception as e:
  30. print(f" Datamuse error for '{word}': {e}", file=sys.stderr)
  31. return []
  32. def get_english_synonyms_wordnet(word: str) -> list[str]:
  33. """Get synonyms from WordNet."""
  34. from nltk.corpus import wordnet as wn
  35. syns = wn.synsets(word)
  36. if not syns:
  37. return []
  38. lemmas = []
  39. for s in syns[:10]:
  40. for l in s.lemmas():
  41. name = l.name().lower()
  42. if "_" not in name and name != word and name not in lemmas:
  43. lemmas.append(name)
  44. return lemmas
  45. def pick_best_synonym(word: str, candidates: list[str]) -> str | None:
  46. """Pick the best synonym from candidates. Prefers common, short words."""
  47. # Filter out multi-word, very long words, and obscure forms
  48. good = [c for c in candidates if len(c) <= 10 and "_" not in c]
  49. if not good:
  50. return None
  51. # Prefer shorter words (more likely to be common)
  52. good.sort(key=len)
  53. return good[0]
  54. # Manually curated overrides for words where automated approaches give poor results.
  55. # These are common, intuitive synonyms that help a player guess the target word.
  56. EN_HINT_OVERRIDES: dict[str, str] = {
  57. # Function words and words where Datamuse/WordNet produce confusing hints
  58. "about": "around",
  59. "there": "present",
  60. "which": "that",
  61. "other": "different",
  62. "would": "could",
  63. "these": "those",
  64. "while": "during",
  65. "being": "living",
  66. "where": "location",
  67. "still": "quiet",
  68. "never": "not ever",
  69. "every": "each",
  70. "since": "because",
  71. "those": "these",
  72. "among": "between",
  73. "money": "cash",
  74. "local": "nearby",
  75. "least": "fewest",
  76. "month": "april",
  77. "river": "stream",
  78. "david": "giant",
  79. "steve": "stephen",
  80. "didnt": "did not",
  81. "three": "trio",
  82. "years": "decade",
  83. "going": "moving",
  84. # Quality overrides for bad Datamuse/WordNet results
  85. "state": "condition",
  86. "games": "sport",
  87. "found": "located",
  88. "often": "frequent",
  89. "south": "southern",
  90. "small": "little",
  91. "north": "northern",
  92. "third": "tertiary",
  93. "party": "group",
  94. "began": "started",
  95. "young": "youth",
  96. "final": "last",
  97. "given": "provided",
  98. "using": "employ",
  99. "field": "area",
  100. "teams": "squad",
  101. "thing": "object",
  102. "white": "pale",
  103. "march": "parade",
  104. "black": "dark",
  105. "built": "constructed",
  106. "moved": "shifted",
  107. "added": "extra",
  108. "needs": "requires",
  109. "short": "brief",
  110. "front": "forward",
  111. "seven": "seventh",
  112. "title": "name",
  113. "comes": "arrives",
  114. "hours": "time",
  115. "eight": "eighth",
  116. "green": "color",
  117. "round": "circular",
  118. "union": "group",
  119. "lives": "exists",
  120. "radio": "broadcast",
  121. "words": "phrase",
  122. "model": "design",
  123. "terms": "words",
  124. "score": "points",
  125. "ready": "prepared",
  126. "grand": "large",
  127. }
  128. # ---- Russian synonym dictionary (curated for the 100 target words) ----
  129. # Manually selected synonyms or related hints for Russian target words.
  130. RU_HINTS: dict[str, str] = {
  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. def generate_english_hints(bank: dict) -> int:
  233. """Add hints to English targets. Returns count of missing hints."""
  234. targets = bank["targets"]
  235. missing = 0
  236. for i, target in enumerate(targets):
  237. word = target["word"]
  238. # Check manual override first
  239. if word in EN_HINT_OVERRIDES:
  240. target["hint"] = EN_HINT_OVERRIDES[word]
  241. continue
  242. # Try Datamuse
  243. candidates = get_english_synonyms_datamuse(word)
  244. if not candidates:
  245. # Fall back to WordNet
  246. candidates = get_english_synonyms_wordnet(word)
  247. hint = pick_best_synonym(word, candidates)
  248. if hint:
  249. target["hint"] = hint
  250. else:
  251. missing += 1
  252. print(f" MISSING: {word} (index={i})")
  253. # Be polite to the API
  254. if i % 20 == 19:
  255. time.sleep(0.5)
  256. return missing
  257. def generate_russian_hints(bank: dict) -> int:
  258. """Add hints to Russian targets from curated dictionary. Returns count missing."""
  259. targets = bank["targets"]
  260. missing = 0
  261. for i, target in enumerate(targets):
  262. word = target["word"]
  263. hint = RU_HINTS.get(word)
  264. if hint:
  265. target["hint"] = hint
  266. else:
  267. missing += 1
  268. print(f" MISSING: {word} (index={i})")
  269. return missing
  270. def main():
  271. # English
  272. print("=== English synonyms ===")
  273. en_path = DATA_DIR / "word-bank.json"
  274. with open(en_path, encoding="utf-8") as f:
  275. en_bank = json.load(f)
  276. en_missing = generate_english_hints(en_bank)
  277. with open(en_path, "w", encoding="utf-8") as f:
  278. json.dump(en_bank, f, ensure_ascii=False, indent=2)
  279. f.write("\n")
  280. print(f" {len(en_bank['targets'])} targets, {len(en_bank['targets']) - en_missing} with hints, {en_missing} missing")
  281. # Russian
  282. print("\n=== Russian synonyms ===")
  283. ru_path = DATA_DIR / "word-bank-ru.json"
  284. with open(ru_path, encoding="utf-8") as f:
  285. ru_bank = json.load(f)
  286. ru_missing = generate_russian_hints(ru_bank)
  287. with open(ru_path, "w", encoding="utf-8") as f:
  288. json.dump(ru_bank, f, ensure_ascii=False, indent=2)
  289. f.write("\n")
  290. print(f" {len(ru_bank['targets'])} targets, {len(ru_bank['targets']) - ru_missing} with hints, {ru_missing} missing")
  291. print("\nDone!")
  292. if __name__ == "__main__":
  293. main()