unsloth/studio/backend/tests/conftest.py
Daniel Han 8848a310df
Studio: clean-room compact RAG (knowledge bases, hybrid search, fast indexing) (#5910)
Adds a self-contained RAG stack to Studio: knowledge bases with chunked indexing, hybrid (dense + lexical) retrieval, and an automatic first-pass context inject into chat. Embeddings run through a local llama-server GGUF backend (default unsloth/bge-small-en-v1.5-GGUF) with a sentence-transformers fallback. The chat tool loop gains a search_knowledge_base tool, a per-turn re-search cap, and source citation, layered on top of the shared ToolLoopController.
2026-06-09 21:17:04 -07:00

179 lines
5.4 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Shared pytest configuration for the backend test suite.
Puts the backend root on sys.path (mirrors app launch) and provides a hybrid
``studio_server`` session fixture for end-to-end tests with two modes:
external server (``UNSLOTH_E2E_BASE_URL``/``UNSLOTH_E2E_API_KEY``) for fast
iteration, or a fixture-managed server started/torn down per session for CI.
Model/variant for the managed mode resolve from ``--unsloth-model`` /
``--unsloth-gguf-variant``, then env vars, then ``test_studio_api.py`` defaults.
"""
import os
import sys
from pathlib import Path
import pytest
# Add backend root to sys.path (mirrors app launch)
_backend_root = Path(__file__).resolve().parent.parent
if str(_backend_root) not in sys.path:
sys.path.insert(0, str(_backend_root))
# Pytest CLI options
def pytest_addoption(parser):
group = parser.getgroup(
"unsloth-e2e",
"Unsloth Studio end-to-end test options",
)
group.addoption(
"--unsloth-model",
action = "store",
default = None,
help = (
"GGUF model id used when starting a server for e2e tests. "
"Ignored if UNSLOTH_E2E_BASE_URL is set. Overrides "
"UNSLOTH_E2E_MODEL env var. Defaults to test_studio_api.py's "
"DEFAULT_MODEL."
),
)
group.addoption(
"--unsloth-gguf-variant",
action = "store",
default = None,
help = (
"GGUF variant used when starting a server for e2e tests. "
"Ignored if UNSLOTH_E2E_BASE_URL is set. Overrides "
"UNSLOTH_E2E_VARIANT env var. Defaults to test_studio_api.py's "
"DEFAULT_VARIANT."
),
)
# E2E server fixtures
@pytest.fixture(scope = "session")
def studio_server(request):
"""Yield ``(base_url, api_key)`` for e2e tests.
Uses ``UNSLOTH_E2E_BASE_URL`` (requires ``UNSLOTH_E2E_API_KEY``) if set,
else starts/tears down a fresh server via ``_start_server``. Session-scoped
and lazy so the GGUF load happens at most once and only when requested.
"""
external_url = os.environ.get("UNSLOTH_E2E_BASE_URL")
if external_url:
api_key = os.environ.get("UNSLOTH_E2E_API_KEY")
if not api_key:
pytest.skip(
"UNSLOTH_E2E_BASE_URL is set but UNSLOTH_E2E_API_KEY is "
"missing — tests that require auth cannot run against an "
"external server without it.",
)
yield external_url, api_key
return
# Lazy import; pytest has already loaded test_studio_api, so this is a cache hit.
import test_studio_api as _e2e
model = (
request.config.getoption("--unsloth-model")
or os.environ.get("UNSLOTH_E2E_MODEL")
or _e2e.DEFAULT_MODEL
)
variant = (
request.config.getoption("--unsloth-gguf-variant")
or os.environ.get("UNSLOTH_E2E_VARIANT")
or _e2e.DEFAULT_VARIANT
)
proc, api_key = _e2e._start_server(model, variant)
try:
yield f"http://{_e2e.HOST}:{_e2e.PORT}", api_key
finally:
_e2e._kill_server(proc)
@pytest.fixture
def base_url(studio_server):
"""Base URL for the e2e Studio server (from ``studio_server``)."""
return studio_server[0]
@pytest.fixture
def api_key(studio_server):
"""API key for the e2e Studio server (from ``studio_server``)."""
return studio_server[1]
# ── RAG fixtures ─────────────────────────────────────────────────────
@pytest.fixture
def rag_home(tmp_path, monkeypatch):
"""Isolate the RAG database under a fresh UNSLOTH_STUDIO_HOME per test.
Points the storage root at ``tmp_path`` and resets the lazy schema flag so
each test starts from an empty rag.db. Yields the temp home path.
"""
from storage import rag_db
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(rag_db, "_schema_ready", False)
return tmp_path
@pytest.fixture
def rag_conn(rag_home):
"""A fresh RAG connection bound to the isolated ``rag_home`` database."""
from storage import rag_db
conn = rag_db.get_connection()
try:
yield conn
finally:
conn.close()
@pytest.fixture
def stub_embeddings(monkeypatch):
"""Stub ``core.rag.embeddings`` with deterministic hash-based vectors.
Lets store / retrieval / ingestion tests run fast without downloading a
sentence-transformers model. Returns the fixed embedding dimension.
"""
import hashlib
import math
from core.rag import embeddings
dim = 32
def _vec(text: str):
seed = hashlib.sha256(text.encode("utf-8")).digest()
raw = [seed[i % len(seed)] / 255.0 for i in range(dim)]
norm = math.sqrt(sum(x * x for x in raw)) or 1.0
return [x / norm for x in raw]
def fake_encode(
texts,
*,
model_name = None,
normalize = True,
):
return [_vec(t) for t in texts]
monkeypatch.setattr(embeddings, "encode", fake_encode)
monkeypatch.setattr(embeddings, "dim", lambda model_name = None: dim)
monkeypatch.setattr(
embeddings,
"token_counter",
lambda model_name = None: (lambda t: len(t.split())),
)
monkeypatch.setattr(embeddings, "warm", lambda model_name = None: None)
return dim