feat(logic): dostawcy LLM i pisanie horoskopu (LOG-31)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m38s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m43s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 24s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 24s
build / build (push) Successful in 59s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m53s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 18s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m38s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m43s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 24s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 24s
build / build (push) Successful in 59s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m53s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 18s
Domyslnie model LOKALNY — prompt niesie oryginalne opisy z baz, wiec domyslnie NIC nie opuszcza sieci. Chmura wlaczana swiadomie (LOG-32). - app/llm/: LLMProvider (jak EphemerisEngine z LOG-24) + dwie implementacje. Lokalny serwer modelu (Ollama/vLLM/llama.cpp) i OpenAI mowia TYM SAMYM protokolem /chat/completions, wiec obsluguje je jedna klasa; Anthropic ma wlasny /v1/messages. Napisane na samym httpx — bez SDK openai/anthropic: mniej zaleznosci i pelna kontrola nad tym, co wychodzi z sieci. - Kazdy dostawca deklaruje `leaves_lan` — interfejs MUSI jawnie mowic, czy tresc baz opuszcza siec; UI na tej podstawie ostrzega. - Ponawianie z backoffem (429/5xx), timeouty, czytelne bledy zamiast stacktrace. - Klucz wylacznie z LLM_API_KEY (sekret), nigdy w repo ani w UI. - POST /chart/horoscope + GET /llm/health. WAZNE: prompt jest zwracany ZAWSZE — takze gdy model padnie lub brakuje klucza. Dzieki temu awaria dostawcy nie blokuje pracy: prompt mozna skopiowac i uzyc recznie. Prezentacja: wybor modelu (lokalny/Anthropic/OpenAI), przycisk „Napisz horoskop (AI)", wynik z informacja kto go napisal, czy dane opuscily siec, ile wskazan weszlo, oraz zastrzezenie ze to nie porada medyczna (PRE-15). Testy: 13 nowych (transport podstawiony — zaden prawdziwy model nie wolany), calosc 131 passed / 1 skipped. Zweryfikowane e2e na atrapie serwera modelu: horoskop napisany, leaves_lan=false, tokeny zliczone; a przy padnietym modelu / braku klucza / zlym dostawcy — czytelny blad i zachowany prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"""Dostawcy LLM (LOG-31) — bez wołania jakiegokolwiek prawdziwego modelu.
|
||||
|
||||
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
|
||||
deterministyczne i nic nie wychodzi na zewnątrz.
|
||||
"""
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.llm import factory
|
||||
from app.llm.base import Completion, LLMError
|
||||
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
|
||||
|
||||
|
||||
def _mock_client(handler):
|
||||
"""Podmienia httpx.Client na wersję z transportem testowym."""
|
||||
class _C(httpx.Client):
|
||||
def __init__(self, *a, **kw):
|
||||
kw["transport"] = httpx.MockTransport(handler)
|
||||
super().__init__(*a, **kw)
|
||||
return _C
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_ok(monkeypatch):
|
||||
def handler(request):
|
||||
assert request.url.path.endswith("/chat/completions")
|
||||
return httpx.Response(200, json={
|
||||
"model": "test-model",
|
||||
"choices": [{"message": {"content": "Horoskop testowy."}}],
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 50},
|
||||
})
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
|
||||
|
||||
# ------------------------------------------------------- protokół chat/completions
|
||||
|
||||
def test_local_provider_generates(chat_ok):
|
||||
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
|
||||
out = p.generate("prompt", 500)
|
||||
assert isinstance(out, Completion)
|
||||
assert out.text == "Horoskop testowy."
|
||||
assert out.usage["completion_tokens"] == 50
|
||||
|
||||
|
||||
def test_local_provider_does_not_leave_lan(chat_ok):
|
||||
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
|
||||
assert p.generate("prompt", 100).leaves_lan is False
|
||||
|
||||
|
||||
def test_cloud_provider_marks_leaving_lan(chat_ok):
|
||||
p = ChatCompletionsProvider("openai", "https://api.openai.com/v1", "m", "klucz",
|
||||
leaves_lan=True)
|
||||
assert p.generate("prompt", 100).leaves_lan is True
|
||||
|
||||
|
||||
def test_api_key_sent_only_when_set(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["auth"] = request.headers.get("authorization")
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": "x"}}]})
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||
assert seen["auth"] is None
|
||||
ChatCompletionsProvider("openai", "http://x/v1", "m", "tajny").generate("p", 10)
|
||||
assert seen["auth"] == "Bearer tajny"
|
||||
|
||||
|
||||
def test_http_error_becomes_readable_message(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "Client",
|
||||
_mock_client(lambda r: httpx.Response(400, text="zly model")))
|
||||
with pytest.raises(LLMError, match="400"):
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||
|
||||
|
||||
def test_malformed_response_reported(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "Client",
|
||||
_mock_client(lambda r: httpx.Response(200, json={"nonsens": 1})))
|
||||
with pytest.raises(LLMError, match="kształt"):
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||
|
||||
|
||||
def test_retries_then_succeeds(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
return httpx.Response(429, text="za duzo")
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
monkeypatch.setattr("app.llm.providers.time.sleep", lambda s: None) # bez czekania
|
||||
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10).text == "ok"
|
||||
assert calls["n"] == 3
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- Anthropic
|
||||
|
||||
def test_anthropic_generates(monkeypatch):
|
||||
def handler(request):
|
||||
assert request.url.path.endswith("/v1/messages")
|
||||
assert request.headers.get("x-api-key") == "klucz"
|
||||
assert request.headers.get("anthropic-version")
|
||||
return httpx.Response(200, json={
|
||||
"model": "claude-x",
|
||||
"content": [{"type": "text", "text": "Prognoza."}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||
})
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
out = AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
|
||||
assert out.text == "Prognoza." and out.leaves_lan is True
|
||||
|
||||
|
||||
def test_anthropic_requires_key():
|
||||
with pytest.raises(LLMError, match="LLM_API_KEY"):
|
||||
AnthropicProvider("https://api.anthropic.com", "m", "").generate("p", 10)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- fabryka
|
||||
|
||||
def test_default_provider_is_local(monkeypatch):
|
||||
monkeypatch.delenv("LLM_PROVIDER", raising=False)
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
monkeypatch.delenv("LLM_BASE_URL", raising=False)
|
||||
p = factory.build_provider()
|
||||
assert p.name == "local"
|
||||
# domyślnie NIC nie opuszcza sieci — prompt niesie opisy z baz (LOG-32)
|
||||
assert p.leaves_lan is False
|
||||
|
||||
|
||||
def test_openai_requires_key(monkeypatch):
|
||||
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||
with pytest.raises(LLMError, match="LLM_API_KEY"):
|
||||
factory.build_provider("openai")
|
||||
|
||||
|
||||
def test_unknown_provider_rejected():
|
||||
with pytest.raises(LLMError, match="Nieznany dostawca"):
|
||||
factory.build_provider("bzdura")
|
||||
|
||||
|
||||
def test_env_overrides_model_and_url(monkeypatch):
|
||||
monkeypatch.setenv("LLM_MODEL", "moj-model")
|
||||
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
|
||||
p = factory.build_provider("local")
|
||||
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
|
||||
Reference in New Issue
Block a user