nspasojevic commited on
Commit
a04d50d
·
verified ·
1 Parent(s): 387fd3e

Automatic spelling vocabulary: /spell_vocab.json from the index (indexing #98 c8f9e69) + UI slim-ui-940a240b

Browse files
docker/hf-space/nginx.conf.template CHANGED
@@ -170,6 +170,20 @@ server {
170
  return 200 '{"index":"usdsearch-dev","complete":false,"generated_at":null,"basis":"package sizes are not available yet","total":0,"sizes":{}}';
171
  }
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  location /images {
174
  if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
175
  rewrite ^/images(.*)$ /v3/images$1 break;
 
170
  return 200 '{"index":"usdsearch-dev","complete":false,"generated_at":null,"basis":"package sizes are not available yet","total":0,"sizes":{}}';
171
  }
172
 
173
+ # The corpus's own words for the Explorer's spelling suggestion: package-download keeps the table current
174
+ # (pipeline/package_download/spell_vocab.py). Not up yet, or failing: a complete:false 200 (the Explorer keeps its
175
+ # baked list). A 404 (switched off) passes through.
176
+ location = /spell_vocab.json {
177
+ if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
178
+ proxy_pass http://127.0.0.1:8020;
179
+ proxy_intercept_errors on;
180
+ error_page 500 502 503 504 = @spell_vocab_pending;
181
+ }
182
+ location @spell_vocab_pending {
183
+ default_type application/json;
184
+ return 200 '{"index":"usdsearch-dev","complete":false,"generated_at":null,"basis":"the spelling vocabulary is not available yet","assets":0,"count":0,"words":[]}';
185
+ }
186
+
187
  location /images {
188
  if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
189
  rewrite ^/images(.*)$ /v3/images$1 break;
pipeline/package_download/__main__.py CHANGED
@@ -11,6 +11,7 @@ from .app import create_app
11
  from .catalog import Catalog
12
  from .settings import Settings
13
  from .sizes import PackageSizes, SizesSettings
 
14
 
15
 
16
  def main() -> int:
@@ -31,8 +32,11 @@ def main() -> int:
31
  # /package_sizes.json: every indexed asset's download size, kept current in the background (sizes.py).
32
  sizes_settings = SizesSettings.from_env()
33
  sizes = PackageSizes(sizes_settings) if sizes_settings.enabled else None
 
 
 
34
  uvicorn.run(
35
- create_app(settings, catalog=catalog, sizes=sizes), host=settings.host, port=settings.port, log_config=None
36
  )
37
  return 0
38
 
 
11
  from .catalog import Catalog
12
  from .settings import Settings
13
  from .sizes import PackageSizes, SizesSettings
14
+ from .spell_vocab import SpellVocab, VocabSettings
15
 
16
 
17
  def main() -> int:
 
32
  # /package_sizes.json: every indexed asset's download size, kept current in the background (sizes.py).
33
  sizes_settings = SizesSettings.from_env()
34
  sizes = PackageSizes(sizes_settings) if sizes_settings.enabled else None
35
+ # /spell_vocab.json: the corpus's own words, which the Explorer's "Did you mean" never questions (spell_vocab.py).
36
+ vocab_settings = VocabSettings.from_env()
37
+ vocab = SpellVocab(vocab_settings) if vocab_settings.enabled else None
38
  uvicorn.run(
39
+ create_app(settings, catalog=catalog, sizes=sizes, vocab=vocab), host=settings.host, port=settings.port, log_config=None
40
  )
41
  return 0
42
 
pipeline/package_download/app.py CHANGED
@@ -26,6 +26,7 @@ from . import bundle, telemetry
26
  from .catalog import Catalog
27
  from .settings import Settings
28
  from .sizes import PackageSizes
 
29
 
30
  logger = logging.getLogger("package_download")
31
 
@@ -184,6 +185,7 @@ def create_app(
184
  url_mapping: Optional[UrlMapping] = None,
185
  fallback_transport: Optional[httpx.AsyncBaseTransport] = None,
186
  sizes: Optional[PackageSizes] = None,
 
187
  ) -> FastAPI:
188
  settings = settings or Settings.from_env()
189
  to_internal, to_public, to_public_url = url_mapping or _default_url_mapping()
@@ -220,11 +222,15 @@ def create_app(
220
  )
221
  if sizes is not None:
222
  sizes.start()
 
 
223
  try:
224
  yield
225
  finally:
226
  if sizes is not None:
227
  await sizes.stop()
 
 
228
  await app.state.fallback.aclose()
229
  if owned_source:
230
  await source.close()
@@ -264,6 +270,15 @@ def create_app(
264
  headers["Content-Encoding"] = encoding
265
  return Response(body, media_type="application/json", headers=headers)
266
 
 
 
 
 
 
 
 
 
 
267
  async def forward(request: Request) -> Response:
268
  """Hand the request to deepsearch-api's own /v3/download/asset, unchanged."""
269
  params = [(k, v) for k, v in request.query_params.multi_items() if k != "scope"]
 
26
  from .catalog import Catalog
27
  from .settings import Settings
28
  from .sizes import PackageSizes
29
+ from .spell_vocab import SpellVocab
30
 
31
  logger = logging.getLogger("package_download")
32
 
 
185
  url_mapping: Optional[UrlMapping] = None,
186
  fallback_transport: Optional[httpx.AsyncBaseTransport] = None,
187
  sizes: Optional[PackageSizes] = None,
188
+ vocab: Optional[SpellVocab] = None,
189
  ) -> FastAPI:
190
  settings = settings or Settings.from_env()
191
  to_internal, to_public, to_public_url = url_mapping or _default_url_mapping()
 
222
  )
223
  if sizes is not None:
224
  sizes.start()
225
+ if vocab is not None:
226
+ vocab.start()
227
  try:
228
  yield
229
  finally:
230
  if sizes is not None:
231
  await sizes.stop()
232
+ if vocab is not None:
233
+ await vocab.stop()
234
  await app.state.fallback.aclose()
235
  if owned_source:
236
  await source.close()
 
270
  headers["Content-Encoding"] = encoding
271
  return Response(body, media_type="application/json", headers=headers)
272
 
273
+ if vocab is not None:
274
+ @app.get("/spell_vocab.json")
275
+ async def spell_vocab(request: Request):
276
+ body, encoding = vocab.body(request.headers.get("accept-encoding", ""))
277
+ headers = {"Cache-Control": "no-cache", "Vary": "Accept-Encoding"}
278
+ if encoding:
279
+ headers["Content-Encoding"] = encoding
280
+ return Response(body, media_type="application/json", headers=headers)
281
+
282
  async def forward(request: Request) -> Response:
283
  """Hand the request to deepsearch-api's own /v3/download/asset, unchanged."""
284
  params = [(k, v) for k, v in request.query_params.multi_items() if k != "scope"]
pipeline/package_download/spell_vocab.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``GET /spell_vocab.json``: the corpus's own words, so the Explorer's "Did you mean" never questions them.
2
+
3
+ The Explorer (SimReady Central's ``utils/spellSuggest.js``) offers a spelling from the English word list for a typed
4
+ word the list lacks. A word this corpus uses (a vendor, a product code stem, a file name part, a category label) is
5
+ not a typo: the Explorer treats every word here like its own hard-coded protected list (the 2026-10-17 scan, kept as
6
+ the fallback). This table replaces hand-curating that list after every re-index.
7
+
8
+ Same lifecycle as ``sizes.py``: a background task, at start and every ``interval`` seconds, pages this Space's own
9
+ index (deepsearch-api's query-less search, by cursor, with each hit's vision metadata) and reads the property
10
+ inventory (deepsearch-api's ``stats/usd_properties``) and the restored Category tree. Words come from every asset's
11
+ file name, folder segments, dataset org and name, vision caption, object_type, tags and queries, the inventory's
12
+ name / class / category / vendor / type values, and the Category tree's labels and vendor names. Each text is split
13
+ at non-letters, camelCase and digits; words of ``min_len``+ letters are kept, lowercased, de-duplicated; words the
14
+ English list already has are left out (the Explorer never questions those). Until the walk and the inventory have
15
+ both been read the table says ``complete: false`` (the Explorer keeps its baked list).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import gzip
22
+ import hashlib
23
+ import json
24
+ import logging
25
+ import os
26
+ import re
27
+ import time
28
+ from dataclasses import dataclass, fields
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+ from typing import Any, Awaitable, Callable, Iterable, Mapping, Optional, Set, Tuple
32
+ from urllib.parse import unquote
33
+
34
+ import httpx
35
+
36
+ logger = logging.getLogger("package_download.spell_vocab")
37
+
38
+ PREFIX = "SPELL_VOCAB_"
39
+ BASIS = (
40
+ "words of every indexed asset's file name, folders, dataset, vision caption / object_type / tags / queries, "
41
+ "the property inventory's name, class, category and vendor values and the Category tree's labels; "
42
+ "English-list words left out"
43
+ )
44
+ # The vision bags deepsearch-api returns on a hit's source (return_vision_generated_metadata).
45
+ VISION_BAGS = (
46
+ "plugin_thumbnail_to_vision_metadata_metadata_vlm_generated",
47
+ "plugin_rendering_to_vision_metadata_metadata_vlm_generated",
48
+ )
49
+ VISION_FIELDS = frozenset({"caption", "object_type", "tags", "queries"})
50
+ # Inventory keys whose values are words a user types for an asset (the 2026-10-17 scan's rule).
51
+ STATS_KEY = re.compile(r"(name|class|categor|vendor|manufact|brand|maker|author|profile|type|label|wikidata)", re.I)
52
+ QCODE = re.compile(r"^Q\d+$")
53
+ # A hex run of 8+ with 2+ digits (a hash, a uuid part) is no word: its letter fragments would be noise.
54
+ HEXRUN = re.compile(r"(?=(?:[a-f]*\d){2})[0-9a-f]{8,}", re.I)
55
+ # camelCase / ACRONYMWord / digits: "LGElectronics" -> LG, Electronics; "mser1130b" -> mser, b.
56
+ PARTS = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+")
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class VocabSettings:
61
+ enabled: bool = True
62
+ index: str = "usdsearch-dev"
63
+ search_url: str = "http://127.0.0.1:8000/v3/deepsearch/search"
64
+ stats_url: str = "http://127.0.0.1:8000/v2/deepsearch/search/stats/usd_properties"
65
+ page_size: int = 500
66
+ search_user: str = "simready-central-svc-user"
67
+ search_password: str = "simready-central-svc-pass"
68
+ # nginx's /wikidata_hierarchy.json (the Category tree), restored with the index run; absent is fine.
69
+ hierarchy_path: Path = Path("/app/hf-space-pipeline/.build/restore/hierarchy/category_hierarchy.json")
70
+ # The dist's English list: its words are left out of the table (absent: nothing left out).
71
+ english_path: Path = Path("/usr/share/nginx/explorer/spell/en-words.txt")
72
+ interval: float = 900.0
73
+ retry_interval: float = 30.0
74
+ # The inventory (a multi-MB aggregation) is read again only when the walk's assets changed, or after this long.
75
+ reread_after: float = 6 * 3600.0
76
+ timeout: float = 120.0
77
+ min_len: int = 3
78
+ # Longer inventory values are descriptions, not names.
79
+ max_value_len: int = 120
80
+
81
+ @classmethod
82
+ def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "VocabSettings":
83
+ env = os.environ if env is None else env
84
+ values = {}
85
+ for field in fields(cls):
86
+ raw = env.get(PREFIX + field.name.upper())
87
+ if raw is None or raw == "":
88
+ continue
89
+ default = field.default
90
+ if isinstance(default, bool):
91
+ values[field.name] = raw.strip().lower() in ("1", "true", "yes", "on")
92
+ elif isinstance(default, int):
93
+ values[field.name] = int(raw)
94
+ elif isinstance(default, float):
95
+ values[field.name] = float(raw)
96
+ elif isinstance(default, Path):
97
+ values[field.name] = Path(raw)
98
+ else:
99
+ values[field.name] = raw
100
+ return cls(**values)
101
+
102
+
103
+ def words_of(text: Any, min_len: int = 3) -> Iterable[str]:
104
+ """Lowercase words of ``min_len``+ letters: split at non-letters, camelCase and digits (a camelCase run kept whole
105
+ too); hash-like hex runs dropped."""
106
+ if not isinstance(text, str) or not text:
107
+ return
108
+ for run in re.findall(r"[A-Za-z]+", HEXRUN.sub(" ", text)):
109
+ pieces = PARTS.findall(run)
110
+ if len(pieces) > 1 and len(run) >= min_len:
111
+ yield run.lower() # "IsaacSim" is typed whole too
112
+ for w in pieces:
113
+ if len(w) >= min_len:
114
+ yield w.lower()
115
+
116
+
117
+ def key_texts(base_key: str) -> Iterable[str]:
118
+ """``…/datasets/<org>/<name>/blob/<rev>/<path>``: org, name, every folder and the file stem (the revision never)."""
119
+ m = re.search(r"/(?:datasets/|spaces/)?([^/]+)/([^/]+)/(?:blob|resolve)/[^/]+/(.+)$", base_key or "")
120
+ if not m:
121
+ return
122
+ yield m.group(1)
123
+ yield m.group(2)
124
+ segs = unquote(m.group(3).split("?", 1)[0]).split("/")
125
+ yield from segs[:-1]
126
+ yield re.sub(r"\.[A-Za-z0-9]+$", "", segs[-1])
127
+
128
+
129
+ def hit_texts(hit: Mapping[str, Any]) -> Iterable[str]:
130
+ source = hit.get("source") or {}
131
+ yield from key_texts(source.get("base_key") or "")
132
+ name = source.get("name")
133
+ if isinstance(name, str):
134
+ yield re.sub(r"\.[A-Za-z0-9]+$", "", name)
135
+ for bag in VISION_BAGS:
136
+ for item in source.get(bag) or []:
137
+ if isinstance(item, dict) and item.get("name") in VISION_FIELDS:
138
+ for v in item.get("value_text") or []:
139
+ yield v
140
+ vgm = source.get("vision_generated_metadata")
141
+ if isinstance(vgm, dict):
142
+ for k, v in vgm.items():
143
+ if any(f in k for f in VISION_FIELDS):
144
+ yield from (v if isinstance(v, list) else [v])
145
+
146
+
147
+ def stats_texts(doc: Mapping[str, Any], max_len: int) -> Iterable[str]:
148
+ for kv in doc.get("kv_pairs") or []:
149
+ if not isinstance(kv, dict):
150
+ continue
151
+ val = kv.get("value")
152
+ if STATS_KEY.search(str(kv.get("key", ""))) and isinstance(val, str) and len(val) <= max_len:
153
+ if not QCODE.match(val):
154
+ yield val
155
+
156
+
157
+ def hierarchy_texts(doc: Any) -> Iterable[str]:
158
+ """Every ``label`` in the Category tree, its vendor names and its folder pins."""
159
+ if isinstance(doc, dict):
160
+ for k, v in doc.items():
161
+ if k == "label" and isinstance(v, str):
162
+ yield v
163
+ elif k == "vendors" and isinstance(v, dict):
164
+ for a, b in v.items():
165
+ yield a
166
+ if isinstance(b, str):
167
+ yield b
168
+ else:
169
+ yield from hierarchy_texts(v)
170
+ elif isinstance(doc, list):
171
+ for v in doc:
172
+ yield from hierarchy_texts(v)
173
+
174
+
175
+ class IndexUnavailable(Exception):
176
+ pass
177
+
178
+
179
+ class SpellVocab:
180
+ """The word table and the task that keeps it current. ``body()`` is what the route sends."""
181
+
182
+ def __init__(
183
+ self,
184
+ settings: Optional[VocabSettings] = None,
185
+ *,
186
+ transport: Optional[httpx.AsyncBaseTransport] = None,
187
+ wall: Callable[[], float] = time.time,
188
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
189
+ ):
190
+ self.settings = settings or VocabSettings.from_env()
191
+ self._transport, self._wall, self._sleep = transport, wall, sleep
192
+ self._client: Optional[httpx.AsyncClient] = None
193
+ self._task: Optional[asyncio.Task] = None
194
+ self._english: Optional[Set[str]] = None
195
+ self._fingerprint = ""
196
+ self._stats_words: Optional[Set[str]] = None
197
+ self._stats_at = 0.0
198
+ self.words: list = []
199
+ self.assets = 0
200
+ self.complete = False
201
+ self._body = self._gz = b""
202
+ self._rebuild(None)
203
+
204
+ # --- serving ---------------------------------------------------------------------------------------------
205
+
206
+ def body(self, accept_encoding: str = "") -> Tuple[bytes, Optional[str]]:
207
+ if "gzip" in (accept_encoding or "").lower():
208
+ return self._gz, "gzip"
209
+ return self._body, None
210
+
211
+ def _rebuild(self, generated_at: Optional[float]) -> None:
212
+ doc = {
213
+ "index": self.settings.index,
214
+ "complete": self.complete,
215
+ "generated_at": (
216
+ datetime.fromtimestamp(generated_at, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
217
+ if generated_at is not None else None
218
+ ),
219
+ "basis": BASIS,
220
+ "assets": self.assets,
221
+ "count": len(self.words),
222
+ "words": self.words,
223
+ }
224
+ self._body = json.dumps(doc, separators=(",", ":")).encode()
225
+ self._gz = gzip.compress(self._body, compresslevel=9, mtime=0)
226
+
227
+ # --- the task --------------------------------------------------------------------------------------------
228
+
229
+ def start(self) -> None:
230
+ self._task = asyncio.get_running_loop().create_task(self.run(), name="spell-vocab")
231
+
232
+ async def stop(self) -> None:
233
+ if self._task is not None:
234
+ self._task.cancel()
235
+ await asyncio.gather(self._task, return_exceptions=True)
236
+ self._task = None
237
+ if self._client is not None:
238
+ await self._client.aclose()
239
+ self._client = None
240
+
241
+ async def run(self) -> None:
242
+ while True:
243
+ try:
244
+ delay = await self.refresh()
245
+ except asyncio.CancelledError:
246
+ raise
247
+ except Exception: # noqa: BLE001 — the table stays as it was; try again later
248
+ logger.exception("spell vocab: pass failed")
249
+ delay = self.settings.retry_interval
250
+ await self._sleep(delay)
251
+
252
+ def _http(self) -> httpx.AsyncClient:
253
+ s = self.settings
254
+ if self._client is None:
255
+ auth = (s.search_user, s.search_password) if s.search_user else None
256
+ self._client = httpx.AsyncClient(timeout=s.timeout, transport=self._transport, trust_env=False, auth=auth)
257
+ return self._client
258
+
259
+ def _english_words(self) -> Set[str]:
260
+ if self._english is None:
261
+ words: Set[str] = set()
262
+ try:
263
+ for line in Path(self.settings.english_path).read_text(encoding="utf-8").splitlines():
264
+ w = line.split("\t", 1)[0].strip().lower()
265
+ if w.isalpha():
266
+ words.add(w)
267
+ except OSError as exc:
268
+ logger.info("spell vocab: no English list at %s (%s); every word kept", self.settings.english_path, exc)
269
+ self._english = words
270
+ return self._english
271
+
272
+ async def refresh(self) -> float:
273
+ """One pass: walk the index, read the inventory when needed, rebuild. Returns the delay to the next."""
274
+ s = self.settings
275
+ try:
276
+ hits = await self.walk_index()
277
+ except IndexUnavailable as exc:
278
+ logger.info("spell vocab: index not readable yet (%s); next try in %.0f s", exc, s.retry_interval)
279
+ return s.retry_interval
280
+ fingerprint = hashlib.sha256(
281
+ "\n".join(sorted(f"{(h.get('source') or {}).get('base_key')}|{(h.get('source') or {}).get('hash_value')}"
282
+ for h in hits)).encode()
283
+ ).hexdigest()
284
+ now = self._wall()
285
+ if (self._stats_words is None or fingerprint != self._fingerprint
286
+ or now - self._stats_at >= s.reread_after):
287
+ try:
288
+ stats = await self._get_json(s.stats_url)
289
+ except IndexUnavailable as exc:
290
+ logger.info("spell vocab: inventory not readable (%s); next try in %.0f s", exc, s.retry_interval)
291
+ return s.retry_interval
292
+ self._stats_words = {w for t in stats_texts(stats, s.max_value_len) for w in words_of(t, s.min_len)}
293
+ self._stats_at = now
294
+ elif fingerprint == self._fingerprint and self.complete:
295
+ return s.interval # nothing moved
296
+ words: Set[str] = set(self._stats_words)
297
+ for hit in hits:
298
+ for text in hit_texts(hit):
299
+ words.update(words_of(text, s.min_len))
300
+ words.update(w for t in hierarchy_texts(self._read_hierarchy()) for w in words_of(t, s.min_len))
301
+ english = self._english_words()
302
+ self.words = sorted(w for w in words if w not in english)
303
+ self.assets, self.complete, self._fingerprint = len(hits), True, fingerprint
304
+ self._rebuild(now)
305
+ logger.info(
306
+ "spell vocab: %d asset(s), %d word(s) (%d left out as English), %d bytes, %d gzipped (complete)",
307
+ len(hits), len(self.words), len(words) - len(self.words), len(self._body), len(self._gz),
308
+ )
309
+ return s.interval
310
+
311
+ def _read_hierarchy(self) -> Any:
312
+ path = Path(self.settings.hierarchy_path)
313
+ try:
314
+ return json.loads(path.read_text(encoding="utf-8"))
315
+ except FileNotFoundError:
316
+ return None
317
+ except (OSError, ValueError) as exc:
318
+ logger.warning("spell vocab: ignoring the Category tree at %s (%s)", path, exc)
319
+ return None
320
+
321
+ async def _get_json(self, url: str) -> Any:
322
+ try:
323
+ r = await self._http().get(url)
324
+ r.raise_for_status()
325
+ return r.json()
326
+ except (httpx.HTTPError, ValueError) as exc:
327
+ raise IndexUnavailable(f"{type(exc).__name__}: {exc}") from exc
328
+
329
+ async def walk_index(self) -> list:
330
+ """Every hit in the index with its vision metadata (deepsearch-api's query-less search, by cursor)."""
331
+ s = self.settings
332
+ hits: dict = {}
333
+ cursor = None
334
+ for _ in range(10_000):
335
+ body = {"limit": s.page_size, "return_metadata": True, "return_vision_generated_metadata": True,
336
+ "return_usd_properties": False, "return_images": False}
337
+ if cursor:
338
+ body["cursor"] = cursor
339
+ try:
340
+ r = await self._http().post(s.search_url, json=body)
341
+ r.raise_for_status()
342
+ doc = r.json()
343
+ except (httpx.HTTPError, ValueError) as exc:
344
+ raise IndexUnavailable(f"{type(exc).__name__}: {exc}") from exc
345
+ page = doc.get("hits") or []
346
+ for hit in page:
347
+ key = (hit.get("source") or {}).get("base_key")
348
+ if isinstance(key, str) and key:
349
+ hits[key] = hit
350
+ if not doc.get("has_more"):
351
+ return list(hits.values())
352
+ cursor = doc.get("next_cursor")
353
+ if not page or not cursor: # never a complete table from a part
354
+ raise IndexUnavailable("has_more without hits or a cursor")
355
+ raise IndexUnavailable("the walk did not end")
services/explorer/dist/build-info.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "product": "simready-central-slim-explorer",
3
- "version": "slim-ui-e4522a3f",
4
- "bundle_id": "main.01872f5b.js",
5
- "built_at": "2026-10-18T17:30:52Z",
6
  "flags": {"public_mode": true, "email_auth": false, "create_ui": false, "hierarchy_editor": false, "create_search": false, "auth_ui": false, "credentials_ui": false, "index_management": false, "inference_backend": "hub", "vlm_asset_refs": false, "validation_image_max_px": 448, "validation_max_retries": 0, "validation_max_concurrent": 32, "validation_tail_concurrent": 8, "validation_eager_count": 50, "validation_batch_stream": true, "library_idle_prefetch": false, "validation_score_scale": "percent", "parse_gate_fields": true, "parse_fast_path": true, "featured_landing": true, "hf_download_commands": true, "dependency_views": true, "model_viewer": false, "library_viewer": true, "library_media": "runtime", "spell_correction": true, "downloads": "runtime", "submit_content": "runtime", "third_party_notices": "runtime"}
7
  }
 
1
  {
2
  "product": "simready-central-slim-explorer",
3
+ "version": "slim-ui-940a240b",
4
+ "bundle_id": "main.d09cd33e.js",
5
+ "built_at": "2026-10-19T08:19:49Z",
6
  "flags": {"public_mode": true, "email_auth": false, "create_ui": false, "hierarchy_editor": false, "create_search": false, "auth_ui": false, "credentials_ui": false, "index_management": false, "inference_backend": "hub", "vlm_asset_refs": false, "validation_image_max_px": 448, "validation_max_retries": 0, "validation_max_concurrent": 32, "validation_tail_concurrent": 8, "validation_eager_count": 50, "validation_batch_stream": true, "library_idle_prefetch": false, "validation_score_scale": "percent", "parse_gate_fields": true, "parse_fast_path": true, "featured_landing": true, "hf_download_commands": true, "dependency_views": true, "model_viewer": false, "library_viewer": true, "library_media": "runtime", "spell_correction": true, "downloads": "runtime", "submit_content": "runtime", "third_party_notices": "runtime"}
7
  }
services/explorer/dist/index.html CHANGED
@@ -1 +1 @@
1
- <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/ds.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SimReady Central — SimReady asset search"/><meta name="ui-version" content="slim-ui-e4522a3f"><script src="/config.js"></script><title>SimReady Central</title><script defer="defer" src="/static/js/main.01872f5b.js"></script><link href="/static/css/main.ed151f6a.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
 
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/ds.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SimReady Central — SimReady asset search"/><meta name="ui-version" content="slim-ui-940a240b"><script src="/config.js"></script><title>SimReady Central</title><script defer="defer" src="/static/js/main.d09cd33e.js"></script><link href="/static/css/main.ed151f6a.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
services/explorer/dist/static/js/main.d09cd33e.js ADDED
The diff for this file is too large to render. See raw diff
 
services/explorer/dist/static/js/main.d09cd33e.js.LICENSE.txt ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license React
3
+ * react-dom.production.min.js
4
+ *
5
+ * Copyright (c) Facebook, Inc. and its affiliates.
6
+ *
7
+ * This source code is licensed under the MIT license found in the
8
+ * LICENSE file in the root directory of this source tree.
9
+ */
10
+
11
+ /**
12
+ * @license React
13
+ * react-jsx-runtime.production.min.js
14
+ *
15
+ * Copyright (c) Facebook, Inc. and its affiliates.
16
+ *
17
+ * This source code is licensed under the MIT license found in the
18
+ * LICENSE file in the root directory of this source tree.
19
+ */
20
+
21
+ /**
22
+ * @license React
23
+ * react.production.min.js
24
+ *
25
+ * Copyright (c) Facebook, Inc. and its affiliates.
26
+ *
27
+ * This source code is licensed under the MIT license found in the
28
+ * LICENSE file in the root directory of this source tree.
29
+ */
30
+
31
+ /**
32
+ * @license React
33
+ * scheduler.production.min.js
34
+ *
35
+ * Copyright (c) Facebook, Inc. and its affiliates.
36
+ *
37
+ * This source code is licensed under the MIT license found in the
38
+ * LICENSE file in the root directory of this source tree.
39
+ */
services/explorer/dist/third-party-notices.txt CHANGED
@@ -1,4 +1,4 @@
1
- Third-party notices — SimReady Central Explorer slim-ui-e4522a3f
2
 
3
  The software in this web application's browser bundle includes the packages below, each under its own
4
  license. Each entry gives the package, its version, its license and the license / notice text it ships.
 
1
+ Third-party notices — SimReady Central Explorer slim-ui-940a240b
2
 
3
  The software in this web application's browser bundle includes the packages below, each under its own
4
  license. Each entry gives the package, its version, its license and the license / notice text it ships.