Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions docs/architecture/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ What else each spec names, and why the analyser cannot find it on its own:
| `api.spec` | `onnxruntime` and `tokenizers` libraries | the query encoder's native libraries load from C |
| `api.spec` | the local model manifest `catalog/local/manifest/models.json`, the remote model manifest `catalog/remote/manifest/models.json`, the chat prompts | read by path or through `importlib.resources` |
| `api.spec` | excludes Docling, torch, torchvision, transformers, pandas, scipy and OpenCV | only the worker parses files, and the analyser cannot tell these are optional |
| `worker.spec` | the remote model manifest | Studio classifies a remote model through the same discovery the API uses, and the file is read by path |
| `worker.spec` | the local model manifest, the remote model manifest | Studio finds its chosen image and audio models through the local catalog, and classifies a remote model through the same discovery the API uses; both files are read by path |
| `worker.spec` | Docling and its packages, RapidOCR, transformers, torchvision | lazy and native imports Docling reaches only on the first PDF |
| `worker.spec` | python-docx, python-pptx, xlsxwriter, reportlab | the Office formats run model-written code that imports them, so no static import exists |
| `worker.spec` | `modules.documents.tasks`, `modules.artifacts.tasks` | Huey resolves a task by its name |
Expand Down Expand Up @@ -109,8 +109,8 @@ All five carry the `packaging` marker, which `pyproject.toml` excludes by defaul
| Test | Proves |
|---|---|
| `test_frozen_boot.py` | a minimal frozen entry, `sys.frozen` true, migrates a real database from the bundled revisions, loads `vec0` and round-trips a vector through `chunk_vectors` |
| `test_real_binaries.py` | the real API binary passes its retrieval import check and answers `/health`; the real worker passes its vision import check and both queue consumers stay up; both binaries ship the remote manifest, and the frozen API serves a curated row |
| `test_spec_data_files.py` | every literal `datas` path in the specs exists, so a renamed file cannot ship missing |
| `test_real_binaries.py` | the real API binary passes its retrieval import check and answers `/health`; the real worker passes its vision import check and both queue consumers stay up; both binaries ship the remote manifest, the worker ships the local one, and the frozen API serves a curated row |
| `test_spec_data_files.py` | every literal `datas` path in the specs exists, so a renamed file cannot ship missing, and both `api.spec` and `worker.spec` bundle the local model manifest |
| `test_license_key.py` | the compiled license keys exclude the fixture key |
| `test_audiocpp_voicing.py` | the staged audio.cpp server, started with the sidecar's flags from the `server.json` the app writes, voices two turns of each curated model through the app's adapter, at the sample rate its entry names, then holds no model loaded; it runs only when `SURFSENSE_TEST_AUDIO_MODELS` names a folder of the pinned files, each checked against its sha256, and then fails without a staged server; `build-audiocpp.yml` runs it on both builds |

Expand All @@ -124,4 +124,3 @@ Two run in CI: `test_license_key.py` inside the release workflow, and `test_audi
- No release has built or packaged audio.cpp yet, and nothing has staged its macOS archive on a Mac.
- No workflow runs the `surfsense_local` unit or integration tests on pull requests; only the two packaging tests above run in CI.
- No test ingests a PDF with networking disabled.
- `test_the_curated_manifest_is_one_of_them` fails on Windows: `literal_data_paths()` joins the spec's path with `Path`, which gives backslashes there, and the test looks for a forward-slash string.
10 changes: 10 additions & 0 deletions surfsense_local/backend/bundling/worker.spec
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ datas.append(
)
)

# Studio resolves its chosen image and audio models through the local catalog,
# which knows a curated model only from this manifest; without it an installed,
# chosen model reads as "not installed".
datas.append(
(
str(BACKEND / "modules" / "llm" / "catalog" / "local" / "manifest" / "models.json"),
"modules/llm/catalog/local/manifest",
)
)

for package in (
"onnxruntime",
"docling",
Expand Down
18 changes: 18 additions & 0 deletions surfsense_local/backend/tests/packaging/test_real_binaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ def _assert_capability_catalogue_shipped(binary: Path) -> None:
assert json.loads(manifest.read_text())["providers"]


def _assert_curated_manifest_shipped(binary: Path) -> None:
"""Studio finds its chosen image and audio models through the curated list;
without it the frozen worker calls an installed model "not installed"."""
manifest = (
binary.parent
/ "_internal"
/ "modules"
/ "llm"
/ "catalog"
/ "local"
/ "manifest"
/ "models.json"
)
assert manifest.is_file(), f"local model manifest missing from {binary.parent}"
assert json.loads(manifest.read_text())["models"]


def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
Expand Down Expand Up @@ -130,6 +147,7 @@ def test_worker_binary_starts(tmp_path: Path) -> None:
"""Freeze the worker and assert its lazy vision imports and consumer boot."""
binary = _freeze("worker.spec", tmp_path)
_assert_capability_catalogue_shipped(binary)
_assert_curated_manifest_shipped(binary)
vision = subprocess.run(
[str(binary), "--check-vision-runtime"],
env=_env(tmp_path),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ def literal_data_paths(spec: Path) -> list[str]:
parts.insert(0, str(current.right.value))
current = current.left
if parts and getattr(current, "id", None) == "BACKEND":
found.append(str(Path(*parts)))
# Forward slashes on every platform, as the specs' targets are.
found.append(Path(*parts).as_posix())
return found


Expand All @@ -59,9 +60,11 @@ def test_every_bundled_data_path_exists(spec: Path) -> None:
assert missing == [], f"{spec.name} bundles paths that do not exist: {missing}"


def test_the_curated_manifest_is_one_of_them() -> None:
"""The file that was missed. Without it a frozen build has no curated list,
and the model screen is empty on the machine that most needs it."""
bundled = {rel for spec in SPECS for rel in literal_data_paths(spec)}
@pytest.mark.parametrize("name", ["api.spec", "worker.spec"])
def test_every_binary_that_reads_the_curated_manifest_ships_it(name: str) -> None:
"""Without it the catalog falls back to an empty list: the API shows no
curated models, and the Studio worker calls an installed, chosen image or
audio model "not installed"."""
bundled = literal_data_paths(BACKEND / "bundling" / name)

assert "modules/llm/catalog/local/manifest/models.json" in bundled
Loading