diff --git a/services/logic/app/llm/__init__.py b/services/logic/app/llm/__init__.py new file mode 100644 index 0000000..62db606 --- /dev/null +++ b/services/logic/app/llm/__init__.py @@ -0,0 +1 @@ +"""Warstwa dostawców modeli językowych (LOG-31).""" diff --git a/services/logic/app/llm/base.py b/services/logic/app/llm/base.py new file mode 100644 index 0000000..a6b23a1 --- /dev/null +++ b/services/logic/app/llm/base.py @@ -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.""" diff --git a/services/logic/app/llm/factory.py b/services/logic/app/llm/factory.py new file mode 100644 index 0000000..47791ba --- /dev/null +++ b/services/logic/app/llm/factory.py @@ -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) diff --git a/services/logic/app/llm/providers.py b/services/logic/app/llm/providers.py new file mode 100644 index 0000000..8a1bc0f --- /dev/null +++ b/services/logic/app/llm/providers.py @@ -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", + } diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 3a68682..69f24d8 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -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 diff --git a/services/logic/tests/test_llm.py b/services/logic/tests/test_llm.py new file mode 100644 index 0000000..2726b49 --- /dev/null +++ b/services/logic/tests/test_llm.py @@ -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" diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py index a225cdb..0fb96fc 100644 --- a/services/presentation/app/clients/logic_client.py +++ b/services/presentation/app/clients/logic_client.py @@ -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, diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 46893e6..889739e 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -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, diff --git a/services/presentation/app/templates/_prompt_block.html b/services/presentation/app/templates/_prompt_block.html index d03a484..bc56d16 100644 --- a/services/presentation/app/templates/_prompt_block.html +++ b/services/presentation/app/templates/_prompt_block.html @@ -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. #}
+ Prompt zawiera oryginalne opisy z baz oraz dane urodzeniowe. Model lokalny + przetwarza je u nas; wybór dostawcy w chmurze oznacza, że ta treść opuszcza naszą + sieć. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać. +
{% if prompt_result %} {% set st = prompt_result.stats %} + + {# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #} + {% if prompt_result.horoscope %} + ++ Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz. + Nie stanowi porady medycznej, prawnej ani finansowej. +
+ {% endif %} + + {% if prompt_result.llm_error %} +