Compare commits

...

5 Commits

Author SHA1 Message Date
gitea 877ec91ff0 fix(llm): pusta odpowiedz modelu to blad, nie pusta strona
build / build (push) Successful in 57s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 13m7s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m57s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 40s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
Objaw zgloszony przez uzytkownika: w Kalendarzu horoskop wyswietla sie
poprawnie, w Interpretacjach zapytanie wychodzi, wraca — i NIC sie nie
pokazuje. Bez zadnego komunikatu.

Przyczyna: model potrafi oddac pusta tresc (finish_reason=length,
completion_tokens=0), a generate() zwracalo wtedy pusty tekst BEZ bledu.
Widok sprawdza {% if prompt_result.horoscope %} -> falsz -> nie renderuje nic,
a llm_error nie jest ustawiony -> zero wyjasnienia. Cicha awaria.

Asymetria miedzy ekranami wynika z rozmiaru promptu: natalny (13 obiektow x
fasety x opisy z bazy) wypelnia okno kontekstu modelu lokalnego i na odpowiedz
nie zostaje miejsca; okresowy jest mniejszy i sie miesci.

- wspolny straznik _require_text() dla obu dostawcow: pusta lub bialoznakowa
  odpowiedz podnosi LLMError,
- komunikat PROWADZI DO PRZYCZYNY: podaje finish_reason i zuzycie tokenow oraz
  radzi zmniejszyc budzet promptu / zwiekszyc num_ctx / LLM_MAX_TOKENS,
- Anthropic sprowadzony do wspolnego ksztaltu diagnostyki (input/output_tokens).

Dzieki temu uzytkownik widzi powod ORAZ gotowy prompt do recznego uzycia.

Zweryfikowane na zywym stosie z atrapa modelu oddajaca pusta tresc: zamiast
pustej strony pojawia sie pelny komunikat z diagnostyka. Testy: 148 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:05:20 +00:00
gitea 487dbb8fd3 fix(ci): smoke test silnika B bez kontenera w tle i bez sieci
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m54s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m54s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 39s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 21s
build / build (push) Successful in 56s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m53s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m51s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 39s
Testy / Kontrola składni wszystkich warstw (push) Successful in 20s
Job „Build obrazu silnika B" padal na kazdym przebiegu po pierwszym:

    Conflict. The container name "/swe" is already in use

Dwie wady starego kroku, obie moje:
1. startowal kontener w tle (docker run -d --name swe) i NIGDY go nie usuwal,
   wiec nazwa zostawala zajeta na runnerze i kolejne przebiegi sie wywalaly;
2. pukal curl-em w localhost:8003, podczas gdy job Gitea Actions sam dziala
   w kontenerze, a -p publikuje port na HOSCIE — to nie ten sam localhost,
   wiec health-check i tak nie mial prawa dojsc.

Teraz test biegnie WEWNATRZ obrazu (docker run --rm ... python -), wolajac
funkcje endpointow wprost. Omija oba problemy, nie zostawia niczego po sobie,
a sprawdza to samo i wiecej: obraz sie zbudowal, pyswisseph liczy, komplet 13
obiektow, Slonce w oczekiwanym zakresie, SN = NN + 180.

Dodany krok sprzatajacy osierocony kontener „swe" ze starych przebiegow.

Zweryfikowane lokalnie na realnym pyswisseph: Sun=40.2102, NN=68.1530,
13 obiektow — zgodnie z wyrocznia.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:29:27 +02:00
gitea 5203ba9e76 fix(llm): konfiguracja per dostawca — przelacznik w UI byl iluzja
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m49s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 28s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 23s
build / build (push) Successful in 1m14s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m0s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m0s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 50s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
UI pozwala wybrac dostawce przy KAZDYM zadaniu, ale factory czytalo jedna
wspolna trojke LLM_MODEL / LLM_BASE_URL / LLM_API_KEY dla wszystkich. Na
klastrze LLM_BASE_URL trzeba ustawic na lokalny model (localhost:11434 w podzie
nie istnieje) — i wtedy:
  - wybor „OpenAI" wysylal zadanie do Ollamy,
  - LLM_MODEL=llama3.1:8b kazal Anthropic uzyc modelu llama,
  - jeden LLM_API_KEY nie moze byc kluczem OpenAI i Anthropic naraz.
Czyli nie bylo miejsca, w ktore dalo sie sensownie wpisac klucze do chmury.

- konfiguracja per dostawca: <DOSTAWCA>_MODEL / _BASE_URL / _API_KEY
  (LOCAL_*, OPENAI_*, ANTHROPIC_*),
- zgodnosc wstecz: wspolne LLM_* dziala nadal, ale stosuje sie WYLACZNIE do
  dostawcy domyslnego (LLM_PROVIDER) — instalacja jednodostawcowa bez zmian,
- Anthropic dostal brakujaca walidacje klucza (mial ja tylko OpenAI),
- komunikat bledu wskazuje konkretna zmienna do ustawienia.

Testy regresyjne pilnuja, ze ustawienia jednego dostawcy NIE przeciekaja na
pozostalych. Calosc: 143 passed / 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:32:04 +02:00
gitea 64d1afc76d fix(presentation): brakujacy token przy /chart/prompt i /chart/horoscope
build / build (push) Successful in 59s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m57s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m52s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 30s
Testy / Kontrola składni wszystkich warstw (push) Successful in 18s
Blad z mergu: _auth_headers() dodano na galezi hardeningu, ktora odbila sie
od mastera ZANIM powstaly metody prompt() i horoscope() (LOG-29/30, LOG-31).
Git zmergowal obie zmiany czysto — byly w roznych liniach — ale semantycznie
nowe metody wyszly bez tokenu i dostawaly 401 przy wlaczonej ochronie.

Skutek dla uzytkownika: przyciski „Generuj prompt (AI)" i „Napisz horoskop"
nie dzialaly po wdrozeniu INTERNAL_TOKEN, mimo ze reszta aplikacji dzialala.

- naprawione oba wywolania,
- nowy test strukturalny (AST): KAZDE wyjscie HTTP w dol musi niesc headers=.
  Test jednej metody by tego nie zlapal — regula musi byc pilnowana calosciowo.

Straznik zweryfikowany sabotazem: po usunieciu naglowka test pada ze
wskazaniem konkretnej linii; po przywroceniu 15/15 przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:02:40 +00:00
gitea 4da5f5fe7e docs: braki bezpieczenstwa jako wymagania (PRE-16/17, DAN-25/26, LOG-33)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m53s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m56s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 44s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 34s
build / build (push) Successful in 52s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m51s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m56s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 30s
Testy / Kontrola składni wszystkich warstw (push) Successful in 20s
Obecny poziom ochrony wystarcza do developmentu, ale luki musza byc zapisane,
zeby nie wyparowaly przed produkcja.

- PRE-16 (Must) HTTPS/TLS — dzis Basic Auth leci po http, czyli haslo da sie
  podsluchac. Odblokowuje przy okazji DWIE funkcje zepsute z tego samego
  powodu: geolokalizacje (Tu i teraz) i kopiowanie do schowka — oba wymagaja
  secure context.
- PRE-17 (Should) konta imienne + slad audytowy zamiast jednego wspolnego
  hasla; bez tego nie wiadomo, kto pobieral dane, ani jak odciac jedna osobe.
- DAN-25 (Must) ograniczenie udzialu NFS — kto ma do niego dostep, bierze
  komplet baz z pominieciem aplikacji. Dzis najkrotsza droga do wycieku.
- DAN-26 (Should) znakowanie baz rekordami-pulapkami — zabezpieczenie
  detekcyjne: pozwala udowodnic zrodlo wycieku.
- LOG-33 (Should) sekrety w spoczynku (etcd to tylko base64) + rotacja.

Q-12 odnotowane jako rozstrzygniete: bazy zostaly KUPIONE, wiec zgoda jest —
ale to nie zwalnia z ochrony. LOG-32 przestawione na "W trakcie" z wykazem,
co juz wdrozone, a co zostaje.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:47:21 +02:00
7 changed files with 312 additions and 32 deletions
+41 -15
View File
@@ -82,25 +82,51 @@ jobs:
- name: docker build - name: docker build
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
- name: Smoke test (health + pozycje) # Test biegnie WEWNĄTRZ obrazu, bez sieci i bez kontenera w tle. Poprzednia
# wersja startowała kontener w tle (--name swe) i pukała curl-em w
# localhost:8003 — co miało dwie wady:
# 1. nie sprzątała kontenera, więc każdy kolejny przebieg padał na
# konflikcie nazwy (Conflict. The container name "/swe" is already in use),
# 2. job Gitea Actions sam działa w kontenerze, a -p publikuje port na
# HOŚCIE — więc localhost joba to nie ten sam localhost.
# Wywołanie funkcji endpointów wprost omija oba problemy, a sprawdza to samo:
# obraz się zbudował, pyswisseph liczy, kontrakt /positions się zgadza.
# --rm gwarantuje, że nic nie zostaje po przebiegu.
- name: Smoke test (health + pozycje) wewnątrz obrazu
run: | run: |
docker run -d --name swe -p 8003:8003 astrololo/engine-swisseph:ci docker run --rm astrololo/engine-swisseph:ci python - <<'PY'
for i in $(seq 1 30); do from datetime import datetime, timezone
curl -fsS http://localhost:8003/health >/dev/null 2>&1 && break
sleep 1 from app.main import DEFAULT_OBJECTS, PositionsRequest, health, positions
done
curl -fsS http://localhost:8003/health h = health()
echo assert h["status"] == "ok", h
print("health:", h)
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy # Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy. # silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
curl -fsS -X POST http://localhost:8003/positions \ req = PositionsRequest(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
-H 'Content-Type: application/json' \ lat=50.0647, lon=19.9450)
-d '{"when_utc":"1984-04-30T09:20:00Z","lat":50.0647,"lon":19.9450}' out = positions(req)
echo by = {p["name"]: p for p in out["positions"]}
- name: Logi kontenera (gdy coś padło) assert out["engine"] == "swisseph", out["engine"]
if: failure() assert len(by) == len(DEFAULT_OBJECTS), sorted(by)
run: docker logs swe || true sun = by["Sun"]["longitude"]
assert 39.5 < sun < 41.0, f"Slonce poza oczekiwanym zakresem: {sun}"
nn, sn = by["North Node"]["longitude"], by["South Node"]["longitude"]
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
print("SMOKE OK")
PY
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
- name: Usuń osierocony kontener ze starych przebiegów
if: always()
run: docker rm -f swe 2>/dev/null || true
compile-all: compile-all:
name: Kontrola składni wszystkich warstw name: Kontrola składni wszystkich warstw
Binary file not shown.
+43 -11
View File
@@ -4,13 +4,26 @@ 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 — domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
przez konfigurację albo pojedyncze żądanie. przez konfigurację albo pojedyncze żądanie.
Konfiguracja jest **per dostawca**, bo UI pozwala przełączać go przy każdym żądaniu.
Wspólne `LLM_*` nie wystarczy: ustawienie `LLM_BASE_URL` na lokalny model kierowałoby
tam także żądania do OpenAI, a `LLM_MODEL=llama3.1:8b` kazałoby Anthropic użyć modelu
llama. Dlatego każdy dostawca ma własny komplet zmiennych.
Zmienne środowiskowe: Zmienne środowiskowe:
LLM_PROVIDER local (domyślnie) | openai | anthropic LLM_PROVIDER local (domyślnie) | openai | anthropic — dostawca domyślny
LLM_MODEL nazwa modelu (domyślna zależy od dostawcy) LLM_TIMEOUT sekundy (domyślnie 120)
LLM_BASE_URL adres API (domyślnie: lokalny serwer zgodny z OpenAI) LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
LLM_API_KEY klucz — WYŁĄCZNIE z sekretu; niepotrzebny dla modelu lokalnego
LLM_TIMEOUT sekundy (domyślnie 120) <DOSTAWCA>_MODEL / _BASE_URL / _API_KEY — konfiguracja konkretnego dostawcy:
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000) LOCAL_MODEL, LOCAL_BASE_URL (klucz zwykle zbędny)
OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_API_KEY
ANTHROPIC_MODEL, ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY
Klucze WYŁĄCZNIE z sekretu — nigdy w repo, w UI ani w logach.
Zgodność wstecz: wspólne `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` nadal działają,
ale stosują się TYLKO do dostawcy domyślnego (LLM_PROVIDER) — czyli konfiguracja
instalacji jednodostawcowej zostaje nietknięta, a pozostali dostawcy jej nie dziedziczą.
""" """
from __future__ import annotations from __future__ import annotations
@@ -49,20 +62,39 @@ def timeout() -> float:
return float(os.getenv("LLM_TIMEOUT", "120")) return float(os.getenv("LLM_TIMEOUT", "120"))
def setting(provider: str, suffix: str, fallback: str = "") -> str:
"""Ustawienie dostawcy: <DOSTAWCA>_<SUFIKS> → LLM_<SUFIKS> → wbudowana domyślna.
Wspólne `LLM_*` stosuje się WYŁĄCZNIE do dostawcy domyślnego — inaczej adres
lokalnego modelu przejąłby żądania do chmury (i odwrotnie).
"""
specific = os.getenv(f"{provider.upper()}_{suffix}")
if specific:
return specific
if provider == default_provider_name():
generic = os.getenv(f"LLM_{suffix}")
if generic:
return generic
return fallback
def build_provider(name: str | None = None) -> LLMProvider: def build_provider(name: str | None = None) -> LLMProvider:
name = (name or default_provider_name()).lower() name = (name or default_provider_name()).lower()
if name not in PROVIDERS: if name not in PROVIDERS:
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})") raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
model = os.getenv("LLM_MODEL") or _DEFAULT_MODEL[name] model = setting(name, "MODEL", _DEFAULT_MODEL[name])
base_url = os.getenv("LLM_BASE_URL") or _DEFAULT_URL[name] base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
api_key = os.getenv("LLM_API_KEY", "") api_key = setting(name, "API_KEY")
if name in (OPENAI, ANTHROPIC) and not api_key:
raise LLMError(
f"Brak klucza dla dostawcy {name} — ustaw {name.upper()}_API_KEY "
f"(z sekretu). Model lokalny klucza nie wymaga."
)
if name == ANTHROPIC: if name == ANTHROPIC:
return AnthropicProvider(base_url, model, api_key, timeout()) return AnthropicProvider(base_url, model, api_key, timeout())
if name == OPENAI: 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(), return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
leaves_lan=True) leaves_lan=True)
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci # lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
+45 -3
View File
@@ -49,6 +49,39 @@ def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) ->
raise LLMError(f"Nie udało się wywołać modelu: {last}") raise LLMError(f"Nie udało się wywołać modelu: {last}")
def _require_text(text: str, usage: dict, finish_reason: str | None, max_tokens: int) -> str:
"""Pusta odpowiedź modelu MUSI być błędem, nie pustym tekstem.
Inaczej mamy cichą awarię: warstwa wyżej dostaje `horoscope=""`, widok nic nie
renderuje i NIC nie tłumaczy użytkownikowi, dlaczego strona wróciła pusta.
Najczęstsza przyczyna: prompt wypełnił okno kontekstu modelu, więc na odpowiedź
nie zostało miejsca (`finish_reason=length`, `completion_tokens=0`) — dotyczy to
zwłaszcza obszernych promptów natalnych na modelach lokalnych o małym kontekście.
"""
if text and text.strip():
return text
powod = []
if finish_reason:
powod.append(f"finish_reason={finish_reason}")
if usage:
pt, ct = usage.get("prompt_tokens"), usage.get("completion_tokens")
if pt is not None:
powod.append(f"tokeny promptu={pt}")
if ct is not None:
powod.append(f"tokeny odpowiedzi={ct}")
szczegoly = f" ({', '.join(powod)})" if powod else ""
rada = (
"Najczęstsza przyczyna: prompt nie zmieścił się w oknie kontekstu modelu i na "
"odpowiedź nie zostało miejsca. Zmniejsz budżet promptu (zwięzły), zwiększ okno "
"kontekstu modelu (w Ollamie num_ctx) albo podnieś LLM_MAX_TOKENS."
if finish_reason == "length" or (usage or {}).get("completion_tokens") == 0
else "Model przyjął żądanie, ale nie wygenerował treści."
)
raise LLMError(f"Model zwrócił pustą odpowiedź{szczegoly}. {rada}")
class ChatCompletionsProvider(LLMProvider): class ChatCompletionsProvider(LLMProvider):
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI.""" """Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
@@ -78,12 +111,15 @@ class ChatCompletionsProvider(LLMProvider):
self.timeout, self.timeout,
) )
try: try:
text = data["choices"][0]["message"]["content"] choice = data["choices"][0]
text = choice["message"]["content"]
except (KeyError, IndexError, TypeError) as e: except (KeyError, IndexError, TypeError) as e:
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
usage = data.get("usage") or {}
text = _require_text(text, usage, choice.get("finish_reason"), max_tokens)
return Completion( return Completion(
text=text, model=data.get("model", self.model), provider=self.name, text=text, model=data.get("model", self.model), provider=self.name,
leaves_lan=self.leaves_lan, usage=data.get("usage") or {}, leaves_lan=self.leaves_lan, usage=usage,
) )
def health(self) -> dict: def health(self) -> dict:
@@ -131,9 +167,15 @@ class AnthropicProvider(LLMProvider):
text = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text") text = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text")
except (KeyError, TypeError) as e: except (KeyError, TypeError) as e:
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
usage = data.get("usage") or {}
# Anthropic nazywa to inaczej — sprowadzamy do wspólnego kształtu dla diagnostyki
norm = {"prompt_tokens": usage.get("input_tokens"),
"completion_tokens": usage.get("output_tokens")}
text = _require_text(text, {k: v for k, v in norm.items() if v is not None},
data.get("stop_reason"), max_tokens)
return Completion( return Completion(
text=text, model=data.get("model", self.model), provider=self.name, text=text, model=data.get("model", self.model), provider=self.name,
leaves_lan=True, usage=data.get("usage") or {}, leaves_lan=True, usage=usage,
) )
def health(self) -> dict: def health(self) -> dict:
+119 -1
View File
@@ -133,7 +133,9 @@ def test_default_provider_is_local(monkeypatch):
def test_openai_requires_key(monkeypatch): def test_openai_requires_key(monkeypatch):
monkeypatch.delenv("LLM_API_KEY", raising=False) monkeypatch.delenv("LLM_API_KEY", raising=False)
with pytest.raises(LLMError, match="LLM_API_KEY"): monkeypatch.delenv("OPENAI_API_KEY", raising=False)
# komunikat wskazuje ZMIENNĄ DO USTAWIENIA dla tego dostawcy, nie ogólne LLM_API_KEY
with pytest.raises(LLMError, match="OPENAI_API_KEY"):
factory.build_provider("openai") factory.build_provider("openai")
@@ -147,3 +149,119 @@ def test_env_overrides_model_and_url(monkeypatch):
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1") monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
p = factory.build_provider("local") p = factory.build_provider("local")
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1" assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
# ---------------------------------------- konfiguracja per dostawca (regresja LOG-31)
# UI pozwala przelaczac dostawce przy kazdym zadaniu, wiec ustawienia JEDNEGO nie moga
# przeciekac na pozostalych. Wczesniej wspolne LLM_BASE_URL/LLM_MODEL kierowaly zadania
# do OpenAI na adres lokalnej Ollamy i prosily Anthropic o model llama.
def _clear(monkeypatch):
for v in ("LLM_PROVIDER", "LLM_MODEL", "LLM_BASE_URL", "LLM_API_KEY",
"LOCAL_MODEL", "LOCAL_BASE_URL", "LOCAL_API_KEY",
"OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY",
"ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
monkeypatch.delenv(v, raising=False)
def test_local_config_does_not_leak_to_cloud(monkeypatch):
"""Sedno bledu: skonfigurowany model lokalny przejmowal zadania do chmury."""
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("LLM_BASE_URL", "http://ollama:11434/v1") # konfiguracja lokalnego
monkeypatch.setenv("LLM_MODEL", "llama3.1:8b")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
local = factory.build_provider("local")
assert local.base_url == "http://ollama:11434/v1" and local.model == "llama3.1:8b"
openai = factory.build_provider("openai")
assert openai.base_url == "https://api.openai.com/v1", "zadanie do OpenAI poszloby do Ollamy"
assert openai.model == "gpt-4o-mini", "OpenAI dostalby nazwe modelu llama"
def test_provider_specific_settings_win(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
p = factory.build_provider("anthropic")
assert p.model == "claude-opus-4-8" and p.api_key == "sk-ant"
def test_generic_vars_apply_only_to_default_provider(monkeypatch):
"""Zgodnosc wstecz: wspolne LLM_* konfiguruja dostawce domyslnego i tylko jego."""
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "openai")
monkeypatch.setenv("LLM_API_KEY", "sk-generic")
monkeypatch.setenv("LLM_MODEL", "gpt-4o")
assert factory.build_provider("openai").model == "gpt-4o"
assert factory.build_provider("local").model == "llama3.1:8b" # nie dziedziczy
def test_cloud_without_key_is_rejected_clearly(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
for name in ("openai", "anthropic"):
with pytest.raises(LLMError, match=f"{name.upper()}_API_KEY"):
factory.build_provider(name)
def test_local_needs_no_key(monkeypatch):
_clear(monkeypatch)
assert factory.build_provider("local").api_key == ""
# ------------------------------------------- pusta odpowiedz modelu (cicha awaria)
# Regresja: model potrafi oddac pusta tresc (prompt zjadl caly kontekst ->
# finish_reason=length, completion_tokens=0). Wczesniej generate() zwracalo pusty
# tekst BEZ bledu, widok nic nie renderowal i uzytkownik dostawal pusta strone
# bez zadnego wyjasnienia. Pusta odpowiedz MUSI byc bledem.
def test_empty_completion_raises_instead_of_silent_blank(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"model": "llama3.1:8b",
"choices": [{"message": {"content": ""}, "finish_reason": "length"}],
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
})))
with pytest.raises(LLMError, match="pustą odpowiedź"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
def test_empty_completion_explains_context_window(monkeypatch):
"""Komunikat ma prowadzic do przyczyny, a nie tylko stwierdzac fakt."""
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": " "}, "finish_reason": "length"}],
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
})))
with pytest.raises(LLMError) as ei:
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
msg = str(ei.value)
assert "kontekstu" in msg and "budżet" in msg
assert "tokeny promptu=8000" in msg # diagnostyka w tresci bledu
def test_whitespace_only_is_treated_as_empty(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": "\n\n \t "}}],
})))
with pytest.raises(LLMError, match="pustą odpowiedź"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100)
def test_anthropic_empty_completion_raises(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"model": "claude-x", "content": [], "stop_reason": "max_tokens",
"usage": {"input_tokens": 9000, "output_tokens": 0},
})))
with pytest.raises(LLMError, match="pustą odpowiedź"):
AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
def test_normal_response_still_passes(monkeypatch):
"""Straznik nie moze psuc poprawnej odpowiedzi."""
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": "Horoskop."}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 100, "completion_tokens": 20},
})))
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100).text == "Horoskop."
@@ -78,7 +78,7 @@ class LogicClient:
if from_date and to_date: if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date payload["from_date"], payload["to_date"] = from_date, to_date
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client: with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
r = client.post(f"{self.base_url}/chart/prompt", json=payload) r = client.post(f"{self.base_url}/chart/prompt", json=payload, headers=_auth_headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -100,7 +100,7 @@ class LogicClient:
if from_date and to_date: if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date payload["from_date"], payload["to_date"] = from_date, to_date
with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client: with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client:
r = client.post(f"{self.base_url}/chart/horoscope", json=payload) r = client.post(f"{self.base_url}/chart/horoscope", json=payload, headers=_auth_headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -0,0 +1,62 @@
"""Niezmiennik: KAŻDE wyjście HTTP w dół niesie token międzywarstwowy (LOG-32).
Powód istnienia tego testu: token dodano do klienta na gałęzi, która odbiła się od
mastera zanim powstały metody `prompt()` i `horoscope()`. Git zmergował obie zmiany
czysto (różne linie), ale nowe metody wyszły BEZ tokenu — i dostawały 401 dopiero na
produkcji. Zwykły test jednej metody by tego nie złapał, więc sprawdzamy regułę
strukturalnie: nie ma wywołania bez `headers=`.
"""
import ast
import pathlib
CLIENT = pathlib.Path(__file__).resolve().parents[1] / "app" / "clients" / "logic_client.py"
def _http_calls(path: pathlib.Path) -> list[tuple[str, int, bool]]:
"""(nazwa_metody_http, linia, czy_ma_headers) dla każdego client.post/get."""
tree = ast.parse(path.read_text(encoding="utf-8"))
out = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
if node.func.attr not in ("post", "get", "put", "patch", "delete"):
continue
if not (isinstance(node.func.value, ast.Name) and node.func.value.id == "client"):
continue
has_headers = any(kw.arg == "headers" for kw in node.keywords)
out.append((node.func.attr, node.lineno, has_headers))
return out
def test_client_module_exists():
assert CLIENT.is_file()
def test_every_outbound_call_sends_auth_header():
calls = _http_calls(CLIENT)
assert calls, "nie znaleziono żadnego wywołania HTTP — test przestał cokolwiek pilnować"
missing = [f"{CLIENT.name}:{line} client.{verb}()" for verb, line, ok in calls if not ok]
assert not missing, (
"Wywołania w dół bez tokenu międzywarstwowego (dostaną 401 przy włączonej "
"ochronie): " + ", ".join(missing)
)
def test_auth_headers_helper_is_lazy():
"""Token czytany przy wywołaniu, nie przy imporcie — inaczej pod wystartowałby
z pustym tokenem, gdyby zmienna pojawiła się później."""
import os
from app.clients.logic_client import _auth_headers
old = os.environ.get("INTERNAL_TOKEN")
try:
os.environ["INTERNAL_TOKEN"] = "abc"
assert _auth_headers() == {"X-Astrololo-Token": "abc"}
os.environ.pop("INTERNAL_TOKEN")
assert _auth_headers() == {} # ochrona wyłączona = brak nagłówka
finally:
if old is not None:
os.environ["INTERNAL_TOKEN"] = old
else:
os.environ.pop("INTERNAL_TOKEN", None)