Spaces:
Running on RTX PRO 6000
Running on RTX PRO 6000
Automatic spelling vocabulary: /spell_vocab.json from the index (indexing #98 c8f9e69) + UI slim-ui-940a240b
Browse files- docker/hf-space/nginx.conf.template +14 -0
- pipeline/package_download/__main__.py +5 -1
- pipeline/package_download/app.py +15 -0
- pipeline/package_download/spell_vocab.py +355 -0
- services/explorer/dist/build-info.json +3 -3
- services/explorer/dist/index.html +1 -1
- services/explorer/dist/static/js/main.d09cd33e.js +0 -0
- services/explorer/dist/static/js/main.d09cd33e.js.LICENSE.txt +39 -0
- services/explorer/dist/third-party-notices.txt +1 -1
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-
|
| 4 |
-
"bundle_id": "main.
|
| 5 |
-
"built_at": "2026-10-
|
| 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-
|
|
|
|
| 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-
|
| 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.
|