Commit Β·
9cdb79f
1
Parent(s): 7523db4
fix: memory overload by adding top models and options to load singular models
Browse files- api/main.py +110 -6
- api/model_registry.py +56 -8
- frontend/src/api.ts +19 -0
- frontend/src/components/VersionSelector.tsx +191 -84
- scripts/download_models.py +123 -4
api/main.py
CHANGED
|
@@ -53,6 +53,7 @@ from fastapi.responses import FileResponse
|
|
| 53 |
|
| 54 |
from api.model_registry import registry, registry_v1, registry_v2, registry_v3
|
| 55 |
from api.schemas import HealthResponse
|
|
|
|
| 56 |
from src.utils.logger import get_logger
|
| 57 |
|
| 58 |
log = get_logger(__name__)
|
|
@@ -67,10 +68,20 @@ _FRONTEND_DIST = _HERE.parent / "frontend" / "dist"
|
|
| 67 |
# ββ Lifespan βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
@asynccontextmanager
|
| 69 |
async def lifespan(app: FastAPI):
|
| 70 |
-
"""Load only v3
|
| 71 |
log.info("Loading model registries β¦")
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
# v1 and v2 are NOT loaded at startup β download + load on-demand via
|
| 75 |
# POST /api/versions/{v}/load to reduce startup time and memory usage.
|
| 76 |
if _version_loaded("v1"):
|
|
@@ -118,6 +129,7 @@ async def health():
|
|
| 118 |
# ββ Version management βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 119 |
_REGISTRIES = {"v1": registry_v1, "v2": registry_v2, "v3": registry_v3}
|
| 120 |
_version_status: dict[str, str] = {} # "downloading" | "ready" | "error"
|
|
|
|
| 121 |
|
| 122 |
|
| 123 |
def _artifacts_dir() -> Path:
|
|
@@ -125,8 +137,14 @@ def _artifacts_dir() -> Path:
|
|
| 125 |
|
| 126 |
|
| 127 |
def _version_loaded(version: str) -> bool:
|
| 128 |
-
base = _artifacts_dir() / version / "models"
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
|
| 132 |
@app.get("/api/versions", tags=["meta"])
|
|
@@ -157,7 +175,7 @@ async def list_versions():
|
|
| 157 |
|
| 158 |
|
| 159 |
async def _bg_load_version(version: str) -> None:
|
| 160 |
-
import
|
| 161 |
try:
|
| 162 |
proc = await asyncio.create_subprocess_exec(
|
| 163 |
_sys.executable, "scripts/download_models.py", "--version", version,
|
|
@@ -177,6 +195,33 @@ async def _bg_load_version(version: str) -> None:
|
|
| 177 |
log.error("Failed to load version %s: %s", version, exc)
|
| 178 |
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
@app.post("/api/versions/{version}/load", tags=["meta"])
|
| 181 |
async def load_version(version: str, background_tasks: BackgroundTasks):
|
| 182 |
"""Download + activate a model version from HF Hub (runs in background)."""
|
|
@@ -210,6 +255,65 @@ async def get_version_models_meta(version: str):
|
|
| 210 |
return _json.loads(meta_path.read_text(encoding="utf-8"))
|
| 211 |
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
# ββ Include routers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 214 |
from api.routers.predict import router as predict_router, v1_router
|
| 215 |
from api.routers.predict_v2 import router as predict_v2_router
|
|
|
|
| 53 |
|
| 54 |
from api.model_registry import registry, registry_v1, registry_v2, registry_v3
|
| 55 |
from api.schemas import HealthResponse
|
| 56 |
+
from scripts.download_models import select_top_models, DEFAULT_STARTUP_TOP_MODELS
|
| 57 |
from src.utils.logger import get_logger
|
| 58 |
|
| 59 |
log = get_logger(__name__)
|
|
|
|
| 68 |
# ββ Lifespan βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 69 |
@asynccontextmanager
|
| 70 |
async def lifespan(app: FastAPI):
|
| 71 |
+
"""Load only top v3 models on startup; v1/v2 loaded on-demand."""
|
| 72 |
log.info("Loading model registries β¦")
|
| 73 |
+
startup_models = select_top_models("v3")
|
| 74 |
+
if startup_models:
|
| 75 |
+
registry_v3.load_all(only_models=set(startup_models))
|
| 76 |
+
log.info(
|
| 77 |
+
"v3 registry ready β %d models loaded (startup top-%d set: %s)",
|
| 78 |
+
registry_v3.model_count,
|
| 79 |
+
DEFAULT_STARTUP_TOP_MODELS,
|
| 80 |
+
", ".join(startup_models),
|
| 81 |
+
)
|
| 82 |
+
else:
|
| 83 |
+
registry_v3.load_all()
|
| 84 |
+
log.warning("Top-5 selection unavailable; loaded full v3 registry")
|
| 85 |
# v1 and v2 are NOT loaded at startup β download + load on-demand via
|
| 86 |
# POST /api/versions/{v}/load to reduce startup time and memory usage.
|
| 87 |
if _version_loaded("v1"):
|
|
|
|
| 129 |
# ββ Version management βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 130 |
_REGISTRIES = {"v1": registry_v1, "v2": registry_v2, "v3": registry_v3}
|
| 131 |
_version_status: dict[str, str] = {} # "downloading" | "ready" | "error"
|
| 132 |
+
_model_status: dict[str, str] = {} # "vX:model" -> status
|
| 133 |
|
| 134 |
|
| 135 |
def _artifacts_dir() -> Path:
|
|
|
|
| 137 |
|
| 138 |
|
| 139 |
def _version_loaded(version: str) -> bool:
|
| 140 |
+
base = _artifacts_dir() / version / "models"
|
| 141 |
+
if not base.exists():
|
| 142 |
+
return False
|
| 143 |
+
return any(base.rglob("*.joblib")) or any(base.rglob("*.pt")) or any(base.rglob("*.keras"))
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _model_status_key(version: str, model_name: str) -> str:
|
| 147 |
+
return f"{version}:{model_name}"
|
| 148 |
|
| 149 |
|
| 150 |
@app.get("/api/versions", tags=["meta"])
|
|
|
|
| 175 |
|
| 176 |
|
| 177 |
async def _bg_load_version(version: str) -> None:
|
| 178 |
+
import sys as _sys
|
| 179 |
try:
|
| 180 |
proc = await asyncio.create_subprocess_exec(
|
| 181 |
_sys.executable, "scripts/download_models.py", "--version", version,
|
|
|
|
| 195 |
log.error("Failed to load version %s: %s", version, exc)
|
| 196 |
|
| 197 |
|
| 198 |
+
async def _bg_load_model(version: str, model_name: str) -> None:
|
| 199 |
+
import sys as _sys
|
| 200 |
+
|
| 201 |
+
key = _model_status_key(version, model_name)
|
| 202 |
+
try:
|
| 203 |
+
proc = await asyncio.create_subprocess_exec(
|
| 204 |
+
_sys.executable,
|
| 205 |
+
"scripts/download_models.py",
|
| 206 |
+
"--version",
|
| 207 |
+
version,
|
| 208 |
+
"--model",
|
| 209 |
+
model_name,
|
| 210 |
+
stdout=asyncio.subprocess.PIPE,
|
| 211 |
+
stderr=asyncio.subprocess.STDOUT,
|
| 212 |
+
)
|
| 213 |
+
await proc.wait()
|
| 214 |
+
if proc.returncode == 0 and _REGISTRIES[version].load_model(model_name):
|
| 215 |
+
_model_status[key] = "ready"
|
| 216 |
+
log.info("Model %s/%s loaded on demand", version, model_name)
|
| 217 |
+
else:
|
| 218 |
+
_model_status[key] = "error"
|
| 219 |
+
log.error("Failed to download/load model %s/%s", version, model_name)
|
| 220 |
+
except Exception as exc:
|
| 221 |
+
_model_status[key] = "error"
|
| 222 |
+
log.error("Failed to load model %s/%s: %s", version, model_name, exc)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
@app.post("/api/versions/{version}/load", tags=["meta"])
|
| 226 |
async def load_version(version: str, background_tasks: BackgroundTasks):
|
| 227 |
"""Download + activate a model version from HF Hub (runs in background)."""
|
|
|
|
| 255 |
return _json.loads(meta_path.read_text(encoding="utf-8"))
|
| 256 |
|
| 257 |
|
| 258 |
+
@app.get("/api/versions/{version}/models", tags=["meta"])
|
| 259 |
+
async def list_version_models(version: str):
|
| 260 |
+
"""List all models in models.json with disk/memory/load status."""
|
| 261 |
+
if version not in _REGISTRIES:
|
| 262 |
+
raise HTTPException(status_code=400, detail=f"Unknown version '{version}'")
|
| 263 |
+
|
| 264 |
+
reg = _REGISTRIES[version]
|
| 265 |
+
rows = []
|
| 266 |
+
for model_name, info in reg._catalog.items():
|
| 267 |
+
key = _model_status_key(version, model_name)
|
| 268 |
+
status = _model_status.get(key)
|
| 269 |
+
loaded = model_name in reg.models
|
| 270 |
+
if status is None:
|
| 271 |
+
status = "ready" if loaded else ("on_disk" if reg.model_on_disk(model_name) else "not_downloaded")
|
| 272 |
+
rows.append({
|
| 273 |
+
"name": model_name,
|
| 274 |
+
"display_name": info.get("display_name", model_name),
|
| 275 |
+
"family": info.get("family", "unknown"),
|
| 276 |
+
"r2": info.get("r2"),
|
| 277 |
+
"has_file": bool(info.get("file")),
|
| 278 |
+
"on_disk": reg.model_on_disk(model_name),
|
| 279 |
+
"loaded": loaded,
|
| 280 |
+
"status": status,
|
| 281 |
+
})
|
| 282 |
+
return rows
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
@app.post("/api/versions/{version}/models/{model_name}/load", tags=["meta"])
|
| 286 |
+
async def load_single_model(version: str, model_name: str, background_tasks: BackgroundTasks):
|
| 287 |
+
"""Download + activate one model without loading an entire version."""
|
| 288 |
+
if version not in _REGISTRIES:
|
| 289 |
+
raise HTTPException(status_code=400, detail=f"Unknown version '{version}'")
|
| 290 |
+
|
| 291 |
+
reg = _REGISTRIES[version]
|
| 292 |
+
if model_name not in reg._catalog:
|
| 293 |
+
raise HTTPException(status_code=404, detail=f"Unknown model '{model_name}' in {version}")
|
| 294 |
+
|
| 295 |
+
# Virtual models have no direct downloadable artifact.
|
| 296 |
+
if not reg._catalog.get(model_name, {}).get("file"):
|
| 297 |
+
if reg.load_model(model_name):
|
| 298 |
+
return {"status": "ready", "version": version, "model": model_name}
|
| 299 |
+
raise HTTPException(status_code=400, detail=f"Model '{model_name}' cannot be loaded directly")
|
| 300 |
+
|
| 301 |
+
key = _model_status_key(version, model_name)
|
| 302 |
+
if _model_status.get(key) == "downloading":
|
| 303 |
+
return {"status": "downloading", "version": version, "model": model_name}
|
| 304 |
+
|
| 305 |
+
if reg.model_on_disk(model_name):
|
| 306 |
+
if reg.load_model(model_name):
|
| 307 |
+
_model_status[key] = "ready"
|
| 308 |
+
return {"status": "ready", "version": version, "model": model_name}
|
| 309 |
+
_model_status[key] = "error"
|
| 310 |
+
raise HTTPException(status_code=500, detail=f"Model '{model_name}' exists on disk but failed to load")
|
| 311 |
+
|
| 312 |
+
_model_status[key] = "downloading"
|
| 313 |
+
background_tasks.add_task(_bg_load_model, version, model_name)
|
| 314 |
+
return {"status": "downloading", "version": version, "model": model_name}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
# ββ Include routers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 318 |
from api.routers.predict import router as predict_router, v1_router
|
| 319 |
from api.routers.predict_v2 import router as predict_v2_router
|
api/model_registry.py
CHANGED
|
@@ -185,22 +185,49 @@ class ModelRegistry:
|
|
| 185 |
cinfo = self._catalog.get(cname, {})
|
| 186 |
self._ensemble_weights[cname] = cinfo.get("r2", 1.0)
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
# ββ Loading ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 189 |
-
def load_all(self) -> None:
|
| 190 |
"""Scan artifacts/models and load all available model artefacts.
|
| 191 |
|
| 192 |
Safe to call multiple times β subsequent calls are no-ops when the
|
| 193 |
registry is already populated.
|
| 194 |
"""
|
| 195 |
-
if self.models:
|
| 196 |
log.debug("Registry already populated β skipping load_all()")
|
| 197 |
return
|
| 198 |
self._detect_device()
|
| 199 |
self._load_scaler()
|
| 200 |
self.feature_cols = self._load_feature_cols()
|
| 201 |
-
self._load_classical()
|
| 202 |
-
self._load_deep_pytorch()
|
| 203 |
-
self._load_deep_keras()
|
| 204 |
self._register_ensemble()
|
| 205 |
self._choose_default()
|
| 206 |
log.info(
|
|
@@ -217,7 +244,7 @@ class ModelRegistry:
|
|
| 217 |
except ImportError:
|
| 218 |
log.info("torch not installed β deep PyTorch models unavailable")
|
| 219 |
|
| 220 |
-
def _load_classical(self) -> None:
|
| 221 |
"""Eagerly load all sklearn/XGBoost/LightGBM joblib artefacts."""
|
| 222 |
cdir = self._models_dir / "classical"
|
| 223 |
if not cdir.exists():
|
|
@@ -225,6 +252,10 @@ class ModelRegistry:
|
|
| 225 |
return
|
| 226 |
for p in sorted(cdir.glob("*.joblib")):
|
| 227 |
name = p.stem
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
# Skip non-model dumps (param search results, classifiers)
|
| 229 |
if (
|
| 230 |
"best_params" in name
|
|
@@ -308,7 +339,7 @@ class ModelRegistry:
|
|
| 308 |
log.warning("Cannot build PyTorch model '%s': %s", name, exc)
|
| 309 |
return None
|
| 310 |
|
| 311 |
-
def _load_deep_pytorch(self) -> None:
|
| 312 |
"""Load PyTorch .pt state-dict files into reconstructed model instances."""
|
| 313 |
ddir = self._models_dir / "deep"
|
| 314 |
if not ddir.exists():
|
|
@@ -320,6 +351,10 @@ class ModelRegistry:
|
|
| 320 |
return
|
| 321 |
for p in sorted(ddir.glob("*.pt")):
|
| 322 |
name = p.stem
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
n_feat = self._detect_n_feat(p)
|
| 324 |
model = self._build_pytorch_model(name, n_feat=n_feat)
|
| 325 |
if model is None:
|
|
@@ -349,11 +384,20 @@ class ModelRegistry:
|
|
| 349 |
"path": str(p), "load_error": str(exc),
|
| 350 |
}
|
| 351 |
|
| 352 |
-
def _load_deep_keras(self) -> None:
|
| 353 |
"""Load TensorFlow/Keras .keras model files."""
|
| 354 |
ddir = self._models_dir / "deep"
|
| 355 |
if not ddir.exists():
|
| 356 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
try:
|
| 358 |
import tensorflow as tf
|
| 359 |
except ImportError:
|
|
@@ -382,6 +426,10 @@ class ModelRegistry:
|
|
| 382 |
_custom_objects = {}
|
| 383 |
for p in sorted(ddir.glob("*.keras")):
|
| 384 |
name = p.stem
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
try:
|
| 386 |
model = tf.keras.models.load_model(str(p), custom_objects=_custom_objects, safe_mode=False)
|
| 387 |
self.models[name] = model
|
|
|
|
| 185 |
cinfo = self._catalog.get(cname, {})
|
| 186 |
self._ensemble_weights[cname] = cinfo.get("r2", 1.0)
|
| 187 |
|
| 188 |
+
def model_on_disk(self, model_name: str) -> bool:
|
| 189 |
+
"""Return True when the model artifact file exists on disk.
|
| 190 |
+
|
| 191 |
+
Virtual models (e.g., best_ensemble) have no direct file and therefore
|
| 192 |
+
return False.
|
| 193 |
+
"""
|
| 194 |
+
info = self._catalog.get(model_name, {})
|
| 195 |
+
rel = info.get("file")
|
| 196 |
+
if not rel:
|
| 197 |
+
return False
|
| 198 |
+
return (self._artifacts / rel).exists()
|
| 199 |
+
|
| 200 |
+
def load_model(self, model_name: str) -> bool:
|
| 201 |
+
"""Load one model (and ensemble dependencies when applicable).
|
| 202 |
+
|
| 203 |
+
Returns True if the target model is available in memory after loading.
|
| 204 |
+
"""
|
| 205 |
+
if model_name in self.models:
|
| 206 |
+
return True
|
| 207 |
+
|
| 208 |
+
targets: set[str] = {model_name}
|
| 209 |
+
if model_name == "best_ensemble":
|
| 210 |
+
targets.update(self._ensemble_components)
|
| 211 |
+
|
| 212 |
+
self.load_all(only_models=targets)
|
| 213 |
+
return model_name in self.models
|
| 214 |
+
|
| 215 |
# ββ Loading ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 216 |
+
def load_all(self, only_models: set[str] | None = None) -> None:
|
| 217 |
"""Scan artifacts/models and load all available model artefacts.
|
| 218 |
|
| 219 |
Safe to call multiple times β subsequent calls are no-ops when the
|
| 220 |
registry is already populated.
|
| 221 |
"""
|
| 222 |
+
if only_models is None and self.models:
|
| 223 |
log.debug("Registry already populated β skipping load_all()")
|
| 224 |
return
|
| 225 |
self._detect_device()
|
| 226 |
self._load_scaler()
|
| 227 |
self.feature_cols = self._load_feature_cols()
|
| 228 |
+
self._load_classical(only_models=only_models)
|
| 229 |
+
self._load_deep_pytorch(only_models=only_models)
|
| 230 |
+
self._load_deep_keras(only_models=only_models)
|
| 231 |
self._register_ensemble()
|
| 232 |
self._choose_default()
|
| 233 |
log.info(
|
|
|
|
| 244 |
except ImportError:
|
| 245 |
log.info("torch not installed β deep PyTorch models unavailable")
|
| 246 |
|
| 247 |
+
def _load_classical(self, only_models: set[str] | None = None) -> None:
|
| 248 |
"""Eagerly load all sklearn/XGBoost/LightGBM joblib artefacts."""
|
| 249 |
cdir = self._models_dir / "classical"
|
| 250 |
if not cdir.exists():
|
|
|
|
| 252 |
return
|
| 253 |
for p in sorted(cdir.glob("*.joblib")):
|
| 254 |
name = p.stem
|
| 255 |
+
if only_models is not None and name not in only_models:
|
| 256 |
+
continue
|
| 257 |
+
if name in self.models:
|
| 258 |
+
continue
|
| 259 |
# Skip non-model dumps (param search results, classifiers)
|
| 260 |
if (
|
| 261 |
"best_params" in name
|
|
|
|
| 339 |
log.warning("Cannot build PyTorch model '%s': %s", name, exc)
|
| 340 |
return None
|
| 341 |
|
| 342 |
+
def _load_deep_pytorch(self, only_models: set[str] | None = None) -> None:
|
| 343 |
"""Load PyTorch .pt state-dict files into reconstructed model instances."""
|
| 344 |
ddir = self._models_dir / "deep"
|
| 345 |
if not ddir.exists():
|
|
|
|
| 351 |
return
|
| 352 |
for p in sorted(ddir.glob("*.pt")):
|
| 353 |
name = p.stem
|
| 354 |
+
if only_models is not None and name not in only_models:
|
| 355 |
+
continue
|
| 356 |
+
if name in self.models:
|
| 357 |
+
continue
|
| 358 |
n_feat = self._detect_n_feat(p)
|
| 359 |
model = self._build_pytorch_model(name, n_feat=n_feat)
|
| 360 |
if model is None:
|
|
|
|
| 384 |
"path": str(p), "load_error": str(exc),
|
| 385 |
}
|
| 386 |
|
| 387 |
+
def _load_deep_keras(self, only_models: set[str] | None = None) -> None:
|
| 388 |
"""Load TensorFlow/Keras .keras model files."""
|
| 389 |
ddir = self._models_dir / "deep"
|
| 390 |
if not ddir.exists():
|
| 391 |
return
|
| 392 |
+
|
| 393 |
+
if only_models is not None:
|
| 394 |
+
requested_keras = any(
|
| 395 |
+
self._catalog.get(m, {}).get("family") == "deep_keras"
|
| 396 |
+
for m in only_models
|
| 397 |
+
)
|
| 398 |
+
if not requested_keras:
|
| 399 |
+
return
|
| 400 |
+
|
| 401 |
try:
|
| 402 |
import tensorflow as tf
|
| 403 |
except ImportError:
|
|
|
|
| 426 |
_custom_objects = {}
|
| 427 |
for p in sorted(ddir.glob("*.keras")):
|
| 428 |
name = p.stem
|
| 429 |
+
if only_models is not None and name not in only_models:
|
| 430 |
+
continue
|
| 431 |
+
if name in self.models:
|
| 432 |
+
continue
|
| 433 |
try:
|
| 434 |
model = tf.keras.models.load_model(str(p), custom_objects=_custom_objects, safe_mode=False)
|
| 435 |
self.models[name] = model
|
frontend/src/api.ts
CHANGED
|
@@ -86,6 +86,25 @@ export const fetchVersions = () =>
|
|
| 86 |
export const loadVersion = (version: string) =>
|
| 87 |
baseApi.post<{ status: string; version: string }>(`/versions/${version}/load`).then((r) => r.data);
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
/** Models metadata JSON for a specific version. */
|
| 90 |
export const fetchVersionModelsMeta = (version: string) =>
|
| 91 |
baseApi.get<any>(`/versions/${version}/models-meta`).then((r) => r.data);
|
|
|
|
| 86 |
export const loadVersion = (version: string) =>
|
| 87 |
baseApi.post<{ status: string; version: string }>(`/versions/${version}/load`).then((r) => r.data);
|
| 88 |
|
| 89 |
+
export interface VersionModelInfo {
|
| 90 |
+
name: string;
|
| 91 |
+
display_name: string;
|
| 92 |
+
family: string;
|
| 93 |
+
r2: number | null;
|
| 94 |
+
has_file: boolean;
|
| 95 |
+
on_disk: boolean;
|
| 96 |
+
loaded: boolean;
|
| 97 |
+
status: "ready" | "on_disk" | "not_downloaded" | "downloading" | "error";
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export const fetchVersionModels = (version: string) =>
|
| 101 |
+
baseApi.get<VersionModelInfo[]>(`/versions/${version}/models`).then((r) => r.data);
|
| 102 |
+
|
| 103 |
+
export const loadVersionModel = (version: string, model: string) =>
|
| 104 |
+
baseApi
|
| 105 |
+
.post<{ status: string; version: string; model: string }>(`/versions/${version}/models/${model}/load`)
|
| 106 |
+
.then((r) => r.data);
|
| 107 |
+
|
| 108 |
/** Models metadata JSON for a specific version. */
|
| 109 |
export const fetchVersionModelsMeta = (version: string) =>
|
| 110 |
baseApi.get<any>(`/versions/${version}/models-meta`).then((r) => r.data);
|
frontend/src/components/VersionSelector.tsx
CHANGED
|
@@ -13,7 +13,14 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
| 13 |
import {
|
| 14 |
ChevronRight, Download, Check, RefreshCw, AlertCircle, Layers, HardDrive,
|
| 15 |
} from "lucide-react";
|
| 16 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
interface Props {
|
| 19 |
activeVersion: "v1" | "v2" | "v3";
|
|
@@ -24,6 +31,9 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 24 |
const [open, setOpen] = useState(false);
|
| 25 |
const [versions, setVersions] = useState<VersionInfo[]>([]);
|
| 26 |
const [busy, setBusy] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
| 27 |
const menuRef = useRef<HTMLDivElement>(null);
|
| 28 |
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
| 29 |
|
|
@@ -40,18 +50,33 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 40 |
// Poll while a download is in progress
|
| 41 |
useEffect(() => {
|
| 42 |
const hasDownloading = versions.some((v) => v.status === "downloading");
|
|
|
|
|
|
|
| 43 |
if (hasDownloading && !pollRef.current) {
|
| 44 |
pollRef.current = setInterval(refresh, 2500);
|
| 45 |
}
|
| 46 |
-
if (
|
|
|
|
|
|
|
|
|
|
| 47 |
clearInterval(pollRef.current);
|
| 48 |
pollRef.current = null;
|
| 49 |
setBusy(null);
|
|
|
|
| 50 |
}
|
| 51 |
return () => {
|
| 52 |
if (pollRef.current) clearInterval(pollRef.current);
|
| 53 |
};
|
| 54 |
-
}, [versions, refresh]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
// Close on outside click
|
| 57 |
useEffect(() => {
|
|
@@ -86,6 +111,33 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 86 |
setOpen(false);
|
| 87 |
};
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
// Auto-switch when download completes
|
| 90 |
useEffect(() => {
|
| 91 |
if (busy) {
|
|
@@ -156,98 +208,153 @@ export default function VersionSelector({ activeVersion, onSwitch }: Props) {
|
|
| 156 |
const isLoaded = v.loaded && v.model_count > 0;
|
| 157 |
const isOnDisk = v.status === "on_disk" || (v.on_disk && !isLoaded && !isDownloading && !isError);
|
| 158 |
const isNotDownloaded = v.status === "not_downloaded" && !v.on_disk;
|
|
|
|
|
|
|
| 159 |
|
| 160 |
return (
|
| 161 |
-
<div
|
| 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 |
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
-
{/* Error retry */}
|
| 239 |
-
{isError && !isDownloading && (
|
| 240 |
<button
|
| 241 |
-
onClick={() =>
|
| 242 |
-
className="
|
| 243 |
-
|
| 244 |
-
title="Retry download"
|
| 245 |
>
|
| 246 |
-
|
| 247 |
-
Retry
|
| 248 |
</button>
|
| 249 |
-
|
| 250 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
</div>
|
| 252 |
);
|
| 253 |
})}
|
|
|
|
| 13 |
import {
|
| 14 |
ChevronRight, Download, Check, RefreshCw, AlertCircle, Layers, HardDrive,
|
| 15 |
} from "lucide-react";
|
| 16 |
+
import {
|
| 17 |
+
fetchVersions,
|
| 18 |
+
loadVersion,
|
| 19 |
+
VersionInfo,
|
| 20 |
+
fetchVersionModels,
|
| 21 |
+
loadVersionModel,
|
| 22 |
+
VersionModelInfo,
|
| 23 |
+
} from "../api";
|
| 24 |
|
| 25 |
interface Props {
|
| 26 |
activeVersion: "v1" | "v2" | "v3";
|
|
|
|
| 31 |
const [open, setOpen] = useState(false);
|
| 32 |
const [versions, setVersions] = useState<VersionInfo[]>([]);
|
| 33 |
const [busy, setBusy] = useState<string | null>(null);
|
| 34 |
+
const [modelBusy, setModelBusy] = useState<string | null>(null);
|
| 35 |
+
const [expandedVersion, setExpandedVersion] = useState<string | null>(null);
|
| 36 |
+
const [versionModels, setVersionModels] = useState<Record<string, VersionModelInfo[]>>({});
|
| 37 |
const menuRef = useRef<HTMLDivElement>(null);
|
| 38 |
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
| 39 |
|
|
|
|
| 50 |
// Poll while a download is in progress
|
| 51 |
useEffect(() => {
|
| 52 |
const hasDownloading = versions.some((v) => v.status === "downloading");
|
| 53 |
+
const hasModelDownloading = Object.values(versionModels)
|
| 54 |
+
.some((rows) => rows.some((m) => m.status === "downloading"));
|
| 55 |
if (hasDownloading && !pollRef.current) {
|
| 56 |
pollRef.current = setInterval(refresh, 2500);
|
| 57 |
}
|
| 58 |
+
if (hasModelDownloading && !pollRef.current) {
|
| 59 |
+
pollRef.current = setInterval(refresh, 2500);
|
| 60 |
+
}
|
| 61 |
+
if (!hasDownloading && !hasModelDownloading && pollRef.current) {
|
| 62 |
clearInterval(pollRef.current);
|
| 63 |
pollRef.current = null;
|
| 64 |
setBusy(null);
|
| 65 |
+
setModelBusy(null);
|
| 66 |
}
|
| 67 |
return () => {
|
| 68 |
if (pollRef.current) clearInterval(pollRef.current);
|
| 69 |
};
|
| 70 |
+
}, [versions, versionModels, refresh]);
|
| 71 |
+
|
| 72 |
+
useEffect(() => {
|
| 73 |
+
if (!expandedVersion) return;
|
| 74 |
+
fetchVersionModels(expandedVersion)
|
| 75 |
+
.then((rows) => {
|
| 76 |
+
setVersionModels((prev) => ({ ...prev, [expandedVersion]: rows }));
|
| 77 |
+
})
|
| 78 |
+
.catch(() => {});
|
| 79 |
+
}, [expandedVersion, versions]);
|
| 80 |
|
| 81 |
// Close on outside click
|
| 82 |
useEffect(() => {
|
|
|
|
| 111 |
setOpen(false);
|
| 112 |
};
|
| 113 |
|
| 114 |
+
const handleExpandVersion = async (version: string) => {
|
| 115 |
+
if (expandedVersion === version) {
|
| 116 |
+
setExpandedVersion(null);
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
setExpandedVersion(version);
|
| 120 |
+
try {
|
| 121 |
+
const rows = await fetchVersionModels(version);
|
| 122 |
+
setVersionModels((prev) => ({ ...prev, [version]: rows }));
|
| 123 |
+
} catch {
|
| 124 |
+
// ignore
|
| 125 |
+
}
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const handleLoadModel = async (version: string, model: string) => {
|
| 129 |
+
const key = `${version}:${model}`;
|
| 130 |
+
setModelBusy(key);
|
| 131 |
+
try {
|
| 132 |
+
await loadVersionModel(version, model);
|
| 133 |
+
const rows = await fetchVersionModels(version);
|
| 134 |
+
setVersionModels((prev) => ({ ...prev, [version]: rows }));
|
| 135 |
+
refresh();
|
| 136 |
+
} catch {
|
| 137 |
+
// ignore
|
| 138 |
+
}
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
// Auto-switch when download completes
|
| 142 |
useEffect(() => {
|
| 143 |
if (busy) {
|
|
|
|
| 208 |
const isLoaded = v.loaded && v.model_count > 0;
|
| 209 |
const isOnDisk = v.status === "on_disk" || (v.on_disk && !isLoaded && !isDownloading && !isError);
|
| 210 |
const isNotDownloaded = v.status === "not_downloaded" && !v.on_disk;
|
| 211 |
+
const isExpanded = expandedVersion === v.id;
|
| 212 |
+
const models = versionModels[v.id] ?? [];
|
| 213 |
|
| 214 |
return (
|
| 215 |
+
<div key={v.id} className="border-t border-gray-800/80 first:border-t-0">
|
| 216 |
+
<div
|
| 217 |
+
className="flex items-center justify-between px-3 py-2.5
|
| 218 |
+
hover:bg-gray-800/60 transition-colors"
|
| 219 |
+
>
|
| 220 |
+
<div className="flex-1 min-w-0">
|
| 221 |
+
<span className="text-sm font-medium text-gray-200">{v.display}</span>
|
| 222 |
+
{v.features && (
|
| 223 |
+
<span className="ml-2 text-xs text-gray-500">
|
| 224 |
+
{v.features}f
|
| 225 |
+
</span>
|
| 226 |
+
)}
|
| 227 |
+
{isLoaded && (
|
| 228 |
+
<span className="ml-2 text-xs text-gray-500">
|
| 229 |
+
{v.model_count} models
|
| 230 |
+
</span>
|
| 231 |
+
)}
|
| 232 |
+
{isOnDisk && (
|
| 233 |
+
<span className="ml-2 text-xs text-yellow-400">on disk</span>
|
| 234 |
+
)}
|
| 235 |
+
{isError && (
|
| 236 |
+
<span className="ml-2 text-xs text-red-400">error</span>
|
| 237 |
+
)}
|
| 238 |
+
{isDownloading && (
|
| 239 |
+
<span className="ml-2 text-xs text-yellow-400 animate-pulse">
|
| 240 |
+
downloading...
|
| 241 |
+
</span>
|
| 242 |
+
)}
|
| 243 |
+
{isNotDownloaded && (
|
| 244 |
+
<span className="ml-2 text-xs text-gray-600">not downloaded</span>
|
| 245 |
+
)}
|
| 246 |
+
</div>
|
| 247 |
|
| 248 |
+
<div className="flex items-center gap-1 shrink-0 ml-2">
|
| 249 |
+
{isLoaded && (
|
| 250 |
+
<button
|
| 251 |
+
onClick={() => handleSwitch(v.id)}
|
| 252 |
+
className="px-2.5 py-1 rounded text-xs font-medium
|
| 253 |
+
bg-green-700 hover:bg-green-600 text-white transition-colors"
|
| 254 |
+
title={`Switch to ${v.display}`}
|
| 255 |
+
>
|
| 256 |
+
Switch
|
| 257 |
+
</button>
|
| 258 |
+
)}
|
|
|
|
| 259 |
|
| 260 |
+
{isOnDisk && !isDownloading && (
|
| 261 |
+
<button
|
| 262 |
+
onClick={() => handleDownloadOrLoad(v.id)}
|
| 263 |
+
className="flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium
|
| 264 |
+
bg-blue-700 hover:bg-blue-600 text-white transition-colors"
|
| 265 |
+
title={`Load ${v.display} into memory`}
|
| 266 |
+
>
|
| 267 |
+
<HardDrive className="w-3 h-3" />
|
| 268 |
+
Load
|
| 269 |
+
</button>
|
| 270 |
+
)}
|
|
|
|
| 271 |
|
| 272 |
+
{isNotDownloaded && !isDownloading && !isError && (
|
| 273 |
+
<button
|
| 274 |
+
onClick={() => handleDownloadOrLoad(v.id)}
|
| 275 |
+
className="flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium
|
| 276 |
+
bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white transition-colors"
|
| 277 |
+
title={`Download ${v.display} from HF Hub`}
|
| 278 |
+
>
|
| 279 |
+
<Download className="w-3 h-3" />
|
| 280 |
+
Download
|
| 281 |
+
</button>
|
| 282 |
+
)}
|
| 283 |
+
|
| 284 |
+
{isDownloading && (
|
| 285 |
+
<RefreshCw className="w-3.5 h-3.5 text-yellow-400 animate-spin" />
|
| 286 |
+
)}
|
| 287 |
|
| 288 |
+
{isError && !isDownloading && (
|
| 289 |
+
<button
|
| 290 |
+
onClick={() => handleDownloadOrLoad(v.id)}
|
| 291 |
+
className="flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium
|
| 292 |
+
bg-gray-700 hover:bg-red-700 text-red-400 hover:text-white transition-colors"
|
| 293 |
+
title="Retry download"
|
| 294 |
+
>
|
| 295 |
+
<AlertCircle className="w-3 h-3" />
|
| 296 |
+
Retry
|
| 297 |
+
</button>
|
| 298 |
+
)}
|
| 299 |
|
|
|
|
|
|
|
| 300 |
<button
|
| 301 |
+
onClick={() => handleExpandVersion(v.id)}
|
| 302 |
+
className="px-2 py-1 rounded text-xs font-medium bg-gray-800 hover:bg-gray-700 text-gray-300 transition-colors"
|
| 303 |
+
title="Show model-level controls"
|
|
|
|
| 304 |
>
|
| 305 |
+
{isExpanded ? "Hide Models" : "Models"}
|
|
|
|
| 306 |
</button>
|
| 307 |
+
</div>
|
| 308 |
</div>
|
| 309 |
+
|
| 310 |
+
{isExpanded && (
|
| 311 |
+
<div className="px-3 pb-3">
|
| 312 |
+
<div className="rounded-lg border border-gray-800 bg-gray-950/50 overflow-hidden">
|
| 313 |
+
<div className="px-2 py-1.5 text-[11px] text-gray-500 border-b border-gray-800">
|
| 314 |
+
Download / load models individually
|
| 315 |
+
</div>
|
| 316 |
+
<div className="max-h-44 overflow-auto">
|
| 317 |
+
{models.length === 0 && (
|
| 318 |
+
<div className="px-2 py-2 text-xs text-gray-600">No model metadata available</div>
|
| 319 |
+
)}
|
| 320 |
+
{[...models]
|
| 321 |
+
.sort((a, b) => (b.r2 ?? -1) - (a.r2 ?? -1))
|
| 322 |
+
.map((m) => {
|
| 323 |
+
const key = `${v.id}:${m.name}`;
|
| 324 |
+
const downloading = m.status === "downloading" || modelBusy === key;
|
| 325 |
+
const loaded = m.loaded;
|
| 326 |
+
const onDisk = m.on_disk;
|
| 327 |
+
const canDownload = m.has_file;
|
| 328 |
+
return (
|
| 329 |
+
<div key={m.name} className="flex items-center justify-between px-2 py-1.5 border-b border-gray-900 last:border-b-0">
|
| 330 |
+
<div className="min-w-0 pr-2">
|
| 331 |
+
<div className="text-xs text-gray-200 truncate">{m.display_name}</div>
|
| 332 |
+
<div className="text-[11px] text-gray-500 truncate">
|
| 333 |
+
{m.family}
|
| 334 |
+
{m.r2 != null ? ` β’ R2 ${m.r2.toFixed(3)}` : ""}
|
| 335 |
+
{loaded ? " β’ loaded" : onDisk ? " β’ on disk" : ""}
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
<div className="flex items-center gap-1">
|
| 339 |
+
{loaded && <span className="text-[11px] text-green-400">ready</span>}
|
| 340 |
+
{!loaded && canDownload && !downloading && (
|
| 341 |
+
<button
|
| 342 |
+
onClick={() => handleLoadModel(v.id, m.name)}
|
| 343 |
+
className="px-2 py-0.5 rounded text-[11px] font-medium bg-blue-700 hover:bg-blue-600 text-white"
|
| 344 |
+
>
|
| 345 |
+
{onDisk ? "Load" : "Download"}
|
| 346 |
+
</button>
|
| 347 |
+
)}
|
| 348 |
+
{downloading && <RefreshCw className="w-3 h-3 text-yellow-400 animate-spin" />}
|
| 349 |
+
{!canDownload && <span className="text-[11px] text-gray-500">virtual</span>}
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
);
|
| 353 |
+
})}
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
)}
|
| 358 |
</div>
|
| 359 |
);
|
| 360 |
})}
|
scripts/download_models.py
CHANGED
|
@@ -20,6 +20,7 @@ Local layout after download (local_dir = ARTIFACTS_DIR):
|
|
| 20 |
|
| 21 |
import os
|
| 22 |
import sys
|
|
|
|
| 23 |
from pathlib import Path
|
| 24 |
|
| 25 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -34,6 +35,7 @@ HF_TOKEN = os.getenv("HF_TOKEN", "")
|
|
| 34 |
# HF repo stores v1/ and v2/ at root β local_dir=ARTIFACTS_DIR maps them to
|
| 35 |
# artifacts/v1/... and artifacts/v2/...
|
| 36 |
ARTIFACTS_DIR = Path(__file__).resolve().parent.parent / "artifacts"
|
|
|
|
| 37 |
|
| 38 |
# Sentinel file β written after a successful full download
|
| 39 |
SENTINEL = ARTIFACTS_DIR / ".hf_downloaded"
|
|
@@ -101,6 +103,110 @@ def download_version(version: str) -> None:
|
|
| 101 |
print(f"[download_models] {version}/ ready")
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def download_models_meta_only(versions: list[str]) -> None:
|
| 105 |
"""Download only models.json for listed versions."""
|
| 106 |
_ensure_hub()
|
|
@@ -131,8 +237,15 @@ def main() -> None:
|
|
| 131 |
parser = argparse.ArgumentParser()
|
| 132 |
parser.add_argument("--version", default=None,
|
| 133 |
help="Download only this version, e.g. v1 or v2")
|
|
|
|
|
|
|
| 134 |
args = parser.parse_args()
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
if args.version:
|
| 137 |
if version_loaded(args.version):
|
| 138 |
print(f"[download_models] {args.version} already present β skipping")
|
|
@@ -144,12 +257,18 @@ def main() -> None:
|
|
| 144 |
# latest (v3) heavy model artifacts at startup.
|
| 145 |
download_models_meta_only(["v1", "v2", "v3"])
|
| 146 |
|
| 147 |
-
# Then fetch
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
return
|
| 151 |
|
| 152 |
-
|
|
|
|
| 153 |
|
| 154 |
|
| 155 |
if __name__ == "__main__":
|
|
|
|
| 20 |
|
| 21 |
import os
|
| 22 |
import sys
|
| 23 |
+
import json
|
| 24 |
from pathlib import Path
|
| 25 |
|
| 26 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 35 |
# HF repo stores v1/ and v2/ at root β local_dir=ARTIFACTS_DIR maps them to
|
| 36 |
# artifacts/v1/... and artifacts/v2/...
|
| 37 |
ARTIFACTS_DIR = Path(__file__).resolve().parent.parent / "artifacts"
|
| 38 |
+
DEFAULT_STARTUP_TOP_MODELS = 3
|
| 39 |
|
| 40 |
# Sentinel file β written after a successful full download
|
| 41 |
SENTINEL = ARTIFACTS_DIR / ".hf_downloaded"
|
|
|
|
| 103 |
print(f"[download_models] {version}/ ready")
|
| 104 |
|
| 105 |
|
| 106 |
+
def _read_models_meta(version: str) -> dict:
|
| 107 |
+
meta_path = ARTIFACTS_DIR / version / "models.json"
|
| 108 |
+
if not meta_path.exists():
|
| 109 |
+
return {}
|
| 110 |
+
try:
|
| 111 |
+
return json.loads(meta_path.read_text(encoding="utf-8"))
|
| 112 |
+
except Exception:
|
| 113 |
+
return {}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _model_score(info: dict) -> float:
|
| 117 |
+
"""Return a comparable score for a model metadata record.
|
| 118 |
+
|
| 119 |
+
Priority:
|
| 120 |
+
1) R2 (higher is better)
|
| 121 |
+
2) f1_weighted / f1_macro (higher is better)
|
| 122 |
+
3) within_5pct (higher is better)
|
| 123 |
+
4) mae (lower is better; converted to negative contribution)
|
| 124 |
+
"""
|
| 125 |
+
if not isinstance(info, dict):
|
| 126 |
+
return float("-inf")
|
| 127 |
+
|
| 128 |
+
r2 = info.get("r2")
|
| 129 |
+
if isinstance(r2, (int, float)):
|
| 130 |
+
return float(r2)
|
| 131 |
+
|
| 132 |
+
# Fallback when r2 is unavailable.
|
| 133 |
+
f1w = info.get("f1_weighted")
|
| 134 |
+
if isinstance(f1w, (int, float)):
|
| 135 |
+
return float(f1w)
|
| 136 |
+
|
| 137 |
+
f1m = info.get("f1_macro")
|
| 138 |
+
if isinstance(f1m, (int, float)):
|
| 139 |
+
return float(f1m)
|
| 140 |
+
|
| 141 |
+
w5 = info.get("within_5pct")
|
| 142 |
+
if isinstance(w5, (int, float)):
|
| 143 |
+
return float(w5) / 100.0
|
| 144 |
+
|
| 145 |
+
mae = info.get("mae")
|
| 146 |
+
if isinstance(mae, (int, float)):
|
| 147 |
+
return -float(mae)
|
| 148 |
+
|
| 149 |
+
return float("-inf")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def select_top_models(version: str, top_k: int = DEFAULT_STARTUP_TOP_MODELS) -> list[str]:
|
| 153 |
+
"""Pick top-k concrete models from models.json by best available metric score.
|
| 154 |
+
|
| 155 |
+
Excludes virtual entries without a model file.
|
| 156 |
+
"""
|
| 157 |
+
meta = _read_models_meta(version)
|
| 158 |
+
models = meta.get("models", {}) if isinstance(meta, dict) else {}
|
| 159 |
+
scored: list[tuple[float, str]] = []
|
| 160 |
+
for name, info in models.items():
|
| 161 |
+
if not isinstance(info, dict):
|
| 162 |
+
continue
|
| 163 |
+
if not info.get("file"):
|
| 164 |
+
continue
|
| 165 |
+
score = _model_score(info)
|
| 166 |
+
if score != float("-inf"):
|
| 167 |
+
scored.append((score, name))
|
| 168 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 169 |
+
return [name for _, name in scored[:top_k]]
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _collect_model_patterns(version: str, model_names: list[str]) -> list[str]:
|
| 173 |
+
"""Build allow_patterns for model files + required shared artifacts."""
|
| 174 |
+
meta = _read_models_meta(version)
|
| 175 |
+
models = meta.get("models", {}) if isinstance(meta, dict) else {}
|
| 176 |
+
patterns = {
|
| 177 |
+
f"{version}/models.json",
|
| 178 |
+
f"{version}/features/train_split.csv",
|
| 179 |
+
f"{version}/scalers/features_standard.joblib",
|
| 180 |
+
f"{version}/scalers/sequence_scaler.joblib",
|
| 181 |
+
f"{version}/scalers/features_minmax.joblib",
|
| 182 |
+
}
|
| 183 |
+
for model_name in model_names:
|
| 184 |
+
info = models.get(model_name, {})
|
| 185 |
+
rel_file = info.get("file") if isinstance(info, dict) else None
|
| 186 |
+
if rel_file:
|
| 187 |
+
patterns.add(f"{version}/{rel_file}")
|
| 188 |
+
return sorted(patterns)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def download_models(version: str, model_names: list[str]) -> None:
|
| 192 |
+
"""Download selected model artifacts for one version."""
|
| 193 |
+
_ensure_hub()
|
| 194 |
+
from huggingface_hub import snapshot_download
|
| 195 |
+
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 196 |
+
allow = _collect_model_patterns(version, model_names)
|
| 197 |
+
print(f"[download_models] Downloading selected models for {version}: {model_names}")
|
| 198 |
+
snapshot_download(**_hf_kwargs(
|
| 199 |
+
allow_patterns=allow,
|
| 200 |
+
ignore_patterns=["*.log"],
|
| 201 |
+
))
|
| 202 |
+
print(f"[download_models] Selected model artifacts ready for {version}")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def download_model(version: str, model_name: str) -> None:
|
| 206 |
+
"""Download one model artifact (plus required metadata/scalers)."""
|
| 207 |
+
download_models(version, [model_name])
|
| 208 |
+
|
| 209 |
+
|
| 210 |
def download_models_meta_only(versions: list[str]) -> None:
|
| 211 |
"""Download only models.json for listed versions."""
|
| 212 |
_ensure_hub()
|
|
|
|
| 237 |
parser = argparse.ArgumentParser()
|
| 238 |
parser.add_argument("--version", default=None,
|
| 239 |
help="Download only this version, e.g. v1 or v2")
|
| 240 |
+
parser.add_argument("--model", default=None,
|
| 241 |
+
help="Download only this model key inside --version")
|
| 242 |
args = parser.parse_args()
|
| 243 |
|
| 244 |
+
if args.version and args.model:
|
| 245 |
+
download_models_meta_only([args.version])
|
| 246 |
+
download_model(args.version, args.model)
|
| 247 |
+
return
|
| 248 |
+
|
| 249 |
if args.version:
|
| 250 |
if version_loaded(args.version):
|
| 251 |
print(f"[download_models] {args.version} already present β skipping")
|
|
|
|
| 257 |
# latest (v3) heavy model artifacts at startup.
|
| 258 |
download_models_meta_only(["v1", "v2", "v3"])
|
| 259 |
|
| 260 |
+
# Then fetch only top-N (default=3) v3 model artifacts by best score.
|
| 261 |
+
top_models = select_top_models("v3")
|
| 262 |
+
if not top_models:
|
| 263 |
+
print("[download_models] Could not resolve top models from models.json, falling back to full v3 download")
|
| 264 |
+
if already_downloaded("v3"):
|
| 265 |
+
print("[download_models] v3 artifacts already present β skipping download")
|
| 266 |
+
return
|
| 267 |
+
download_version("v3")
|
| 268 |
return
|
| 269 |
|
| 270 |
+
print(f"[download_models] Default startup model set (v3 top-{DEFAULT_STARTUP_TOP_MODELS}): {top_models}")
|
| 271 |
+
download_models("v3", top_models)
|
| 272 |
|
| 273 |
|
| 274 |
if __name__ == "__main__":
|