mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-26 08:15:19 +00:00
The API embeds every query it serves, so it held its own copy of the model: ~890 MB it never needed. EMBEDDINGS_DELEGATE_TO_WORKER (on by default) sends the text to the Celery worker instead and gets the vector back, taking an API process from 1176 MB to 285 MB with no ONNX Runtime imported at all. The client embeds locally when it finds itself inside a worker task, so the worker never dispatches to itself -- the same self-deadlock DOCUMENT_PARSE_QUEUE avoids on the parsing side. EMBEDDINGS_BASE_URL still wins over it, and remains the right answer for production. ensure_vector_schema was constructing the embeddings instance purely to read .dimension off it, loading several hundred MB of ONNX into every API and worker process at import. For a model the registry describes that is a lookup; only an unregistered name now falls back to loading. EMBEDDINGS_BATCH_SIZE was sizing two unrelated things: chunks per store transaction (and per remote embed request) and documents per ONNX forward pass. Each pass pads every input up to its longest, and that waste grows with the square of chunk length, so at the 1250-token default a batch of 32 peaked at 6.6 GB and took 326s where a batch of 1 peaked at 2.9 GB and took 90s. The forward pass is now sized by EMBEDDINGS_MODEL_BATCH_SIZE, defaulting to 1; storage and remote batching are unchanged at 32. reembed embeds in-process: a batch job that walks the whole index should not round-trip every chunk through a broker, and loading the model there reports a real failure instead of timing out against an empty queue. Also drops the mpnet zip download from the docs and the devcontainer, which pointed at a SentenceTransformers export with no ONNX graph and had been inert since the FastEmbed swap; corrects the claim that any sentence-transformers model works; and settles the Configuring/Settings pages on what the registry and the repository metadata actually decide.
326 lines
11 KiB
Python
326 lines
11 KiB
Python
"""Root pytest fixtures for the DocsGPT backend suite.
|
|
|
|
Postgres fixture strategy
|
|
-------------------------
|
|
|
|
Regular unit tests get a Postgres connection from the ``pg_conn`` fixture
|
|
below, which is backed by ``pytest-postgresql``. That plugin spins up an
|
|
ephemeral ``pg_ctl``-managed cluster in a temp directory and tears it
|
|
down at the end of the session, so CI only needs Postgres *binaries*
|
|
installed, not a running service.
|
|
|
|
Alembic migrations run ONCE per session (per xdist worker) into the
|
|
cluster's template database via the ``load=`` hook on ``postgresql_proc``.
|
|
Every per-test database handed out by the ``postgresql`` fixture is then
|
|
cloned from that template (``CREATE DATABASE .. TEMPLATE ..`` — a cheap
|
|
file-level copy), so each test still starts from a pristine schema at
|
|
head without replaying the migration chain.
|
|
|
|
Tests under ``tests/storage/db/`` intentionally override ``pg_conn`` in
|
|
their own conftest to point at a real, long-running Postgres instance
|
|
(DBngin locally, a service container in CI). Those are integration/e2e
|
|
tests and are marked with ``@pytest.mark.integration``.
|
|
|
|
No mongomock. The ``mock_mongo_db`` fixture that used to live here was
|
|
removed as part of the Mongo→Postgres cutover. Tests that still
|
|
reference it will fail with "fixture not found" until the corresponding
|
|
route handler is migrated to a repository read.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
# Disable the app's self-bootstrap (AUTO_CREATE_DB / AUTO_MIGRATE) before
|
|
# any ``application.*`` module is imported. ``application/app.py`` runs
|
|
# ``ensure_database_ready`` at import time using whatever ``POSTGRES_URI``
|
|
# is set in the environment — which in dev is the operator's local DB, not
|
|
# the ephemeral ``pytest-postgresql`` cluster that the fixtures below spin
|
|
# up. Tests manage their own schema via the ``pg_engine`` fixture
|
|
# (subprocess ``alembic upgrade head`` against the per-test URI), so the
|
|
# import-time bootstrap would at best be redundant and at worst would
|
|
# mutate the operator's dev DB. ``setdefault`` so a test run can still
|
|
# opt back in by setting the env var explicitly.
|
|
os.environ.setdefault("AUTO_MIGRATE", "false")
|
|
os.environ.setdefault("AUTO_CREATE_DB", "false")
|
|
os.environ.setdefault("AUTO_VECTOR_SCHEMA", "false")
|
|
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from pytest_postgresql import factories
|
|
from sqlalchemy import create_engine
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Postgres fixtures (ephemeral cluster via pytest-postgresql)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_ALEMBIC_INI = Path(__file__).resolve().parent.parent / "application" / "alembic.ini"
|
|
|
|
|
|
def _migrate_template_db(host, port, user, dbname, password) -> None:
|
|
"""Run alembic ``upgrade head`` into the session's template database.
|
|
|
|
Called once per session by pytest-postgresql's ``load=`` hook (against
|
|
the template DB, before any test runs). Runs in a subprocess so the
|
|
parent process never imports application settings with this URI cached.
|
|
"""
|
|
url = (
|
|
f"postgresql+psycopg://{user}:{password or ''}@{host}:{port}/{dbname}"
|
|
)
|
|
subprocess.check_call(
|
|
[sys.executable, "-m", "alembic", "-c", str(_ALEMBIC_INI), "upgrade", "head"],
|
|
timeout=120,
|
|
env={**os.environ, "POSTGRES_URI": url},
|
|
)
|
|
|
|
|
|
# ``postgresql_proc`` starts a fresh ``pg_ctl`` cluster once per session and
|
|
# migrates its template database (see ``_migrate_template_db``). ``postgresql``
|
|
# hands out a per-test DB cloned from that template. We layer our own
|
|
# SQLAlchemy engine + rolled-back transaction on top for test isolation.
|
|
postgresql_proc = factories.postgresql_proc(load=[_migrate_template_db])
|
|
postgresql = factories.postgresql("postgresql_proc")
|
|
|
|
|
|
def _sqlalchemy_url(pg_conn_info) -> str:
|
|
return (
|
|
"postgresql+psycopg://"
|
|
f"{pg_conn_info.user}:{pg_conn_info.password or ''}"
|
|
f"@{pg_conn_info.host}:{pg_conn_info.port}/{pg_conn_info.dbname}"
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def _alembic_ini_path() -> Path:
|
|
return Path(__file__).resolve().parent.parent / "application" / "alembic.ini"
|
|
|
|
|
|
@pytest.fixture()
|
|
def pg_engine(postgresql, monkeypatch):
|
|
"""Per-test SQLAlchemy engine against a fresh ephemeral Postgres DB.
|
|
|
|
The database is a clone of the session's already-migrated template
|
|
(see ``_migrate_template_db``), so the full schema is present without
|
|
running alembic here. ``POSTGRES_URI`` is patched in the environment
|
|
for the duration of the test so any code that reads it via
|
|
``application.core.settings`` sees the ephemeral DB.
|
|
"""
|
|
url = _sqlalchemy_url(postgresql.info)
|
|
monkeypatch.setenv("POSTGRES_URI", url)
|
|
|
|
# Reset the settings cache so the new POSTGRES_URI is picked up if the
|
|
# settings module is already imported.
|
|
from application.core import settings as settings_module
|
|
|
|
monkeypatch.setattr(settings_module.settings, "POSTGRES_URI", url, raising=False)
|
|
|
|
engine = create_engine(url)
|
|
yield engine
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.fixture()
|
|
def pg_conn(pg_engine):
|
|
"""Per-test connection wrapped in a transaction that always rolls back."""
|
|
conn = pg_engine.connect()
|
|
txn = conn.begin()
|
|
yield conn
|
|
txn.rollback()
|
|
conn.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Generic unit-test fixtures (no DB, no Mongo)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_real_redis(monkeypatch):
|
|
"""Force the Redis-absent baseline CI has.
|
|
|
|
CI runs without a Redis service, so ``get_redis_instance()`` /
|
|
``get_pubsub_redis_instance()`` return None there. A dev machine with
|
|
a live localhost Redis diverges: code under test silently reads and
|
|
writes real keys, leaking state between tests and between runs (seen
|
|
with the ``openai_inline_file:*`` upload cache). Flipping the
|
|
creation-failed flags makes the real accessors return None everywhere
|
|
regardless of import style — consumers that did ``from
|
|
application.cache import get_redis_instance`` still hit these module
|
|
globals at call time. Tests that want Redis behavior keep injecting
|
|
fakes by patching the accessor at the consumer module, which bypasses
|
|
this guard. A test targeting the accessor's own construction path
|
|
must reset the two flags first.
|
|
"""
|
|
monkeypatch.setattr("application.cache._redis_instance", None)
|
|
monkeypatch.setattr("application.cache._redis_creation_failed", True)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_worker_delegation(monkeypatch):
|
|
"""Embed in-process during tests, the way CI has no worker to embed on.
|
|
|
|
``EMBEDDINGS_DELEGATE_TO_WORKER`` ships on, so an unmocked embed would
|
|
publish to a broker nobody is consuming and block for
|
|
``EMBEDDINGS_DELEGATE_TIMEOUT`` before failing -- a minute per call, and a
|
|
pass/fail that depends on whether the developer happens to have a worker
|
|
running. Tests covering delegation patch the setting back on themselves.
|
|
"""
|
|
from application.core.settings import settings
|
|
|
|
monkeypatch.setattr(settings, "EMBEDDINGS_DELEGATE_TO_WORKER", False, raising=False)
|
|
monkeypatch.setattr("application.cache._pubsub_redis_instance", None)
|
|
monkeypatch.setattr("application.cache._pubsub_redis_creation_failed", True)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_llm():
|
|
llm = Mock()
|
|
llm.gen_stream = Mock()
|
|
llm._supports_tools = True
|
|
llm._supports_structured_output = Mock(return_value=False)
|
|
llm.__class__.__name__ = "MockLLM"
|
|
# Mirror BaseLLM.__init__: real LLMCreator stores the resolved
|
|
# upstream model name on self.model_id. Tests that build agents via
|
|
# ``mock_llm_creator`` rely on the agent's ``upstream_model_id``
|
|
# falling through to this attribute.
|
|
llm.model_id = "gpt-4"
|
|
return llm
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_llm_handler():
|
|
handler = Mock()
|
|
handler.process_message_flow = Mock()
|
|
return handler
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_retriever():
|
|
retriever = Mock()
|
|
retriever.search = Mock(
|
|
return_value=[
|
|
{"text": "Test document 1", "filename": "doc1.txt", "source": "test"},
|
|
{"text": "Test document 2", "title": "doc2.txt", "source": "test"},
|
|
]
|
|
)
|
|
return retriever
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_chat_history():
|
|
return [
|
|
{"prompt": "What is Python?", "response": "Python is a programming language."},
|
|
{"prompt": "Tell me more.", "response": "Python is known for simplicity."},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_tool_call():
|
|
return {
|
|
"tool_name": "test_tool",
|
|
"call_id": "123",
|
|
"action_name": "test_action",
|
|
"arguments": {"arg1": "value1"},
|
|
"result": "Tool executed successfully",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def decoded_token():
|
|
return {"sub": "test_user", "email": "test@example.com"}
|
|
|
|
|
|
@pytest.fixture
|
|
def log_context():
|
|
from application.logging import LogContext
|
|
|
|
context = LogContext(
|
|
endpoint="test_endpoint",
|
|
activity_id="test_activity",
|
|
user="test_user",
|
|
api_key="test_key",
|
|
query="test query",
|
|
)
|
|
return context
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_llm_creator(mock_llm, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"application.llm.llm_creator.LLMCreator.create_llm", Mock(return_value=mock_llm)
|
|
)
|
|
return mock_llm
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_llm_handler_creator(mock_llm_handler, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"application.llm.handlers.handler_creator.LLMHandlerCreator.create_handler",
|
|
Mock(return_value=mock_llm_handler),
|
|
)
|
|
return mock_llm_handler
|
|
|
|
|
|
@pytest.fixture
|
|
def agent_base_params(decoded_token):
|
|
return {
|
|
"endpoint": "https://api.example.com",
|
|
"llm_name": "openai",
|
|
"model_id": "gpt-4",
|
|
"api_key": "test_api_key",
|
|
"user_api_key": None,
|
|
"prompt": "You are a helpful assistant.",
|
|
"chat_history": [],
|
|
"decoded_token": decoded_token,
|
|
"attachments": [],
|
|
"json_schema": None,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_tool():
|
|
tool = Mock()
|
|
tool.execute_action = Mock(return_value="Tool result")
|
|
# Skip artifact-id capture in default mock so the recorded JSONB matches
|
|
# what tests expect; per-tool tests can override.
|
|
tool.get_artifact_id = None
|
|
tool.get_actions_metadata = Mock(
|
|
return_value=[
|
|
{
|
|
"name": "test_action",
|
|
"description": "A test action",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"param1": {"type": "string", "description": "Test parameter"}
|
|
},
|
|
"required": ["param1"],
|
|
},
|
|
}
|
|
]
|
|
)
|
|
return tool
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_tool_manager(mock_tool, monkeypatch):
|
|
manager = Mock()
|
|
manager.load_tool = Mock(return_value=mock_tool)
|
|
monkeypatch.setattr(
|
|
"application.agents.tool_executor.ToolManager", Mock(return_value=manager)
|
|
)
|
|
return manager
|
|
|
|
|
|
@pytest.fixture
|
|
def flask_app():
|
|
from flask import Flask
|
|
|
|
app = Flask(__name__)
|
|
return app
|