lct-hack/backend/tests/test_llm.py
2026-09-26 17:13:45 +00:00

172 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Живые проверки локальной LLM: клиент, кэш и ответы звонящего.
Читают `backend/.env.test` с `LLM_PROVIDER=local` и loopback URL. Запускайте
после `make local-llm` отдельной командой `make test-llm-local`. Сеть не нужна;
основной pytest намеренно исключает медленный инференс.
"""
import os
from pathlib import Path
import pytest
ENV_TEST = Path(__file__).resolve().parents[1] / ".env.test"
def _read_test_env() -> dict[str, str]:
values: dict[str, str] = {}
for line in ENV_TEST.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
HAS_TEST_ENV = ENV_TEST.is_file()
pytestmark = [
pytest.mark.llm,
pytest.mark.skipif(not HAS_TEST_ENV, reason="нет backend/.env.test — живые проверки LLM пропущены"),
]
@pytest.fixture(autouse=True)
def local_llm_test_environment():
"""Изолировать live-конфиг: collection обычных тестов не меняет env."""
if not HAS_TEST_ENV:
yield
return
values = _read_test_env()
original = {key: os.environ.get(key) for key in values}
for key, value in values.items():
os.environ[key] = value
from app.config import get_settings
from app.dialog.llm import is_loopback_url
get_settings.cache_clear()
try:
settings = get_settings()
if (
settings.llm_provider != "local"
or not is_loopback_url(settings.llm_base_url)
or settings.llm_api_key
):
raise pytest.UsageError(
"test-llm-local требует LLM_PROVIDER=local, loopback URL и пустой LLM_API_KEY"
)
yield
finally:
for key, value in original.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
get_settings.cache_clear()
@pytest.fixture
async def client():
from app.dialog.llm import LlmClient
# Локальная модель может быть медленной на слабом CPU; в занятии таймаут
# короче, и при отказе используются проверенные заготовки.
llm = LlmClient(timeout=180)
yield llm
await llm.aclose()
@pytest.fixture
async def database_client(postgres_access):
"""Подключить проверку кэша к PostgreSQL, когда тестовый стенд её дал.
Живые inference smoke должны работать и без БД, но проверка устойчивого
кэша имеет смысл только в отдельной DB-интеграционной цели.
"""
from sqlalchemy import select
from app.db.base import get_sessionmaker
from app.db.models import LlmCache
from app.dialog.llm import LlmClient
sessionmaker = get_sessionmaker()
try:
async with sessionmaker() as db:
await db.scalar(select(LlmCache.context_hash).limit(1))
except Exception as exc: # noqa: BLE001 — кэш необязателен для обычного inference smoke
if os.environ.get("DATABASE_URL"):
raise
pytest.skip(f"таблица кэша LLM недоступна: {type(exc).__name__}")
db_client = LlmClient(sessionmaker=sessionmaker, timeout=180)
yield db_client
await db_client.aclose()
async def test_provider_answers(client):
from app.dialog.llm import LlmRequest, LlmUnavailable
request = LlmRequest(
messages=[{"role": "user", "content": "Ответь одним словом: работает"}],
model=os.environ["LLM_MODEL_CALLER"],
temperature=0,
max_tokens=400,
)
try:
text = await client.complete(request, use_cache=False)
except LlmUnavailable as exc:
pytest.fail(f"локальная модель недоступна: {exc}")
assert text, "пустой ответ модели"
async def test_caller_speaks_only_revealed_facts(client):
"""Главное свойство продукта не должно зависеть от послушности модели:
в промпт попадают только раскрытые факты."""
import numpy as np
from app.dialog.caller import LlmCaller
from app.dialog.llm import LlmUnavailable
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from tests.test_slots import SCENARIO, StemEmbedder
slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
persona = PersonaState(SCENARIO.persona)
caller = LlmCaller(client, model=os.environ["LLM_MODEL_CALLER"])
try:
# Оператор спрашивает не об адресе — адрес прозвучать не должен.
reply = await caller.reply(slots.hear("Что у вас случилось?"), persona, slots)
except LlmUnavailable as exc:
pytest.fail(f"локальная модель недоступна: {exc}")
assert caller.fallbacks == 0, "ответила не модель, а заготовка"
assert "Ленина" not in reply.text, f"звонящий выдал адрес без вопроса: «{reply.text}»"
assert len(reply.text) < 300, "звонящий пишет объяснительную вместо крика"
async def test_same_context_comes_from_cache(database_client):
"""Кэш по хешу контекста: та же реплика на том же месте занятия звучит
одинаково у каждой группы и не стоит второго запроса."""
from app.dialog.llm import LlmRequest, LlmUnavailable
request = LlmRequest(
messages=[{"role": "user", "content": "Назови одно слово: пожар"}],
model=os.environ["LLM_MODEL_CALLER"],
temperature=0,
max_tokens=400,
)
try:
first = await database_client.complete(request)
except LlmUnavailable as exc:
pytest.fail(f"локальная модель недоступна: {exc}")
import time
started = time.monotonic()
second = await database_client.complete(request)
elapsed = time.monotonic() - started
assert second == first, "кэш вернул другой ответ"
assert elapsed < 1.0, f"второй запрос занял {elapsed:.2f} с — кэш не сработал"