diff --git a/services/convsim-core/convsim-core.spec b/services/convsim-core/convsim-core.spec index 886ce9e3..9afb62fe 100644 --- a/services/convsim-core/convsim-core.spec +++ b/services/convsim-core/convsim-core.spec @@ -58,6 +58,10 @@ a = Analysis( # These land at sys._MEIPASS/packs/official/ and are found by config.py # when it detects the frozen (PyInstaller) environment. (str(_REPO_ROOT / 'packs' / 'official'), 'packs/official'), + # Model registry catalogue (names, licenses, pinned URLs + SHA-256). + # Seeded into SQLite at startup; without it a fresh install has no + # models to offer and "Set me up" dead-ends with MODEL_NOT_FOUND. + (str(_REPO_ROOT / 'model-registry' / 'registry.yaml'), 'model-registry'), ], hiddenimports=[ # uvicorn resolves protocol and loop implementations at runtime. diff --git a/services/convsim-core/convsim_core/app.py b/services/convsim-core/convsim_core/app.py index cc1d5564..b3572b30 100644 --- a/services/convsim-core/convsim_core/app.py +++ b/services/convsim-core/convsim_core/app.py @@ -117,6 +117,28 @@ async def lifespan(app: FastAPI): supervisor.register(kokoro_sidecar) app.state.supervisor = supervisor seed_official_packs(config, db.connection()) + # Seed the model registry from the bundled catalogue so a fresh + # install has models to offer. Idempotent upsert; failures must not + # block startup (the models UI degrades to user-supplied GGUF only). + try: + from convsim_core.services.model_registry_service import ( + load_and_persist_registry, + ) + + _registry_path = Path(config.model_registry_path) + if _registry_path.is_file(): + load_and_persist_registry(db.connection(), _registry_path) + else: + import logging as _logging + + _logging.getLogger(__name__).warning( + "Model registry file not found at %s — model catalogue will be empty", + _registry_path, + ) + except Exception as _exc: # noqa: BLE001 + import logging as _logging + + _logging.getLogger(__name__).warning("Model registry seeding failed: %s", _exc) # Re-drive any one-click install pipeline that a crash/kill left mid-flight # so the download resumes from its .part offset instead of freezing. setup_install_router.resume_orphaned_jobs(app) diff --git a/services/convsim-core/convsim_core/config.py b/services/convsim-core/convsim_core/config.py index af1078f5..9142f27f 100644 --- a/services/convsim-core/convsim_core/config.py +++ b/services/convsim-core/convsim_core/config.py @@ -40,6 +40,21 @@ def _default_official_packs_dir() -> str: _DEFAULT_OFFICIAL_PACKS_DIR = _default_official_packs_dir() +def _default_model_registry_path() -> str: + """Resolve the read-only bundled model registry YAML. + + Frozen builds embed it at ``sys._MEIPASS/model-registry/registry.yaml`` + (see convsim-core.spec); development resolves relative to the repo root. + """ + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + return str(Path(meipass) / "model-registry" / "registry.yaml") + return str(Path(__file__).resolve().parents[3] / "model-registry" / "registry.yaml") + + +_DEFAULT_MODEL_REGISTRY_PATH = _default_model_registry_path() + + class ServiceConfig(BaseSettings): """Runtime configuration for the convsim-core process. @@ -64,6 +79,7 @@ class ServiceConfig(BaseSettings): # Read-only official packs served (browse-only) by the Creator Workbench. # Defaults to the repo's bundled packs/official directory. official_packs_dir: str = _DEFAULT_OFFICIAL_PACKS_DIR + model_registry_path: str = _DEFAULT_MODEL_REGISTRY_PATH # Set CONVSIM_LOCAL_DEV_PACKS_DIR to a directory that contains in-progress # pack folders. The /api/packs/import/folder endpoint only accepts source # paths that fall within packs_dir or this directory; leaving it unset diff --git a/services/convsim-core/tests/test_model_registry.py b/services/convsim-core/tests/test_model_registry.py index c5e216cb..9108fc0a 100644 --- a/services/convsim-core/tests/test_model_registry.py +++ b/services/convsim-core/tests/test_model_registry.py @@ -482,10 +482,28 @@ def test_get_models_returns_200(client): assert resp.status_code == 200 -def test_get_models_empty_database_returns_empty_list(client): - body = client.get("/api/models").json() - assert body["registry"] == [] - assert body["total"] == 0 +def test_get_models_empty_database_returns_empty_list(tmp_path): + # Startup now seeds the registry from the bundled catalogue by default, so + # an empty registry requires pointing the config at a missing file. The + # endpoint must still degrade to an empty list rather than erroring. + from convsim_core.app import create_app + from convsim_core.config import ServiceConfig + from fastapi.testclient import TestClient + + config = ServiceConfig( + host="127.0.0.1", + port=7355, + data_dir=str(tmp_path / "data"), + log_dir=str(tmp_path / "logs"), + db_dir=str(tmp_path / "db"), + packs_dir=str(tmp_path / "packs"), + model_registry_path=str(tmp_path / "missing" / "registry.yaml"), + ) + app = create_app(config) + with TestClient(app) as empty_client: + body = empty_client.get("/api/models").json() + assert body["registry"] == [] + assert body["total"] == 0 def test_get_models_after_registry_load_returns_sorted_entries(client): diff --git a/services/convsim-core/tests/test_model_registry_seeding.py b/services/convsim-core/tests/test_model_registry_seeding.py new file mode 100644 index 00000000..fe9e3701 --- /dev/null +++ b/services/convsim-core/tests/test_model_registry_seeding.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Startup must seed the model registry from the bundled catalogue. + +A fresh profile previously had an EMPTY model_registry table — nothing in the +app ever called ``load_and_persist_registry`` — so the Welcome screen had no +starter model to offer and "Set me up" dead-ended with MODEL_NOT_FOUND before +any download began. These tests pin the seeded startup state a new user's +first session depends on. +""" +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from convsim_core.app import create_app +from convsim_core.config import ServiceConfig + +_REPO_ROOT = Path(__file__).resolve().parents[3] + + +@pytest.fixture() +def fresh_client(tmp_path): + config = ServiceConfig( + host="127.0.0.1", + port=7355, + data_dir=str(tmp_path / "data"), + log_dir=str(tmp_path / "logs"), + db_dir=str(tmp_path / "db"), + packs_dir=str(tmp_path / "packs"), + model_registry_path=str(_REPO_ROOT / "model-registry" / "registry.yaml"), + ) + app = create_app(config) + with TestClient(app) as c: + yield c + + +def test_fresh_profile_has_seeded_model_registry(fresh_client): + resp = fresh_client.get("/api/models") + assert resp.status_code == 200 + registry = resp.json()["registry"] + assert registry, "fresh profile must offer models from the bundled registry" + roles = {m["role"] for m in registry} + assert "starter" in roles, "the Set-me-up flow requires a starter-role model" + + +def test_setup_install_accepts_the_starter_model(fresh_client): + registry = fresh_client.get("/api/models").json()["registry"] + starter = next(m for m in registry if m["role"] == "starter") + resp = fresh_client.post("/api/setup/install", json={"registry_id": starter["id"]}) + # 200 — the pipeline job is created (the engine stage may later skip or + # download; what must never happen on a fresh install is MODEL_NOT_FOUND). + assert resp.status_code == 200, resp.text + job = resp.json() + assert job["registry_id"] == starter["id"] + assert job["status"] in ("pending", "running") + # Cancel immediately: this test asserts admission, not the download. + fresh_client.delete(f"/api/setup/install/{job['id']}") + + +def test_missing_registry_file_does_not_break_startup(tmp_path): + config = ServiceConfig( + host="127.0.0.1", + port=7355, + data_dir=str(tmp_path / "data"), + log_dir=str(tmp_path / "logs"), + db_dir=str(tmp_path / "db"), + packs_dir=str(tmp_path / "packs"), + model_registry_path=str(tmp_path / "nope" / "registry.yaml"), + ) + app = create_app(config) + with TestClient(app) as c: + resp = c.get("/api/models") + assert resp.status_code == 200 + assert resp.json()["registry"] == []