test(gpu): покрыть L4 OllamaClient (парсинг парафраза, 12 тестов)

Сеть замокана (monkeypatch httpx.post/get). Проверяется устойчивость слоя LLM:
- нормализация ответа: is_paraphrase→bool, confidence→float, reason→str;
- кривой/невалидный JSON от модели → безопасные дефолты, не падение;
- отсутствие ключа "response" → пустой объект → безопасные значения;
- таймаут/ConnectError → «LLM недоступна»; прочие ошибки перехвачены;
- summarize: strip ответа и фолбэк в аннотацию/заголовок при ошибке;
- is_available: 200 → True, исключение → False.

Тестов всего: 53 (indexer 19, gost 17, gpu 17). httpx добавлен в requirements-test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-08-11 20:10:34 +05:00
parent e79f254af6
commit 426796a9a7
2 changed files with 133 additions and 2 deletions

View File

@@ -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