diff --git a/services/worker-gpu/requirements-test.txt b/services/worker-gpu/requirements-test.txt index 7e4621f..7f96039 100644 --- a/services/worker-gpu/requirements-test.txt +++ b/services/worker-gpu/requirements-test.txt @@ -1,6 +1,8 @@ -# Зависимости для юнит-тестов worker-gpu (FAISS-индекс L3, в памяти, без GPU). -# torch/sentence-transformers НЕ нужны — тестируется только логика индекса. +# Зависимости для юнит-тестов worker-gpu (FAISS-индекс L3 + OllamaClient L4). +# torch/sentence-transformers НЕ нужны — тестируется только логика индекса и +# парсинг ответа LLM (сеть в тестах замокана). pytest==8.2.0 faiss-cpu==1.8.0 numpy==1.26.4 pydantic-settings==2.2.1 +httpx==0.27.0 diff --git a/services/worker-gpu/tests/test_ollama_client.py b/services/worker-gpu/tests/test_ollama_client.py new file mode 100644 index 0000000..08611e7 --- /dev/null +++ b/services/worker-gpu/tests/test_ollama_client.py @@ -0,0 +1,129 @@ +"""Юнит-тесты OllamaClient (уровень 4 — LLM-анализ парафраза). + +Сеть замокана: проверяется устойчивость парсинга ответа модели и корректные +фолбэки при недоступности/ошибках — кривой вывод LLM не должен ронять проверку. +""" + +import json + +import httpx +import pytest +from app import ollama_client as oc + + +class _FakeResponse: + def __init__(self, payload: dict): + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return self._payload + + +def _returns(payload: dict): + def _fake(*args, **kwargs): + return _FakeResponse(payload) + return _fake + + +def _raises(exc: Exception): + def _fake(*args, **kwargs): + raise exc + return _fake + + +@pytest.fixture +def client(): + return oc.OllamaClient() + + +def test_paraphrase_parses_and_normalizes(monkeypatch, client): + payload = {"response": json.dumps( + {"is_paraphrase": True, "confidence": 0.87, "reason": "те же идеи"} + )} + monkeypatch.setattr(oc.httpx, "post", _returns(payload)) + assert client.check_paraphrase("оригинал текста", "перефраз того же") == { + "is_paraphrase": True, + "confidence": 0.87, + "reason": "те же идеи", + } + + +def test_paraphrase_coerces_types(monkeypatch, client): + # confidence пришёл как int, reason отсутствует + payload = {"response": json.dumps({"is_paraphrase": False, "confidence": 1})} + monkeypatch.setattr(oc.httpx, "post", _returns(payload)) + out = client.check_paraphrase("a", "b") + assert out["is_paraphrase"] is False + assert isinstance(out["confidence"], float) and out["confidence"] == 1.0 + assert out["reason"] == "" + + +def test_paraphrase_malformed_json_is_safe(monkeypatch, client): + monkeypatch.setattr(oc.httpx, "post", _returns({"response": "не JSON, а болтовня"})) + out = client.check_paraphrase("a", "b") + assert out["is_paraphrase"] is False + assert out["confidence"] == 0.0 + assert "парс" in out["reason"].lower() + + +def test_paraphrase_missing_response_key_defaults(monkeypatch, client): + # нет ключа "response" → дефолт "{}" → пустой объект → безопасные значения + monkeypatch.setattr(oc.httpx, "post", _returns({})) + assert client.check_paraphrase("a", "b") == { + "is_paraphrase": False, + "confidence": 0.0, + "reason": "", + } + + +def test_paraphrase_timeout_returns_unavailable(monkeypatch, client): + monkeypatch.setattr(oc.httpx, "post", _raises(httpx.TimeoutException("timeout"))) + out = client.check_paraphrase("a", "b") + assert out["is_paraphrase"] is False + assert out["reason"] == "LLM недоступна" + + +def test_paraphrase_connect_error_returns_unavailable(monkeypatch, client): + monkeypatch.setattr(oc.httpx, "post", _raises(httpx.ConnectError("no route"))) + assert client.check_paraphrase("a", "b")["reason"] == "LLM недоступна" + + +def test_paraphrase_unexpected_error_is_caught(monkeypatch, client): + monkeypatch.setattr(oc.httpx, "post", _raises(ValueError("boom"))) + out = client.check_paraphrase("a", "b") + assert out["is_paraphrase"] is False + assert out["confidence"] == 0.0 + assert out["reason"] == "boom" + + +def test_summarize_returns_stripped_response(monkeypatch, client): + monkeypatch.setattr(oc.httpx, "post", _returns({"response": " Краткое изложение. "})) + assert client.summarize("Заголовок", "Аннотация") == "Краткое изложение." + + +def test_summarize_falls_back_to_abstract_on_error(monkeypatch, client): + monkeypatch.setattr(oc.httpx, "post", _raises(RuntimeError("down"))) + assert client.summarize("Заголовок", "Аннотация про исследование") == ( + "Аннотация про исследование" + ) + + +def test_summarize_falls_back_to_title_when_no_abstract(monkeypatch, client): + monkeypatch.setattr(oc.httpx, "post", _raises(RuntimeError("down"))) + assert client.summarize("Только заголовок", "") == "Только заголовок" + + +def test_is_available_true_on_200(monkeypatch, client): + class _R: + status_code = 200 + + monkeypatch.setattr(oc.httpx, "get", lambda *a, **k: _R()) + assert client.is_available() is True + + +def test_is_available_false_on_exception(monkeypatch, client): + monkeypatch.setattr(oc.httpx, "get", _raises(httpx.ConnectError("x"))) + assert client.is_available() is False