Mongoosetross commited on
Commit
2214565
·
verified ·
1 Parent(s): f689680

Fast deploy (index 64717948e3cd)

Browse files
Files changed (4) hide show
  1. Dockerfile +1 -1
  2. backend/app.py +2 -2
  3. backend/define.py +125 -206
  4. requirements.space.txt +0 -1
Dockerfile CHANGED
@@ -27,7 +27,7 @@ COPY frontend/dist ./frontend/dist
27
 
28
  # Pinned prebuilt index artifact (update when republishing the dataset).
29
  ARG INDEX_URL=https://huggingface.co/datasets/Mongoosetross/reverse-etymology-index/resolve/main/index.tar.zst
30
- ARG INDEX_SHA256=89aa172cb1685949914166db14532a8784235382adc5a1754a20c208daa12069
31
  RUN mkdir -p data/index \
32
  && curl -fsSL "$INDEX_URL" -o /tmp/index.tar.zst \
33
  && if [ -n "$INDEX_SHA256" ]; then echo "$INDEX_SHA256 /tmp/index.tar.zst" | sha256sum -c -; fi \
 
27
 
28
  # Pinned prebuilt index artifact (update when republishing the dataset).
29
  ARG INDEX_URL=https://huggingface.co/datasets/Mongoosetross/reverse-etymology-index/resolve/main/index.tar.zst
30
+ ARG INDEX_SHA256=64717948e3cd47d2b453757c4e692f94321fe407e633a9b2605e5780b699f609
31
  RUN mkdir -p data/index \
32
  && curl -fsSL "$INDEX_URL" -o /tmp/index.tar.zst \
33
  && if [ -n "$INDEX_SHA256" ]; then echo "$INDEX_SHA256 /tmp/index.tar.zst" | sha256sum -c -; fi \
backend/app.py CHANGED
@@ -86,8 +86,8 @@ def node(term: str, lang: str) -> dict:
86
 
87
  @app.get("/api/define")
88
  def define(term: str, lang: str = "", iso: str | None = None) -> dict:
89
- """Short glosses from Wiktionary REST, matched to the atlas language when possible."""
90
- return fetch_definitions(term=term, lang=lang, iso_639_3=iso)
91
 
92
 
93
  @app.post("/api/tree")
 
86
 
87
  @app.get("/api/define")
88
  def define(term: str, lang: str = "", iso: str | None = None) -> dict:
89
+ """Short glosses from the local Kaikki dump (baked into atlas.sqlite)."""
90
+ return fetch_definitions(term=term, lang=lang, iso_639_3=iso, atlas=atlas)
91
 
92
 
93
  @app.post("/api/tree")
backend/define.py CHANGED
@@ -1,235 +1,154 @@
1
  from __future__ import annotations
2
 
3
- """Fetch short glosses from the Wiktionary REST definition API."""
4
-
5
- import html
6
- import re
7
- import time
8
- from urllib.parse import quote
9
-
10
- import httpx
11
-
12
- USER_AGENT = "ReverseEtymologyAtlas/1.0 (https://huggingface.co/spaces/Mongoosetross/reverse-etymology)"
13
- WIKT_DEF = "https://en.wiktionary.org/api/rest_v1/page/definition/{term}"
14
-
15
- # Atlas lang keys / ISO 639-3 -> Wiktionary REST language codes (usually ISO 639-1 / 639-3).
16
- LANG_TO_WIKT: dict[str, str] = {
17
- "english": "en",
18
- "spanish": "es",
19
- "old spanish": "osp",
20
- "french": "fr",
21
- "old french": "fro",
22
- "middle french": "frm",
23
- "latin": "la",
24
- "vulgar latin": "la",
25
- "italian": "it",
26
- "portuguese": "pt",
27
- "old portuguese": "roa-opt",
28
- "galician": "gl",
29
- "catalan": "ca",
30
- "romanian": "ro",
31
- "german": "de",
32
- "dutch": "nl",
33
- "greek": "el",
34
- "ancient greek": "grc",
35
- "russian": "ru",
36
- "arabic": "ar",
37
- "hebrew": "he",
38
- "chinese": "zh",
39
- "japanese": "ja",
40
- "korean": "ko",
41
- "hindi": "hi",
42
- "sanskrit": "sa",
43
- "proto-indo-european": "ine-pro",
44
- "proto-germanic": "gem-pro",
45
- "proto-celtic": "cel-pro",
46
- "old english": "ang",
47
- "middle english": "enm",
48
- "scottish gaelic": "gd",
49
- "irish": "ga",
50
- "welsh": "cy",
51
- "polish": "pl",
52
- "czech": "cs",
53
- "swedish": "sv",
54
- "norwegian": "no",
55
- "danish": "da",
56
- "finnish": "fi",
57
- "hungarian": "hu",
58
- "turkish": "tr",
59
- "persian": "fa",
60
- "esperanto": "eo",
61
- "tagalog": "tl",
62
- "asturian": "ast",
63
- "aragonese": "an",
64
- "corsican": "co",
65
- "occitan": "oc",
66
- "basque": "eu",
67
- "eng": "en",
68
- "spa": "es",
69
- "fra": "fr",
70
- "fre": "fr",
71
- "lat": "la",
72
- "ita": "it",
73
- "por": "pt",
74
- "deu": "de",
75
- "ger": "de",
76
- "nld": "nl",
77
- "dut": "nl",
78
- "rus": "ru",
79
- "ell": "el",
80
- "gre": "el",
81
- "arb": "ar",
82
- "heb": "he",
83
- "cmn": "zh",
84
- "jpn": "ja",
85
- "kor": "ko",
86
- "hin": "hi",
87
- "san": "sa",
88
- "ang": "ang",
89
- "enm": "enm",
90
- "fro": "fro",
91
- "frm": "frm",
92
- "osp": "osp",
93
- "ron": "ro",
94
- "rum": "ro",
95
- "cat": "ca",
96
- "glg": "gl",
97
- "eus": "eu",
98
- }
99
-
100
- TAG_RE = re.compile(r"<[^>]+>")
101
- NESTED_LIST_RE = re.compile(r"<(ol|ul)\b[^>]*>.*?</\1>", re.I | re.S)
102
- SPACE_RE = re.compile(r"\s+")
103
-
104
- _cache: dict[str, tuple[float, dict]] = {}
105
- _CACHE_TTL = 3600.0
106
-
107
-
108
- def strip_markup(raw: str) -> str:
109
- text = NESTED_LIST_RE.sub(" ", raw or "")
110
- text = TAG_RE.sub(" ", text)
111
- text = html.unescape(text)
112
- return SPACE_RE.sub(" ", text).strip(" \t\n\r-;,:")
113
-
114
-
115
- def wikt_codes_for(lang: str, iso_639_3: str | None = None) -> list[str]:
116
- codes: list[str] = []
117
- key = (lang or "").strip().casefold()
118
- if key in LANG_TO_WIKT:
119
- codes.append(LANG_TO_WIKT[key])
120
- iso = (iso_639_3 or "").strip().casefold()
121
- if iso and iso in LANG_TO_WIKT:
122
- codes.append(LANG_TO_WIKT[iso])
123
- if iso and len(iso) == 3 and iso not in codes:
124
- # Many Wiktionary keys are ISO 639-3 for historical lects.
125
- codes.append(iso)
126
- if key and key not in codes:
127
- codes.append(key)
128
- # de-dupe preserving order
129
- seen: set[str] = set()
130
- out: list[str] = []
131
- for c in codes:
132
- if c and c not in seen:
133
- seen.add(c)
134
- out.append(c)
135
- return out
136
 
 
 
137
 
138
- def _parse_entries(payload: dict, prefer: list[str]) -> tuple[str | None, list[dict]]:
139
- if not isinstance(payload, dict) or not payload:
140
- return None, []
141
- chosen = None
142
- for code in prefer:
143
- if code in payload:
144
- chosen = code
145
- break
146
- if chosen is None:
147
- # Prefer English glosses as a readable fallback, else first language.
148
- if "en" in payload:
149
- chosen = "en"
150
- else:
151
- chosen = next(iter(payload.keys()))
152
  senses: list[dict] = []
153
- for block in payload.get(chosen) or []:
154
  if not isinstance(block, dict):
155
  continue
156
- pos = (block.get("partOfSpeech") or block.get("partOfSpeech") or "").strip() or "Sense"
157
  glosses: list[str] = []
158
- for d in block.get("definitions") or []:
159
- if not isinstance(d, dict):
160
- continue
161
- gloss = strip_markup(str(d.get("definition") or ""))
162
- if gloss:
163
- glosses.append(gloss)
164
- if len(glosses) >= 4:
165
  break
166
  if glosses:
167
  senses.append({"pos": pos, "glosses": glosses})
168
- if len(senses) >= 4:
169
  break
170
- return chosen, senses
171
 
172
 
173
- def fetch_definitions(term: str, lang: str = "", iso_639_3: str | None = None) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  term = (term or "").strip()
175
  if not term:
176
- return {"term": term, "senses": [], "error": "empty_term"}
177
-
178
- cache_key = f"{term.casefold()}|{lang.casefold()}|{(iso_639_3 or '').casefold()}"
179
- now = time.time()
180
- hit = _cache.get(cache_key)
181
- if hit and now - hit[0] < _CACHE_TTL:
182
- return hit[1]
183
-
184
- prefer = wikt_codes_for(lang, iso_639_3)
185
- url = WIKT_DEF.format(term=quote(term, safe=""))
186
- wiki_lang = prefer[0] if prefer else (lang or "english").replace(" ", "_")
187
- wiki_page = f"https://en.wiktionary.org/wiki/{quote(term, safe='')}#{wiki_lang}"
188
-
189
- try:
190
- with httpx.Client(timeout=8.0, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}) as client:
191
- resp = client.get(url)
192
- if resp.status_code == 404:
193
- out = {
194
- "term": term,
195
- "lang": lang,
196
- "matched_code": None,
197
- "senses": [],
198
- "fallback": False,
199
- "source": "wiktionary",
200
- "url": wiki_page,
201
- "error": "not_found",
202
- }
203
- _cache[cache_key] = (now, out)
204
- return out
205
- resp.raise_for_status()
206
- payload = resp.json()
207
- except Exception as exc: # noqa: BLE001 surface soft failure to UI
208
- out = {
 
 
 
209
  "term": term,
210
  "lang": lang,
211
  "matched_code": None,
212
  "senses": [],
213
  "fallback": False,
214
- "source": "wiktionary",
215
- "url": wiki_page,
216
- "error": f"fetch_failed:{type(exc).__name__}",
217
  }
218
- _cache[cache_key] = (now, out)
219
- return out
220
 
221
- matched, senses = _parse_entries(payload if isinstance(payload, dict) else {}, prefer)
222
- fallback = bool(matched and prefer and matched not in prefer)
223
- out = {
224
  "term": term,
225
  "lang": lang,
226
- "matched_code": matched,
227
  "senses": senses,
228
  "fallback": fallback,
229
- "available_langs": sorted((payload or {}).keys())[:24] if isinstance(payload, dict) else [],
230
- "source": "wiktionary",
231
- "url": wiki_page,
232
  "error": None if senses else "no_senses",
233
  }
234
- _cache[cache_key] = (now, out)
235
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ """Serve short glosses from the local Kaikki dump baked into atlas.sqlite."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
+ import json
6
+ import sqlite3
7
 
8
+ MAX_SENSES = 4
9
+ MAX_GLOSSES = 4
10
+
11
+
12
+ def _trim_senses(raw: object) -> list[dict]:
13
+ if isinstance(raw, str):
14
+ try:
15
+ raw = json.loads(raw)
16
+ except json.JSONDecodeError:
17
+ return []
18
+ if not isinstance(raw, list):
19
+ return []
 
 
20
  senses: list[dict] = []
21
+ for block in raw:
22
  if not isinstance(block, dict):
23
  continue
24
+ pos = str(block.get("pos") or "Sense").strip() or "Sense"
25
  glosses: list[str] = []
26
+ for g in block.get("glosses") or []:
27
+ text = " ".join(str(g).split()).strip()
28
+ if text:
29
+ glosses.append(text)
30
+ if len(glosses) >= MAX_GLOSSES:
 
 
31
  break
32
  if glosses:
33
  senses.append({"pos": pos, "glosses": glosses})
34
+ if len(senses) >= MAX_SENSES:
35
  break
36
+ return senses
37
 
38
 
39
+ def _candidate_langs(lang: str, iso_639_3: str | None, lang_meta: dict[str, dict] | None) -> list[str]:
40
+ out: list[str] = []
41
+ key = (lang or "").strip()
42
+ if key:
43
+ out.append(key)
44
+ folded = key.casefold()
45
+ if folded != key:
46
+ out.append(folded)
47
+ iso = (iso_639_3 or "").strip().casefold()
48
+ if lang_meta and iso:
49
+ for lk, meta in lang_meta.items():
50
+ if (meta.get("iso_639_3") or "").casefold() == iso:
51
+ out.append(lk)
52
+ if iso:
53
+ out.append(iso)
54
+ seen: set[str] = set()
55
+ uniq: list[str] = []
56
+ for c in out:
57
+ if c and c not in seen:
58
+ seen.add(c)
59
+ uniq.append(c)
60
+ return uniq
61
+
62
+
63
+ def lookup_definitions(
64
+ conn: sqlite3.Connection,
65
+ term: str,
66
+ lang: str = "",
67
+ iso_639_3: str | None = None,
68
+ lang_meta: dict[str, dict] | None = None,
69
+ ) -> dict:
70
  term = (term or "").strip()
71
  if not term:
72
+ return {"term": term, "senses": [], "error": "empty_term", "source": "kaikki"}
73
+
74
+ candidates = _candidate_langs(lang, iso_639_3, lang_meta)
75
+
76
+ row = None
77
+ matched_lang = None
78
+ for cand in candidates:
79
+ row = conn.execute(
80
+ "SELECT lang, senses_json FROM definitions WHERE term = ? AND lang = ?",
81
+ (term, cand),
82
+ ).fetchone()
83
+ if row is not None:
84
+ matched_lang = row["lang"] if isinstance(row, sqlite3.Row) else row[0]
85
+ break
86
+
87
+ fallback = False
88
+ if row is None:
89
+ # Any language for this spelling (last resort for mismatched atlas keys).
90
+ rows = conn.execute(
91
+ "SELECT lang, senses_json FROM definitions WHERE term = ? ORDER BY lang LIMIT 8",
92
+ (term,),
93
+ ).fetchall()
94
+ if rows:
95
+ prefer = {c.casefold() for c in candidates}
96
+ chosen = None
97
+ for r in rows:
98
+ rlang = r["lang"] if isinstance(r, sqlite3.Row) else r[0]
99
+ if rlang.casefold() in prefer or rlang.casefold() == "english":
100
+ chosen = r
101
+ break
102
+ row = chosen or rows[0]
103
+ matched_lang = row["lang"] if isinstance(row, sqlite3.Row) else row[0]
104
+ fallback = bool(candidates) and matched_lang not in candidates
105
+
106
+ if row is None:
107
+ return {
108
  "term": term,
109
  "lang": lang,
110
  "matched_code": None,
111
  "senses": [],
112
  "fallback": False,
113
+ "source": "kaikki",
114
+ "error": "not_found",
 
115
  }
 
 
116
 
117
+ senses_json = row["senses_json"] if isinstance(row, sqlite3.Row) else row[1]
118
+ senses = _trim_senses(senses_json)
119
+ return {
120
  "term": term,
121
  "lang": lang,
122
+ "matched_code": matched_lang,
123
  "senses": senses,
124
  "fallback": fallback,
125
+ "source": "kaikki",
 
 
126
  "error": None if senses else "no_senses",
127
  }
128
+
129
+
130
+ def fetch_definitions(
131
+ term: str,
132
+ lang: str = "",
133
+ iso_639_3: str | None = None,
134
+ *,
135
+ atlas=None,
136
+ ) -> dict:
137
+ """Lookup glosses from the baked index. ``atlas`` must be a loaded Atlas."""
138
+ if atlas is None or getattr(atlas, "conn", None) is None:
139
+ return {
140
+ "term": term,
141
+ "lang": lang,
142
+ "matched_code": None,
143
+ "senses": [],
144
+ "fallback": False,
145
+ "source": "kaikki",
146
+ "error": "index_unavailable",
147
+ }
148
+ return lookup_definitions(
149
+ atlas.conn,
150
+ term=term,
151
+ lang=lang,
152
+ iso_639_3=iso_639_3,
153
+ lang_meta=getattr(atlas, "lang_meta", None),
154
+ )
requirements.space.txt CHANGED
@@ -4,4 +4,3 @@ uvicorn[standard]>=0.32.0
4
  numpy>=2.0.0
5
  orjson>=3.10.0
6
  pydantic>=2.9.0
7
- httpx>=0.27.0
 
4
  numpy>=2.0.0
5
  orjson>=3.10.0
6
  pydantic>=2.9.0