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 @@
|
||||
"""Warstwa dostawców modeli językowych (LOG-31)."""
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Kontrakt dostawcy modelu językowego (LOG-31).
|
||||
|
||||
Analogicznie do `EphemerisEngine` (LOG-24): prezentacja i reszta logiki nie wiedzą,
|
||||
kto pisze tekst — lokalny model na naszym sprzęcie czy dostawca w chmurze.
|
||||
|
||||
Kluczowa własność dla bezpieczeństwa baz (LOG-32): każdy dostawca deklaruje
|
||||
`leaves_lan`. Prompt niesie ORYGINALNE opisy z baz, więc interfejs musi jawnie
|
||||
mówić, czy ta treść opuszcza naszą sieć — UI ma na tej podstawie ostrzegać.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Completion:
|
||||
"""Wynik generowania — tekst + metryki do rozliczenia i podglądu."""
|
||||
|
||||
text: str
|
||||
model: str
|
||||
provider: str
|
||||
leaves_lan: bool
|
||||
usage: dict = field(default_factory=dict) # prompt_tokens / completion_tokens
|
||||
|
||||
|
||||
class LLMError(RuntimeError):
|
||||
"""Błąd wołania modelu — z komunikatem nadającym się do pokazania użytkownikowi."""
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
name: str = "?"
|
||||
#: czy treść promptu (a więc opisy z baz) opuszcza naszą sieć
|
||||
leaves_lan: bool = True
|
||||
|
||||
@abstractmethod
|
||||
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||
"""Zwraca gotowy tekst. Rzuca LLMError przy niepowodzeniu."""
|
||||
|
||||
@abstractmethod
|
||||
def health(self) -> dict:
|
||||
"""Czy dostawca jest osiągalny i skonfigurowany."""
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Wybór dostawcy LLM (LOG-31) — jedyne miejsce znające konkretne implementacje.
|
||||
|
||||
Domyślny jest **model lokalny**: prompt niesie oryginalne opisy z baz, więc
|
||||
domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
|
||||
przez konfigurację albo pojedyncze żądanie.
|
||||
|
||||
Zmienne środowiskowe:
|
||||
LLM_PROVIDER local (domyślnie) | openai | anthropic
|
||||
LLM_MODEL nazwa modelu (domyślna zależy od dostawcy)
|
||||
LLM_BASE_URL adres API (domyślnie: lokalny serwer zgodny z OpenAI)
|
||||
LLM_API_KEY klucz — WYŁĄCZNIE z sekretu; niepotrzebny dla modelu lokalnego
|
||||
LLM_TIMEOUT sekundy (domyślnie 120)
|
||||
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from app.llm.base import LLMError, LLMProvider
|
||||
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
|
||||
|
||||
LOCAL = "local"
|
||||
OPENAI = "openai"
|
||||
ANTHROPIC = "anthropic"
|
||||
PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
|
||||
|
||||
_DEFAULT_MODEL = {
|
||||
LOCAL: "llama3.1:8b",
|
||||
OPENAI: "gpt-4o-mini",
|
||||
ANTHROPIC: "claude-sonnet-5",
|
||||
}
|
||||
_DEFAULT_URL = {
|
||||
# Ollama i vLLM wystawiają zgodne API pod /v1
|
||||
LOCAL: "http://localhost:11434/v1",
|
||||
OPENAI: "https://api.openai.com/v1",
|
||||
ANTHROPIC: "https://api.anthropic.com",
|
||||
}
|
||||
|
||||
|
||||
def default_provider_name() -> str:
|
||||
return os.getenv("LLM_PROVIDER", LOCAL).lower()
|
||||
|
||||
|
||||
def max_tokens() -> int:
|
||||
return int(os.getenv("LLM_MAX_TOKENS", "2000"))
|
||||
|
||||
|
||||
def timeout() -> float:
|
||||
return float(os.getenv("LLM_TIMEOUT", "120"))
|
||||
|
||||
|
||||
def build_provider(name: str | None = None) -> LLMProvider:
|
||||
name = (name or default_provider_name()).lower()
|
||||
if name not in PROVIDERS:
|
||||
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
|
||||
|
||||
model = os.getenv("LLM_MODEL") or _DEFAULT_MODEL[name]
|
||||
base_url = os.getenv("LLM_BASE_URL") or _DEFAULT_URL[name]
|
||||
api_key = os.getenv("LLM_API_KEY", "")
|
||||
|
||||
if name == ANTHROPIC:
|
||||
return AnthropicProvider(base_url, model, api_key, timeout())
|
||||
if name == OPENAI:
|
||||
if not api_key:
|
||||
raise LLMError("Brak LLM_API_KEY — dostawca openai wymaga klucza.")
|
||||
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
|
||||
leaves_lan=True)
|
||||
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
|
||||
return ChatCompletionsProvider(LOCAL, base_url, model, api_key, timeout(),
|
||||
leaves_lan=False)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Implementacje dostawców LLM (LOG-31) — na samym httpx, bez SDK.
|
||||
|
||||
Świadomie bez bibliotek `openai` / `anthropic`: lokalny serwer modelu (Ollama,
|
||||
vLLM, llama.cpp) i OpenAI mówią **tym samym** protokołem `/chat/completions`,
|
||||
więc jedna implementacja obsługuje oba — różni je tylko adres i klucz. Anthropic
|
||||
ma własny kształt `/v1/messages`, stąd druga klasa. Mniej zależności, mniej
|
||||
powierzchni ataku, pełna kontrola nad tym, co wychodzi z sieci.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.llm.base import Completion, LLMError, LLMProvider
|
||||
|
||||
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict:
|
||||
"""POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx."""
|
||||
last: Exception | None = None
|
||||
for attempt in range(MAX_ATTEMPTS):
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
r = client.post(url, json=payload, headers=headers)
|
||||
if r.status_code in RETRY_STATUSES and attempt < MAX_ATTEMPTS - 1:
|
||||
time.sleep(2 ** attempt)
|
||||
continue
|
||||
if r.status_code >= 400:
|
||||
raise LLMError(f"Model odpowiedział błędem {r.status_code}: {r.text[:300]}")
|
||||
return r.json()
|
||||
except httpx.TimeoutException as e:
|
||||
last = e
|
||||
if attempt < MAX_ATTEMPTS - 1:
|
||||
time.sleep(2 ** attempt)
|
||||
continue
|
||||
raise LLMError(
|
||||
f"Model nie odpowiedział w czasie {timeout:.0f}s. Dłuższe horoskopy "
|
||||
f"wymagają większego LLM_TIMEOUT albo mniejszego budżetu promptu."
|
||||
) from e
|
||||
except httpx.HTTPError as e:
|
||||
last = e
|
||||
if attempt < MAX_ATTEMPTS - 1:
|
||||
time.sleep(2 ** attempt)
|
||||
continue
|
||||
raise LLMError(f"Nie udało się połączyć z modelem: {e}") from e
|
||||
raise LLMError(f"Nie udało się wywołać modelu: {last}")
|
||||
|
||||
|
||||
class ChatCompletionsProvider(LLMProvider):
|
||||
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
|
||||
|
||||
def __init__(self, name: str, base_url: str, model: str, api_key: str = "",
|
||||
timeout: float = 120.0, leaves_lan: bool = True) -> None:
|
||||
self.name = name
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.leaves_lan = leaves_lan
|
||||
|
||||
def _headers(self) -> dict:
|
||||
h = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
h["Authorization"] = f"Bearer {self.api_key}"
|
||||
return h
|
||||
|
||||
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||
data = _post_with_retry(
|
||||
f"{self.base_url}/chat/completions", self._headers(),
|
||||
{
|
||||
"model": self.model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
},
|
||||
self.timeout,
|
||||
)
|
||||
try:
|
||||
text = data["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||
return Completion(
|
||||
text=text, model=data.get("model", self.model), provider=self.name,
|
||||
leaves_lan=self.leaves_lan, usage=data.get("usage") or {},
|
||||
)
|
||||
|
||||
def health(self) -> dict:
|
||||
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
|
||||
try:
|
||||
with httpx.Client(timeout=min(self.timeout, 10.0)) as client:
|
||||
r = client.get(f"{self.base_url}/models", headers=self._headers())
|
||||
info["status"] = "ok" if r.status_code < 400 else f"http {r.status_code}"
|
||||
except httpx.HTTPError as e:
|
||||
info["status"] = f"down: {e}"
|
||||
return info
|
||||
|
||||
|
||||
class AnthropicProvider(LLMProvider):
|
||||
"""Protokół Anthropic `/v1/messages`."""
|
||||
|
||||
leaves_lan = True
|
||||
|
||||
def __init__(self, base_url: str, model: str, api_key: str = "",
|
||||
timeout: float = 120.0) -> None:
|
||||
self.name = "anthropic"
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||
if not self.api_key:
|
||||
raise LLMError("Brak LLM_API_KEY — dostawca anthropic wymaga klucza.")
|
||||
data = _post_with_retry(
|
||||
f"{self.base_url}/v1/messages",
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": self.api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
{
|
||||
"model": self.model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
},
|
||||
self.timeout,
|
||||
)
|
||||
try:
|
||||
text = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text")
|
||||
except (KeyError, TypeError) as e:
|
||||
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||
return Completion(
|
||||
text=text, model=data.get("model", self.model), provider=self.name,
|
||||
leaves_lan=True, usage=data.get("usage") or {},
|
||||
)
|
||||
|
||||
def health(self) -> dict:
|
||||
return {
|
||||
"provider": self.name, "model": self.model, "leaves_lan": True,
|
||||
"status": "ok (klucz ustawiony)" if self.api_key else "brak LLM_API_KEY",
|
||||
}
|
||||
@@ -203,6 +203,54 @@ def chart_prompt(req: PromptRequest) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
class HoroscopeRequest(PromptRequest):
|
||||
"""Jak PromptRequest + wybór dostawcy modelu (LOG-31)."""
|
||||
provider: str | None = None # local (dom.) | openai | anthropic
|
||||
max_tokens: int | None = None
|
||||
|
||||
|
||||
@app.post("/chart/horoscope")
|
||||
def chart_horoscope(req: HoroscopeRequest) -> dict:
|
||||
"""Napisany horoskop (LOG-31): prompt z LOG-29/30 → model → gotowy tekst.
|
||||
|
||||
Zwraca TAKŻE użyty prompt — również gdy wywołanie modelu padnie — żeby dało się
|
||||
go obejrzeć i użyć ręcznie. `leaves_lan` mówi, czy treść baz opuściła naszą sieć
|
||||
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
|
||||
"""
|
||||
from app.llm.base import LLMError
|
||||
from app.llm.factory import build_provider, max_tokens
|
||||
|
||||
out = chart_prompt(req) # ten sam prompt co w podglądzie
|
||||
try:
|
||||
provider = build_provider(req.provider)
|
||||
result = provider.generate(out["prompt"], req.max_tokens or max_tokens())
|
||||
except LLMError as e:
|
||||
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
|
||||
out["llm_error"] = str(e)
|
||||
return out
|
||||
|
||||
out.update(
|
||||
horoscope=result.text,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
leaves_lan=result.leaves_lan,
|
||||
usage=result.usage,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/llm/health")
|
||||
def llm_health(provider: str | None = None) -> dict:
|
||||
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
|
||||
from app.llm.base import LLMError
|
||||
from app.llm.factory import build_provider
|
||||
|
||||
try:
|
||||
return build_provider(provider).health()
|
||||
except LLMError as e:
|
||||
return {"status": f"blad konfiguracji: {e}"}
|
||||
|
||||
|
||||
class ProfectionsRequest(BaseModel):
|
||||
when_utc: datetime # moment urodzenia (UTC)
|
||||
lat: float = 0.0
|
||||
|
||||
@@ -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"
|
||||
@@ -82,6 +82,28 @@ class LogicClient:
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def horoscope(
|
||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||
budget: str = "medium", provider: str | None = None,
|
||||
from_date: str | None = None, to_date: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
|
||||
|
||||
Generowanie tekstu trwa (zwłaszcza na modelu lokalnym), stąd długi timeout.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"profile": profile, "when_utc": when_utc_iso,
|
||||
"lat": lat, "lon": lon, "budget": budget,
|
||||
}
|
||||
if provider:
|
||||
payload["provider"] = provider
|
||||
if from_date and to_date:
|
||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client:
|
||||
r = client.post(f"{self.base_url}/chart/horoscope", json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def timeline(
|
||||
self, when_utc_iso: str, lat: float, lon: float,
|
||||
from_date: str, to_date: str, interpret: bool = True,
|
||||
|
||||
@@ -130,9 +130,11 @@ def interpret_run(
|
||||
group: bool = Form(False),
|
||||
action: str = Form("report"),
|
||||
prompt_budget: str = Form("medium"),
|
||||
llm_provider: str = Form("local"),
|
||||
):
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget}
|
||||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||||
"llm_provider": llm_provider}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
@@ -141,6 +143,11 @@ def interpret_run(
|
||||
ctx["prompt_result"] = logic.prompt(
|
||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
||||
)
|
||||
elif action == "horoscope":
|
||||
ctx["prompt_result"] = logic.horoscope(
|
||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, provider=llm_provider,
|
||||
)
|
||||
else:
|
||||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||
except httpx.HTTPError as e:
|
||||
@@ -171,9 +178,11 @@ def timeline_run(
|
||||
to_date: str = Form(...),
|
||||
action: str = Form("timeline"),
|
||||
prompt_budget: str = Form("medium"),
|
||||
llm_provider: str = Form("local"),
|
||||
):
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget}
|
||||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||||
"llm_provider": llm_provider}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
@@ -183,6 +192,12 @@ def timeline_run(
|
||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
||||
)
|
||||
elif action == "horoscope":
|
||||
ctx["prompt_result"] = logic.horoscope(
|
||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
budget=prompt_budget, provider=llm_provider,
|
||||
from_date=from_date, to_date=to_date,
|
||||
)
|
||||
else:
|
||||
ctx["result"] = logic.timeline(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{# Wspólny blok: sterowanie budżetem + wynik generatora promptu (LOG-29/30).
|
||||
{# Wspólny blok: sterowanie generowaniem + prompt (LOG-29/30) + horoskop (LOG-31).
|
||||
Używany na ekranach Interpretacje i Kalendarz. #}
|
||||
<div class="opts">
|
||||
<label>Budżet promptu
|
||||
@@ -9,12 +9,58 @@
|
||||
<option value="extensive" {{ 'selected' if pb == 'extensive' else '' }}>obszerny (~30 tys.)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Model
|
||||
<select name="llm_provider">
|
||||
{% set lp = form.llm_provider or 'local' %}
|
||||
<option value="local" {{ 'selected' if lp == 'local' else '' }}>lokalny — nic nie opuszcza sieci</option>
|
||||
<option value="anthropic" {{ 'selected' if lp == 'anthropic' else '' }}>Anthropic — dane wychodzą</option>
|
||||
<option value="openai" {{ 'selected' if lp == 'openai' else '' }}>OpenAI — dane wychodzą</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted small">
|
||||
Prompt zawiera <strong>oryginalne opisy z baz</strong> oraz dane urodzeniowe. Model lokalny
|
||||
przetwarza je u nas; wybór dostawcy w chmurze oznacza, że ta treść <strong>opuszcza naszą
|
||||
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
|
||||
</p>
|
||||
|
||||
{% if prompt_result %}
|
||||
{% set st = prompt_result.stats %}
|
||||
|
||||
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
|
||||
{% if prompt_result.horoscope %}
|
||||
<div class="meta">
|
||||
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
|
||||
model: {{ prompt_result.model }}
|
||||
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
|
||||
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
|
||||
{% endif %}
|
||||
{% if prompt_result.leaves_lan %}
|
||||
· <strong class="retro">dane opuściły sieć</strong>
|
||||
{% else %}
|
||||
· dane nie opuściły sieci
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
|
||||
</div>
|
||||
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
|
||||
<p class="muted small">
|
||||
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
|
||||
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if prompt_result.llm_error %}
|
||||
<div class="error">
|
||||
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
|
||||
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
|
||||
<div class="meta">
|
||||
Prompt gotowy · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
|
||||
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
|
||||
budżet: {{ st.budget }} ·
|
||||
wskazań: <strong>{{ st.included }}</strong>
|
||||
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
|
||||
@@ -34,7 +80,7 @@
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
|
||||
<span class="muted small">Wklej do ChatGPT lub Claude.</span>
|
||||
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
|
||||
</div>
|
||||
<textarea id="promptText" class="prompt" rows="18" readonly>{{ prompt_result.prompt }}</textarea>
|
||||
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
|
||||
{% endif %}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
|
||||
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user