Compare commits

..

2 Commits

Author SHA1 Message Date
gitea a8b2321dd7 feat(logic): dostawcy LLM i pisanie horoskopu (LOG-31)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m48s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m46s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 26s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 19s
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>
2026-07-21 18:47:09 +02:00
gitea 80c008f076 feat(logic): generator promptow do LLM + budzetowanie (LOG-29, LOG-30)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m52s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 1m8s
Testy / Kontrola składni wszystkich warstw (push) Successful in 1m21s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m14s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 29s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 20s
Pierwszy krok ustalonej kolejnosci: prompt z podgladem, uzyteczny od razu
BEZ integracji API (ta przyjdzie w LOG-31).

LOG-29 — app/prompt.py:
- profil natal (ekran Interpretacje) i period (ekran Kalendarz),
- prompt po polsku: zadanie -> dane horoskopu (pozycje, osie, Lots, aspekty,
  sekta, zodiak) -> wskazania z baz wg wagi -> instrukcje -> zastrzezenie,
- twarde reguly: kazda teza musi cytowac konkretny sygnifikator, zakaz
  wychodzenia poza dostarczone dane, jawne wskazanie sprzecznosci,
- deterministyczny: ten sam horoskop + budzet = ten sam prompt.

LOG-30 — redukcja do budzetu (concise/medium/extensive):
dedup -> grupowanie z licznikiem -> sortowanie wg punktacji sily (LOG-21) ->
obciecie ogona (jednostka = CALE wskazanie) -> skracanie dlugich opisow.
Statystyki zwracaja ile weszlo/pominieto i jaki byl prog — takze w tresci
promptu, zeby model wiedzial, ze widzi wybor.

POST /chart/prompt — zwraca sam prompt + statystyki, bez wolania modelu.

Prezentacja: przycisk „Generuj prompt (AI)" na obu ekranach, wybor budzetu,
pole z promptem + kopiowanie (z fallbackiem dla http bez secure context).

WAZNE (znalezione przy tescie e2e): padnieta warstwa danych zabiera tylko
wskazania — horoskop i OS CZASU zostaja, bo sa czysto obliczeniowe. Wczesniej
blad bazy gubil cala osie czasu, czyniac prognoze okresowa bezuzyteczna.
Zabezpieczone testem.

Testy: 19 nowych, calosc 118 passed / 1 skipped. Zweryfikowane e2e w
przegladarce: profil natal (3340 znakow) i period (15 zdarzen, 4728 znakow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:31:51 +02:00
31 changed files with 108 additions and 2364 deletions
+15 -61
View File
@@ -51,26 +51,6 @@ jobs:
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
run: pytest tests -q -rs
presentation-tests:
name: Testy warstwy prezentacji (dostęp do baz)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/presentation/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/presentation/requirements-dev.txt
# Bramka chroniąca oryginalne bazy — nietestowany kod ochronny jest gorszy
# niż jego brak, bo daje złudzenie zabezpieczenia.
- name: Testy (pytest)
working-directory: services/presentation
env:
PYTHONPATH: .
run: pytest tests -q -rs
swisseph-image:
name: Build obrazu silnika B (swisseph)
runs-on: ubuntu-latest
@@ -82,51 +62,25 @@ jobs:
- name: docker build
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
# 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
- name: Smoke test (health + pozycje)
run: |
docker run --rm astrololo/engine-swisseph:ci python - <<'PY'
from datetime import datetime, timezone
from app.main import DEFAULT_OBJECTS, PositionsRequest, health, positions
h = health()
assert h["status"] == "ok", h
print("health:", h)
docker run -d --name swe -p 8003:8003 astrololo/engine-swisseph:ci
for i in $(seq 1 30); do
curl -fsS http://localhost:8003/health >/dev/null 2>&1 && break
sleep 1
done
curl -fsS http://localhost:8003/health
echo
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
req = PositionsRequest(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
lat=50.0647, lon=19.9450)
out = positions(req)
by = {p["name"]: p for p in out["positions"]}
curl -fsS -X POST http://localhost:8003/positions \
-H 'Content-Type: application/json' \
-d '{"when_utc":"1984-04-30T09:20:00Z","lat":50.0647,"lon":19.9450}'
echo
assert out["engine"] == "swisseph", out["engine"]
assert len(by) == len(DEFAULT_OBJECTS), sorted(by)
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
- name: Logi kontenera (gdy coś padło)
if: failure()
run: docker logs swe || true
compile-all:
name: Kontrola składni wszystkich warstw
Binary file not shown.
-2
View File
@@ -10,7 +10,6 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from app import security
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.factory import build_provider
@@ -25,7 +24,6 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
security.install(app, "danych") # token międzywarstwowy (LOG-32)
@app.post("/search", response_model=SearchResult)
+1 -4
View File
@@ -18,10 +18,7 @@ class SearchQuery(BaseModel):
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
value: str = Field(..., description="Szukana wartość.")
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
# Górny limit celowo niski: to zapytanie oddaje SUROWE wiersze baz, więc wysoki
# pułap zamienia je w narzędzie do masowego pobrania (LOG-32). 5000 = tyle, ile
# realnie potrzebuje build_report na jeden obiekt.
limit: int = Field(50, ge=1, le=5000)
limit: int = Field(50, ge=1, le=50000)
fields: list[str] | None = Field(
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
)
-50
View File
@@ -1,50 +0,0 @@
"""Uwierzytelnianie międzywarstwowe (LOG-32).
Warstwa danych oddaje SUROWE wiersze baz — to najbardziej wrażliwy punkt całego
systemu. Bez tego kontrolera wystarczyłoby uderzyć w nią bezpośrednio, z pominięciem
i logiki, i logowania w UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
musi go przynieść w nagłówku X-Astrololo-Token.
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
starcie leci ostrzeżenie.
"""
from __future__ import annotations
import logging
import os
import secrets
from fastapi import Request
from fastapi.responses import JSONResponse
log = logging.getLogger("astrololo.security")
HEADER = "X-Astrololo-Token"
PUBLIC_PATHS = frozenset({"/health"})
def token() -> str:
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
return os.getenv("INTERNAL_TOKEN", "")
def enabled() -> bool:
return bool(token())
def install(app, layer: str) -> None:
if not enabled():
log.warning(
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or not enabled():
return await call_next(request)
got = request.headers.get(HEADER, "")
if not secrets.compare_digest(got, token()):
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
status_code=401)
return await call_next(request)
+2 -9
View File
@@ -5,7 +5,6 @@ Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
"""
from __future__ import annotations
import os
from typing import Any
import httpx
@@ -13,12 +12,6 @@ import httpx
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
class DataClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.data_url).rstrip("/")
@@ -33,12 +26,12 @@ class DataClient:
) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/search", json=payload, headers=_auth_headers())
r = client.post(f"{self.base_url}/search", json=payload)
r.raise_for_status()
return r.json()
def health(self) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
r = client.get(f"{self.base_url}/health")
r.raise_for_status()
return r.json()
-16
View File
@@ -27,18 +27,6 @@ LUMINARIES = {"Sun", "Moon"}
DEFAULT_ORB = 8.0
LUMINARY_BONUS = 2.0
# Pary sztywno powiązane definicyjnie — kąt między nimi wynika z samej definicji
# punktu, nie z układu nieba (SN = NN + 180°). Aspekt taki zawsze wychodzi
# dokładny (orb 0,00°) i nie niesie żadnej informacji astrologicznej, więc
# wycinamy go z wyników: zaśmieca listę w UI i zjada budżet promptu do LLM.
RIGID_PAIRS = frozenset({
frozenset({"North Node", "South Node"}),
})
def _is_rigid(name_a: str, name_b: str) -> bool:
return frozenset({name_a, name_b}) in RIGID_PAIRS
def separation(a: float, b: float) -> float:
"""Najmniejsza separacja kątowa [0,180]."""
@@ -68,16 +56,12 @@ def find_aspects(
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
"""
out: list[dict] = []
n = len(positions)
for i in range(n):
for j in range(i + 1, n):
a, b = positions[i], positions[j]
if _is_rigid(a["name"], b["name"]):
continue
la, lb = a.get("decimal"), b.get("decimal")
if la is None or lb is None:
continue
-368
View File
@@ -1,368 +0,0 @@
"""Tabele pomocnicze horoskopu (LOG-23).
Zbiór wyliczeń, które astrolog czyta „obok" pozycji: bilans żywiołów i jakości,
faza Księżyca, stopnie krytyczne, dzień i godziny planetarne, syzygia prenatalna
oraz podziały (dwunastniki i nawamsa).
Dwie rzeczy wymagają prawdziwego liczenia, nie tabelki:
* **godziny planetarne** — są NIERÓWNE: dzień od wschodu do zachodu Słońca dzieli
się na 12 części, noc osobno. Bez faktycznego wschodu/zachodu wynik byłby
zmyślony, więc szukamy ich numerycznie (przejście wysokości Słońca przez 0°50);
* **syzygia prenatalna** — ostatni nów albo pełnia PRZED urodzeniem; szukamy
wstecz momentu, w którym elongacja Księżyca przechodzi przez 0° lub 180°.
Moduł jest silnik-agnostyczny: potrzebuje tylko `positions()` i `sidereal()`.
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
from app.engine.models import ChartMoment
from app.engine.zodiac import to_equatorial
# --- żywioły i jakości ------------------------------------------------------
ELEMENTS = ["Fire", "Earth", "Air", "Water"]
QUALITIES = ["Cardinal", "Fixed", "Mutable"]
ELEMENT_PL = {"Fire": "Ogień", "Earth": "Ziemia", "Air": "Powietrze", "Water": "Woda"}
QUALITY_PL = {"Cardinal": "Kardynalny", "Fixed": "Stały", "Mutable": "Zmienny"}
CLASSICAL = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"]
MODERN = CLASSICAL + ["Uranus", "Neptune", "Pluto"]
# --- dzień i godziny planetarne --------------------------------------------
# Kolejność chaldejska: od najwolniejszej do najszybszej planety
CHALDEAN = ["Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon"]
# Władca dnia wg dnia tygodnia (0 = poniedziałek, jak w datetime.weekday())
WEEKDAY_RULER = ["Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun"]
# wysokość środka tarczy Słońca przy wschodzie/zachodzie (refrakcja + promień tarczy)
SUNRISE_ALTITUDE = -0.833
def element_of(sign: str) -> str:
return ELEMENTS[SIGNS.index(sign) % 4]
def quality_of(sign: str) -> str:
return QUALITIES[SIGNS.index(sign) % 3]
def tally(positions: list[dict], asc_sign: str | None = None,
modern: bool = True) -> dict:
"""Bilans żywiołów i jakości (LOG-23).
Liczymy w dwóch wariantach naraz, bo szkoły się różnią: 7 planet klasycznych
i 10 z nowożytnymi. Ascendent doliczany osobno — bywa traktowany jak punkt
równorzędny planetom.
"""
wanted = MODERN if modern else CLASSICAL
by_name = {p.get("name"): p for p in positions}
def count(names: list[str], with_asc: bool) -> dict:
elements = dict.fromkeys(ELEMENTS, 0)
qualities = dict.fromkeys(QUALITIES, 0)
used = []
for name in names:
p = by_name.get(name)
if not p or not p.get("sign"):
continue
elements[element_of(p["sign"])] += 1
qualities[quality_of(p["sign"])] += 1
used.append(name)
if with_asc and asc_sign:
elements[element_of(asc_sign)] += 1
qualities[quality_of(asc_sign)] += 1
used.append("Asc")
return {"elements": elements, "qualities": qualities,
"counted": used, "total": len(used)}
classical = count(CLASSICAL, False)
result = {
"classical_7": classical,
"with_modern_10": count(wanted, False),
"classical_7_plus_asc": count(CLASSICAL, True),
"with_modern_10_plus_asc": count(wanted, True),
}
# brakujące żywioły — klasyczne „no air" itd., podstawa pod scoring (LOG-21)
base = result["with_modern_10_plus_asc"]
result["missing_elements"] = [e for e, n in base["elements"].items() if n == 0]
result["missing_qualities"] = [q for q, n in base["qualities"].items() if n == 0]
result["labels"] = {"elements": ELEMENT_PL, "qualities": QUALITY_PL}
return result
# --- faza Księżyca ----------------------------------------------------------
_PHASES = [
(0.0, "New Moon", "Nów"),
(45.0, "Waxing Crescent", "Sierp przybywający"),
(90.0, "First Quarter", "Pierwsza kwadra"),
(135.0, "Waxing Gibbous", "Garb przybywający"),
(180.0, "Full Moon", "Pełnia"),
(225.0, "Waning Gibbous", "Garb ubywający"),
(270.0, "Last Quarter", "Ostatnia kwadra"),
(315.0, "Waning Crescent", "Sierp ubywający"),
]
def moon_phase(sun_lon: float, moon_lon: float) -> dict:
"""Faza Księżyca z elongacji (Księżyc Słońce)."""
angle = norm360(moon_lon - sun_lon)
idx = int(((angle + 22.5) % 360.0) // 45.0)
_, name, name_pl = _PHASES[idx]
illumination = (1.0 - math.cos(math.radians(angle))) / 2.0
return {
"angle": round(angle, 4),
"phase": name,
"phase_pl": name_pl,
"illumination": round(illumination, 4),
"waxing": angle < 180.0,
}
# --- stopnie krytyczne ------------------------------------------------------
# klasyczne stopnie krytyczne zależą od jakości znaku
_CRITICAL = {"Cardinal": (0, 13, 26), "Fixed": (8, 21), "Mutable": (4, 17)}
CRITICAL_ORB = 1.0
def critical_degrees(positions: list[dict]) -> list[dict]:
"""Obiekty stojące na stopniach krytycznych, 0° albo 29° (anaretycznym)."""
out = []
for p in positions:
lon = p.get("decimal")
sign = p.get("sign")
if lon is None or not sign:
continue
deg = norm360(lon) - sign_index(lon) * 30.0
flags = []
for critical in _CRITICAL[quality_of(sign)]:
if abs(deg - critical) <= CRITICAL_ORB:
flags.append(f"stopień krytyczny {critical}° ({QUALITY_PL[quality_of(sign)].lower()})")
if deg >= 29.0:
flags.append("29° — stopień anaretyczny (koniec znaku)")
elif deg < 1.0:
flags.append("0° — wejście w znak")
if flags:
out.append({"name": p.get("name"), "sign": sign,
"in_sign": p.get("in_sign"), "flags": flags})
return out
# --- podziały: dwunastnik i nawamsa ----------------------------------------
def dwadasamsa(lon: float) -> float:
"""12. część (dwadasamsa): znak dzielony na 12 po 2°30, licząc od siebie."""
lon = norm360(lon)
start = sign_index(lon) * 30.0
return norm360(start + (lon - start) * 12.0)
def navamsa(lon: float) -> float:
"""9. część (nawamsa): 108 podziałów po 3°20 liczonych od 0° Barana."""
lon = norm360(lon)
part = int(lon // (30.0 / 9.0))
return norm360((part % 12) * 30.0 + (lon % (30.0 / 9.0)) * 9.0)
def divisional(positions: list[dict]) -> list[dict]:
"""Pozycje w podziałach 12. i 9. — obie tabele naraz."""
out = []
for p in positions:
lon = p.get("decimal")
if lon is None:
continue
d12, d9 = dwadasamsa(lon), navamsa(lon)
out.append({
"name": p.get("name"),
"d12_sign": SIGNS[sign_index(d12)], "d12_in_sign": in_sign(d12),
"d9_sign": SIGNS[sign_index(d9)], "d9_in_sign": in_sign(d9),
})
return out
# --- wschód/zachód Słońca i godziny planetarne ------------------------------
def sun_altitude(engine, moment: ChartMoment) -> float:
"""Wysokość Słońca nad horyzontem [°] dla momentu i miejsca."""
ramc, eps = engine.sidereal(moment)
sun = engine.positions(moment, ["Sun"])[0]
ra, dec = to_equatorial(sun.longitude, sun.latitude, eps)
hour_angle = math.radians(norm360(ramc - ra))
phi, d = math.radians(moment.lat), math.radians(dec)
sin_alt = math.sin(d) * math.sin(phi) + math.cos(d) * math.cos(phi) * math.cos(hour_angle)
return math.degrees(math.asin(max(-1.0, min(1.0, sin_alt))))
def _at(moment: ChartMoment, when: datetime) -> ChartMoment:
return ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon)
def _crossings(engine, moment: ChartMoment, start: datetime, end: datetime,
step_minutes: int = 20) -> list[tuple[datetime, str]]:
"""Momenty przejścia Słońca przez horyzont w oknie [start, end].
Skan zgrubny + bisekcja — ten sam wzorzec co przy stacjach planet (LOG-03).
"""
out: list[tuple[datetime, str]] = []
step = timedelta(minutes=step_minutes)
t0 = start
f0 = sun_altitude(engine, _at(moment, t0)) - SUNRISE_ALTITUDE
while t0 < end:
t1 = min(t0 + step, end)
f1 = sun_altitude(engine, _at(moment, t1)) - SUNRISE_ALTITUDE
if f0 == 0.0 or (f0 < 0.0) != (f1 < 0.0):
lo, hi, flo = t0, t1, f0
for _ in range(40): # ~sekundowa dokładność
mid = lo + (hi - lo) / 2
fmid = sun_altitude(engine, _at(moment, mid)) - SUNRISE_ALTITUDE
if (flo < 0.0) != (fmid < 0.0):
hi = mid
else:
lo, flo = mid, fmid
out.append((lo + (hi - lo) / 2, "sunrise" if f1 > f0 else "sunset"))
t0, f0 = t1, f1
return out
def planetary_hours(engine, moment: ChartMoment) -> dict | None:
"""Dzień i godziny planetarne w porządku chaldejskim (LOG-23).
Godziny są NIERÓWNE: dzień (wschód→zachód) i noc (zachód→wschód) dzielą się
na 12 części każde. Doba planetarna zaczyna się o WSCHODZIE, nie o północy —
dlatego władcę dnia bierzemy z dnia tygodnia tego wschodu, który otworzył
bieżący okres.
Zwraca None dla dnia polarnego/nocy polarnej, gdzie wschód nie występuje.
"""
now = moment.when_utc
events = _crossings(engine, moment, now - timedelta(hours=30), now + timedelta(hours=30))
if not events:
return None # brak wschodu/zachodu w oknie
before = [e for e in events if e[0] <= now]
after = [e for e in events if e[0] > now]
if not before or not after:
return None
last_time, last_kind = before[-1]
next_time, _ = after[0]
daytime = last_kind == "sunrise"
period_start, period_end = last_time, next_time
# doba planetarna startuje o wschodzie: w nocy to wschód POPRZEDZAJĄCY zachód
day_start = last_time if daytime else next((t for t, k in reversed(before)
if k == "sunrise"), last_time)
length = (period_end - period_start) / 12
index = int((now - period_start) / length)
index = max(0, min(11, index))
day_ruler = WEEKDAY_RULER[day_start.weekday()]
hour_number = index if daytime else index + 12 # 0..23 od wschodu
ruler = CHALDEAN[(CHALDEAN.index(day_ruler) + hour_number) % 7]
hours = []
for i in range(12):
start = period_start + length * i
hours.append({
"index": i + 1,
"ruler": CHALDEAN[(CHALDEAN.index(day_ruler) + (i if daytime else i + 12)) % 7],
"start": start.isoformat(timespec="seconds"),
"end": (start + length).isoformat(timespec="seconds"),
"current": i == index,
})
return {
"day_ruler": day_ruler,
"hour_ruler": ruler,
"hour_number": hour_number + 1,
"daytime": daytime,
"period": "dzień" if daytime else "noc",
"hour_length_minutes": round(length.total_seconds() / 60.0, 2),
"period_start": period_start.isoformat(timespec="seconds"),
"period_end": period_end.isoformat(timespec="seconds"),
"hours": hours,
}
# --- syzygia prenatalna -----------------------------------------------------
def prenatal_syzygy(engine, moment: ChartMoment, max_days: float = 32.0) -> dict | None:
"""Ostatni nów albo pełnia PRZED podanym momentem (LOG-23).
Szukamy wstecz przejścia elongacji przez 0° (nów) lub 180° (pełnia); bierzemy
to, które wypadło później. Cykl trwa ~29,5 dnia, więc okno 32 dni wystarcza.
"""
def elongation(when: datetime) -> float:
pts = {p.name: p.longitude for p in
engine.positions(_at(moment, when), ["Sun", "Moon"])}
return norm360(pts["Moon"] - pts["Sun"])
def signed(when: datetime, target: float) -> float:
"""Odległość od celu w [180, 180] — zeruje się dokładnie w syzygii."""
return ((elongation(when) - target + 180.0) % 360.0) - 180.0
best: tuple[datetime, str] | None = None
for target, kind in ((0.0, "new_moon"), (180.0, "full_moon")):
step = timedelta(hours=6)
t1 = moment.when_utc
f1 = signed(t1, target)
scanned = timedelta()
while scanned < timedelta(days=max_days):
t0 = t1 - step
f0 = signed(t0, target)
if (f0 < 0.0) != (f1 < 0.0) and abs(f0 - f1) < 180.0:
lo, hi, flo = t0, t1, f0
for _ in range(40):
mid = lo + (hi - lo) / 2
fmid = signed(mid, target)
if (flo < 0.0) != (fmid < 0.0):
hi = mid
else:
lo, flo = mid, fmid
found = lo + (hi - lo) / 2
if best is None or found > best[0]:
best = (found, kind)
break
t1, f1 = t0, f0
scanned += step
if best is None:
return None
when, kind = best
pts = {p.name: p.longitude for p in engine.positions(_at(moment, when), ["Sun", "Moon"])}
lon = pts["Sun"] if kind == "new_moon" else pts["Moon"]
return {
"type": kind,
"type_pl": "nów" if kind == "new_moon" else "pełnia",
"when_utc": when.isoformat(timespec="seconds"),
"days_before_birth": round((moment.when_utc - when).total_seconds() / 86400.0, 3),
"sign": SIGNS[sign_index(lon)],
"in_sign": in_sign(lon),
"decimal": round(norm360(lon), 6),
}
# --- złożenie wszystkiego ---------------------------------------------------
def build_tables(engine, moment: ChartMoment, chart: dict,
heavy: bool = True) -> dict:
"""Komplet tabel dla policzonego horoskopu.
`heavy=False` pomija wyliczenia wymagające szukania numerycznego (godziny
planetarne, syzygia) — przydatne, gdy liczy się czas odpowiedzi.
"""
positions = chart.get("positions") or []
by_name = {p.get("name"): p for p in positions}
asc_sign = (chart.get("angles") or {}).get("Asc", {}).get("sign")
out: dict = {
"tally": tally(positions, asc_sign),
"critical_degrees": critical_degrees(positions),
"divisional": divisional(positions),
}
if "Sun" in by_name and "Moon" in by_name:
out["moon_phase"] = moon_phase(by_name["Sun"]["decimal"], by_name["Moon"]["decimal"])
if heavy:
out["planetary_hours"] = planetary_hours(engine, moment)
out["prenatal_syzygy"] = prenatal_syzygy(engine, moment)
return out
-75
View File
@@ -1,75 +0,0 @@
"""Katalog modeli do wyboru w UI (LOG-31).
To są **podpowiedzi**, nie zamknięta lista. Pole modelu w UI jest tekstowe z
datalistą, więc można wpisać dowolny identyfikator — konto może mieć dostęp do
modeli, których tu nie ma, a nowe wychodzą szybciej, niż aktualizuje się kod.
Puste pole = model domyślny dostawcy.
Uwaga o pewności danych:
* modele **Anthropic** pochodzą z oficjalnej dokumentacji API (okna kontekstu
i limity wyjścia zgadzają się z `app/llm/limits.py`);
* modele **OpenAI** to podpowiedzi — nie weryfikowałem ich katalogu, więc
traktuj je jako wygodę, a nie źródło prawdy;
* modele **lokalne** zależą wyłącznie od tego, co masz pobrane w Ollamie/vLLM.
Katalog można nadpisać/rozszerzyć zmienną `<DOSTAWCA>_MODELS` (lista po przecinku),
np. `OPENAI_MODELS="gpt-5,gpt-4o"`.
"""
from __future__ import annotations
import os
from app.llm.limits import limits_for
# dostawca -> [(id modelu, krótki opis dla człowieka)]
_CATALOG: dict[str, list[tuple[str, str]]] = {
"anthropic": [
("claude-opus-4-8", "Opus 4.8 — domyślny, bardzo zdolny, 1M kontekstu"),
("claude-fable-5", "Fable 5 — najbardziej zdolny, do najtrudniejszych zadań"),
("claude-sonnet-5", "Sonnet 5 — szybszy i tańszy, jakość blisko Opusa"),
("claude-opus-4-7", "Opus 4.7 — poprzednia generacja Opusa"),
("claude-haiku-4-5", "Haiku 4.5 — najszybszy i najtańszy, mniejsze okno"),
],
"openai": [
("gpt-4o-mini", "GPT-4o mini — tani i szybki"),
("gpt-4o", "GPT-4o"),
("gpt-5", "GPT-5 — jeśli Twoje konto ma dostęp"),
("gpt-4.1", "GPT-4.1"),
("gpt-4.1-mini", "GPT-4.1 mini"),
],
"local": [
("llama3.1:8b", "Llama 3.1 8B"),
("llama3.2", "Llama 3.2"),
("qwen2.5", "Qwen 2.5 — większe okno kontekstu"),
("mistral", "Mistral"),
],
}
def models_for(provider: str) -> list[dict]:
"""Podpowiedzi modeli dla dostawcy, wraz z oknem kontekstu.
Okno kontekstu podajemy, bo wprost przekłada się na opcję „maksymalny
kontekst modelu" — użytkownik widzi, na ile budżetu promptu może liczyć.
"""
override = os.getenv(f"{provider.upper()}_MODELS", "").strip()
if override:
entries = [(m.strip(), "") for m in override.split(",") if m.strip()]
else:
entries = _CATALOG.get(provider, [])
out = []
for model_id, label in entries:
context_window, max_output = limits_for(provider, model_id)
out.append({
"id": model_id,
"label": label or model_id,
"context_window": context_window,
"max_output": max_output,
})
return out
def catalog() -> dict[str, list[dict]]:
"""Pełny katalog dla UI — jedno żądanie zamiast trzech."""
return {provider: models_for(provider) for provider in ("local", "anthropic", "openai")}
+11 -62
View File
@@ -4,26 +4,13 @@ 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.
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:
LLM_PROVIDER local (domyślnie) | openai | anthropic — dostawca domyślny
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)
<DOSTAWCA>_MODEL / _BASE_URL / _API_KEY — konfiguracja konkretnego dostawcy:
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
@@ -40,11 +27,7 @@ PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
_DEFAULT_MODEL = {
LOCAL: "llama3.1:8b",
OPENAI: "gpt-4o-mini",
# Opus 4.8 świadomie zamiast Sonnet 5: Sonnet uruchamia myślenie adaptacyjne,
# gdy pominąć parametr `thinking`, a jego tokeny liczą się do max_tokens —
# przy ciasnym limicie cała tura wychodziła jako samo myślenie z pustym
# tekstem. To była przyczyna pustych odpowiedzi na Anthropicu.
ANTHROPIC: "claude-opus-4-8",
ANTHROPIC: "claude-sonnet-5",
}
_DEFAULT_URL = {
# Ollama i vLLM wystawiają zgodne API pod /v1
@@ -66,54 +49,20 @@ def timeout() -> float:
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 resolve_model(name: str | None = None, model: str | None = None) -> tuple[str, str]:
"""(dostawca, model) BEZ budowania dostawcy — czyli bez wymogu klucza API.
Rozmiar budżetu promptu zależy tylko od okna kontekstu modelu, więc nie może
zależeć od tego, czy klucz jest już skonfigurowany.
"""
provider = (name or default_provider_name()).lower()
if provider not in PROVIDERS:
provider = default_provider_name()
chosen = (model or "").strip() or setting(provider, "MODEL", _DEFAULT_MODEL[provider])
return provider, chosen
def build_provider(name: str | None = None, model: str | None = None) -> LLMProvider:
"""Dostawca modelu. `model` z żądania wygrywa nad konfiguracją — użytkownik
wybiera model w UI, a konfiguracja podaje tylko wartość domyślną."""
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 = (model or "").strip() or setting(name, "MODEL", _DEFAULT_MODEL[name])
base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
api_key = setting(name, "API_KEY")
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 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:
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
-133
View File
@@ -1,133 +0,0 @@
"""Okna kontekstu modeli i planowanie budżetu tokenów (LOG-30/31).
Po co to istnieje: horoskop MA powstać niezależnie od objętości promptu. Żeby to
zagwarantować, trzeba wiedzieć dwie rzeczy o każdym modelu — ile zmieści na
wejściu (okno kontekstu) i ile maksymalnie wypisze na wyjściu. Bez tego łatwo
wysłać prompt, który wypełnia całe okno i **nie zostawia miejsca na odpowiedź** —
model kończy wtedy na `max_tokens` z pustą albo uciętą treścią.
Zasada naczelna: **zawsze rezerwuj miejsce na odpowiedź.** Budżet promptu liczy
się jako `okno_kontekstu zarezerwowane_wyjście margines`, nigdy odwrotnie.
Wartości są zaszyte jako rozsądne domyślne i nadpisywalne środowiskiem
(`<DOSTAWCA>_CONTEXT_WINDOW`, `<DOSTAWCA>_MAX_OUTPUT`) — modele wychodzą szybciej,
niż aktualizuje się ten plik.
"""
from __future__ import annotations
import os
# model -> (okno kontekstu, maksymalne wyjście) w tokenach
_MODEL_LIMITS: dict[str, tuple[int, int]] = {
# Anthropic
"claude-opus-4-8": (1_000_000, 128_000),
"claude-opus-4-7": (1_000_000, 128_000),
"claude-opus-4-6": (1_000_000, 128_000),
"claude-sonnet-5": (1_000_000, 128_000),
"claude-sonnet-4-6": (1_000_000, 128_000),
"claude-fable-5": (1_000_000, 128_000),
"claude-haiku-4-5": (200_000, 64_000),
# OpenAI
"gpt-4o": (128_000, 16_384),
"gpt-4o-mini": (128_000, 16_384),
"gpt-4.1": (1_000_000, 32_768),
"gpt-4.1-mini": (1_000_000, 32_768),
# lokalne (Ollama/vLLM) — zwykle małe okno, dlatego ostrożna domyślna
"llama3.1": (8_192, 4_096),
"llama3.2": (8_192, 4_096),
"qwen2.5": (32_768, 8_192),
"mistral": (32_768, 8_192),
}
# gdy modelu nie ma w tabeli — zachowawczo, żeby nie obiecywać nieistniejącego okna
_FALLBACK: dict[str, tuple[int, int]] = {
"anthropic": (200_000, 32_000),
"openai": (128_000, 16_384),
"local": (8_192, 4_096),
}
# ile tokenów zostawiamy jako bufor na narzut protokołu i niedokładność liczenia
SAFETY_MARGIN = 2_000
# poniżej tylu tokenów wyjścia nie ma sensu wołać modelu — nie zmieści horoskopu
MIN_OUTPUT = 1_500
# powyżej tylu tokenów promptu ostrzegamy użytkownika (nadal pozwalając wysłać)
WARN_PROMPT_TOKENS = 90_000
def _env_int(provider: str, suffix: str) -> int | None:
raw = os.getenv(f"{provider.upper()}_{suffix}")
if not raw:
return None
try:
value = int(raw)
except ValueError:
return None
return value if value > 0 else None
def limits_for(provider: str, model: str) -> tuple[int, int]:
"""(okno kontekstu, maksymalne wyjście) dla modelu — z nadpisaniem z ENV.
Dopasowanie po prefiksie, bo nazwy modeli lokalnych niosą tag (`llama3.1:8b`).
"""
env_ctx = _env_int(provider, "CONTEXT_WINDOW")
env_out = _env_int(provider, "MAX_OUTPUT")
key = (model or "").strip().lower()
known: tuple[int, int] | None = _MODEL_LIMITS.get(key)
if known is None:
for name, pair in _MODEL_LIMITS.items():
if key.startswith(name):
known = pair
break
if known is None:
known = _FALLBACK.get(provider, _FALLBACK["local"])
return (env_ctx or known[0], env_out or known[1])
def plan(provider: str, model: str, prompt_tokens: int,
want_output: int | None = None) -> dict:
"""Ile tokenów wyjścia zamówić dla promptu tej wielkości.
Zwraca plan z jawną diagnostyką — UI ma z czego zbudować ostrzeżenie, a błąd
ma czym wytłumaczyć, dlaczego się nie udało.
"""
context_window, model_max_output = limits_for(provider, model)
room = context_window - prompt_tokens - SAFETY_MARGIN
target = want_output or model_max_output
max_output = max(0, min(model_max_output, target, room))
warnings: list[str] = []
if prompt_tokens > WARN_PROMPT_TOKENS:
warnings.append(
f"Prompt ma ~{prompt_tokens} tokenów — to dużo. Zapytanie zostanie wysłane, "
f"ale potrwa dłużej i będzie odpowiednio kosztowne."
)
if max_output < MIN_OUTPUT:
warnings.append(
f"Po zmieszczeniu promptu zostaje tylko {max_output} tokenów na odpowiedź "
f"(minimum {MIN_OUTPUT}). Zmniejsz budżet promptu albo wybierz model "
f"z większym oknem kontekstu."
)
return {
"provider": provider,
"model": model,
"context_window": context_window,
"model_max_output": model_max_output,
"prompt_tokens": prompt_tokens,
"max_output": max_output,
"fits": max_output >= MIN_OUTPUT,
"warnings": warnings,
}
def prompt_token_budget(provider: str, model: str, reserve_output: int | None = None) -> int:
"""Ile tokenów promptu wolno wysłać, ZAWSZE zostawiając miejsce na odpowiedź.
To jest podstawa opcji „maksymalny kontekst modelu" w UI.
"""
context_window, model_max_output = limits_for(provider, model)
reserve = reserve_output or model_max_output
return max(0, context_window - reserve - SAFETY_MARGIN)
+34 -197
View File
@@ -5,24 +5,9 @@ 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.
**Gwarancja niepustej odpowiedzi.** Horoskop ma powstać niezależnie od objętości
promptu, więc `generate()` nie jest pojedynczym strzałem, tylko pętlą:
1. wyślij turę z policzonym limitem wyjścia,
2. jeśli model urwał na limicie — dopisz turę „kontynuuj" i sklej tekst,
3. jeśli tura nie dała ani znaku tekstu — ponów z podpowiedzią,
4. dopiero brak tekstu po wszystkich próbach jest błędem (z diagnostyką).
Kontynuacja jest pewniejsza niż jedno wielkie żądanie: każda tura mieści się
w timeoucie HTTP, a długość odpowiedzi przestaje być ograniczona jedną turą.
**Anthropic i myślenie.** Modele Claude potrafią mieć włączone myślenie, którego
tokeny liczą się do `max_tokens`. Przy ciasnym limicie cała tura potrafi wyjść
jako same bloki `thinking` z pustym tekstem — dokładnie ten objaw, który
zgłoszono. Traktujemy taką turę jak ucięcie i kontynuujemy, zamiast zwracać pustkę.
"""
from __future__ import annotations
import os
import time
import httpx
@@ -32,23 +17,6 @@ from app.llm.base import Completion, LLMError, LLMProvider
RETRY_STATUSES = {429, 500, 502, 503, 504}
MAX_ATTEMPTS = 3
# ile razy wolno poprosić model o dokończenie urwanej odpowiedzi
MAX_CONTINUATIONS = 12
# ile tokenów zamawiać na jedną turę — mieści się w timeoucie, a pętla i tak
# dociągnie resztę; zbyt duża wartość ryzykuje zerwanie połączenia w trakcie
TURN_TOKENS_CAP = 16_000
_CONTINUE = (
"Kontynuuj dokładnie od miejsca, w którym przerwałeś — nie powtarzaj tego, "
"co już napisałeś, i nie zaczynaj od nowa. Jeśli skończyłeś całą odpowiedź, "
"napisz wyłącznie: KONIEC"
)
_NUDGE = (
"Nie otrzymałem żadnej treści. Napisz odpowiedź zgodnie z powyższym poleceniem, "
"zaczynając od razu od treści horoskopu."
)
_DONE_MARKER = "KONIEC"
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict:
"""POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx."""
@@ -69,8 +37,8 @@ def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) ->
time.sleep(2 ** attempt)
continue
raise LLMError(
f"Model nie odpowiedział w czasie {timeout:.0f}s. Zwiększ LLM_TIMEOUT "
f"albo zmniejsz budżet promptu."
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
@@ -81,108 +49,7 @@ def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) ->
raise LLMError(f"Nie udało się wywołać modelu: {last}")
def _merge_usage(total: dict, turn: dict) -> dict:
"""Sumuje zużycie tokenów przez wszystkie tury jednej odpowiedzi."""
for key, value in (turn or {}).items():
if isinstance(value, int):
total[key] = total.get(key, 0) + value
return total
def _join(parts: list[str]) -> str:
return "".join(parts).strip()
def _explain_empty(turns: int, usage: dict, stop: str | None) -> str:
detail = []
if stop:
detail.append(f"powód zakończenia: {stop}")
for key in ("completion_tokens", "output_tokens"):
if usage.get(key) is not None:
detail.append(f"tokeny odpowiedzi: {usage[key]}")
break
suffix = f" ({', '.join(detail)})" if detail else ""
return (
f"Model nie zwrócił żadnej treści po {turns} próbach{suffix}. "
f"Najczęstsza przyczyna: prompt wypełnił okno kontekstu i nie zostało miejsca "
f"na odpowiedź. Zmniejsz budżet promptu albo wybierz model z większym oknem."
)
class _Driver:
"""Wspólna pętla: tura → ewentualna kontynuacja → sklejony tekst.
Podklasy dostarczają tylko `_turn()` — reszta (kontynuacje, ponawianie pustej
tury, sumowanie zużycia) jest identyczna dla obu protokołów.
"""
name: str
model: str
leaves_lan: bool
def _turn(self, messages: list[dict], max_tokens: int):
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
raise NotImplementedError
def generate(self, prompt: str, max_tokens: int) -> Completion:
messages: list[dict] = [{"role": "user", "content": prompt}]
parts: list[str] = []
usage: dict = {}
model_name = self.model
remaining = max(max_tokens, 256)
stop: str | None = None
turns = 0
nudged = False
while turns <= MAX_CONTINUATIONS:
turns += 1
budget = max(256, min(remaining, TURN_TOKENS_CAP))
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
_merge_usage(usage, turn_usage)
remaining -= budget
chunk = text.strip()
if chunk:
if chunk.endswith(_DONE_MARKER): # model zgłasza koniec
parts.append(("\n" if parts else "") + chunk[: -len(_DONE_MARKER)].rstrip())
break
parts.append(("\n" if parts else "") + chunk)
if not truncated:
break
elif not truncated:
# pusta i NIE ucięta: jedna próba z podpowiedzią, potem koniec
if nudged or parts:
break
nudged = True
messages = messages + [
{"role": "assistant", "content": ""},
{"role": "user", "content": _NUDGE},
]
continue
# ucięta (także tura złożona z samego myślenia) — poproś o dokończenie
if remaining < 256:
break
messages = [
{"role": "user", "content": prompt},
{"role": "assistant", "content": _join(parts) or ""},
{"role": "user", "content": _CONTINUE},
]
final = _join(parts)
if not final:
raise LLMError(_explain_empty(turns, usage, stop))
usage["turns"] = turns
return Completion(text=final, model=model_name, provider=self.name,
leaves_lan=self.leaves_lan, usage=usage)
def count_tokens(self, prompt: str) -> int:
"""Szacunek tokenów promptu. Dostawcy z własnym licznikiem nadpisują."""
return int(len(prompt) / 3.6)
class ChatCompletionsProvider(_Driver, LLMProvider):
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 = "",
@@ -200,20 +67,24 @@ class ChatCompletionsProvider(_Driver, LLMProvider):
h["Authorization"] = f"Bearer {self.api_key}"
return h
def _turn(self, messages: list[dict], max_tokens: int):
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": messages},
{
"model": self.model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
},
self.timeout,
)
try:
choice = data["choices"][0]
text = choice["message"].get("content") or ""
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
stop = choice.get("finish_reason")
return (text, stop == "length", data.get("usage") or {},
data.get("model", self.model), stop)
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}
@@ -226,7 +97,7 @@ class ChatCompletionsProvider(_Driver, LLMProvider):
return info
class AnthropicProvider(_Driver, LLMProvider):
class AnthropicProvider(LLMProvider):
"""Protokół Anthropic `/v1/messages`."""
leaves_lan = True
@@ -239,68 +110,34 @@ class AnthropicProvider(_Driver, LLMProvider):
self.api_key = api_key
self.timeout = timeout
def _headers(self) -> dict:
return {
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",
}
def _thinking(self) -> dict:
"""Konfiguracja myślenia. Domyślnie adaptacyjne — podnosi jakość tekstu.
UWAGA: tokeny myślenia liczą się do `max_tokens`, więc przy ciasnym limicie
cała tura potrafi wyjść jako samo myślenie z pustym tekstem. Pętla
kontynuacji to obsługuje, ale ANTHROPIC_THINKING=off wyłącza myślenie,
gdy zależy nam na przewidywalnym zużyciu tokenów.
"""
mode = os.getenv("ANTHROPIC_THINKING", "adaptive").lower()
if mode in ("off", "disabled", "0", "false"):
return {"thinking": {"type": "disabled"}}
return {
"thinking": {"type": "adaptive"},
"output_config": {"effort": os.getenv("ANTHROPIC_EFFORT", "high")},
}
def _turn(self, messages: list[dict], max_tokens: int):
if not self.api_key:
raise LLMError("Brak ANTHROPIC_API_KEY — dostawca anthropic wymaga klucza.")
payload = {"model": self.model, "max_tokens": max_tokens, "messages": messages}
payload.update(self._thinking())
data = _post_with_retry(f"{self.base_url}/v1/messages", self._headers(),
payload, self.timeout)
},
{
"model": self.model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
},
self.timeout,
)
try:
blocks = data["content"]
text = "".join(b.get("text", "") for b in blocks 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:
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
stop = data.get("stop_reason")
# tura złożona z samego myślenia = budżet poszedł na rozumowanie; traktujemy
# jak ucięcie, żeby pętla poprosiła o treść zamiast zwrócić pustkę
thinking_only = not text.strip() and any(
b.get("type") in ("thinking", "redacted_thinking") for b in blocks
return Completion(
text=text, model=data.get("model", self.model), provider=self.name,
leaves_lan=True, usage=data.get("usage") or {},
)
return (text, stop == "max_tokens" or thinking_only, data.get("usage") or {},
data.get("model", self.model), stop)
def count_tokens(self, prompt: str) -> int:
"""Dokładny licznik Anthropic — nie szacunek. Od tego zależy, czy po
zmieszczeniu promptu zostanie miejsce na odpowiedź."""
if not self.api_key:
return super().count_tokens(prompt)
try:
data = _post_with_retry(
f"{self.base_url}/v1/messages/count_tokens", self._headers(),
{"model": self.model, "messages": [{"role": "user", "content": prompt}]},
min(self.timeout, 30.0),
)
return int(data.get("input_tokens") or super().count_tokens(prompt))
except LLMError:
return super().count_tokens(prompt)
def health(self) -> dict:
return {
"provider": self.name, "model": self.model, "leaves_lan": True,
"status": "ok (klucz ustawiony)" if self.api_key else "brak ANTHROPIC_API_KEY",
"status": "ok (klucz ustawiony)" if self.api_key else "brak LLM_API_KEY",
}
+9 -55
View File
@@ -12,14 +12,12 @@ import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from app import security
from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse
from app.service import QueryService
app = FastAPI(title="astrololo · warstwa logiczna")
service = QueryService()
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
_engine = None
@@ -41,7 +39,6 @@ class PositionsRequest(BaseModel):
objects: list[str] | None = None
house_system: str = "whole_sign" # whole_sign | equal | porphyry
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
@@ -73,10 +70,6 @@ def chart_positions(req: PositionsRequest) -> dict:
st = find_stations(engine, moment, p["name"])
if st:
p["stations"] = st
if req.tables:
from app.engine.tables import build_tables
chart["tables"] = build_tables(engine, moment, chart)
return chart
@@ -134,10 +127,8 @@ class PromptRequest(BaseModel):
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
budget: str = "medium" # concise | medium | extensive | huge | max
budget: str = "medium" # concise | medium | extensive
limit: int = 5000
provider: str | None = None # do wyliczenia budżetu „max" wg okna modelu
model: str | None = None
# tylko dla profilu period:
from_date: str | None = None
to_date: str | None = None
@@ -156,7 +147,7 @@ def chart_prompt(req: PromptRequest) -> dict:
"""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.prompt import CHARS_PER_TOKEN, MAX_BUDGET, build_natal_prompt, build_period_prompt
from app.prompt import build_natal_prompt, build_period_prompt
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
@@ -164,17 +155,6 @@ def chart_prompt(req: PromptRequest) -> dict:
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
data_error = None
# Budżet „maksymalny kontekst modelu": limit znaków liczymy z okna kontekstu
# WYBRANEGO modelu, zawsze po odjęciu miejsca zarezerwowanego na odpowiedź.
budget_chars = None
if req.budget == MAX_BUDGET:
from app.llm.factory import resolve_model
from app.llm.limits import prompt_token_budget
# celowo bez build_provider(): budżet zależy TYLKO od okna kontekstu modelu,
# więc nie może wymagać skonfigurowanego klucza API
provider_name, model_name = resolve_model(req.provider, req.model)
budget_chars = int(prompt_token_budget(provider_name, model_name) * CHARS_PER_TOKEN)
# Warstwa danych dokłada wyłącznie WSKAZANIA. Wyliczenia (horoskop, oś czasu) są
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
try:
@@ -189,7 +169,7 @@ def chart_prompt(req: PromptRequest) -> dict:
)
except httpx.HTTPError as e:
data_error = f"Warstwa danych niedostępna: {e}"
out = build_natal_prompt(chart, report, req.budget, label, budget_chars)
out = build_natal_prompt(chart, report, req.budget, label)
elif req.profile == "period":
if not (req.from_date and req.to_date):
@@ -209,7 +189,7 @@ def chart_prompt(req: PromptRequest) -> dict:
except httpx.HTTPError as e:
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
out = build_period_prompt(chart, events, req.from_date, req.to_date,
req.budget, label, budget_chars)
req.budget, label)
else:
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
except ValueError as e: # nieznany budżet
@@ -222,7 +202,8 @@ def chart_prompt(req: PromptRequest) -> dict:
class HoroscopeRequest(PromptRequest):
"""Jak PromptRequest (niesie już provider i model) + limit wyjścia (LOG-31)."""
"""Jak PromptRequest + wybór dostawcy modelu (LOG-31)."""
provider: str | None = None # local (dom.) | openai | anthropic
max_tokens: int | None = None
@@ -235,26 +216,12 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
"""
from app.llm.base import LLMError
from app.llm.factory import build_provider
from app.llm.limits import plan
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, req.model)
# Ile tokenów ma naprawdę ten prompt i ile zostaje na odpowiedź. Anthropic
# liczy dokładnie (własny endpoint), reszta szacuje — od tego zależy, czy
# w oknie kontekstu w ogóle zmieści się miejsce na horoskop.
prompt_tokens = provider.count_tokens(out["prompt"])
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
out["token_plan"] = budget
if budget["warnings"]:
out["warnings"] = budget["warnings"]
if not budget["fits"]:
out["llm_error"] = " ".join(budget["warnings"])
return out
result = provider.generate(out["prompt"], budget["max_output"])
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)
@@ -270,19 +237,6 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
return out
@app.get("/llm/models")
def llm_models() -> dict:
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
To nie jest lista zamknięta: pole modelu jest tekstowe, więc można wpisać
dowolny identyfikator, do którego konto ma dostęp.
"""
from app.llm.catalog import catalog
from app.llm.factory import _DEFAULT_MODEL
return {"providers": catalog(), "defaults": dict(_DEFAULT_MODEL)}
@app.get("/llm/health")
def llm_health(provider: str | None = None) -> dict:
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
+5 -18
View File
@@ -28,14 +28,8 @@ BUDGETS: dict[str, int] = {
"concise": 4_000,
"medium": 12_000,
"extensive": 30_000,
"huge": 120_000,
# „maksymalny kontekst modelu" — wyliczany dynamicznie z okna kontekstu
# wybranego modelu, ZAWSZE po odjęciu miejsca zarezerwowanego na odpowiedź.
# Wartość poniżej jest tylko zapasem, gdy limity modelu są nieznane.
"max": 400_000,
}
DEFAULT_BUDGET = "medium"
MAX_BUDGET = "max"
MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji)
CHARS_PER_TOKEN = 4.0 # zgrubny szacunek tokenów do podglądu w UI
@@ -290,16 +284,11 @@ def _assemble(task: str, chart_sec: str, ind_sec: str, output: str) -> str:
return "\n\n".join([task, chart_sec, ind_sec, output, f"# ZASTRZEŻENIE\n{DISCLAIMER}"])
def _budget_chars(budget: str, budget_chars: int | None = None) -> int:
"""Limit znaków promptu. `budget_chars` nadpisuje tabelę — używane dla opcji
maksymalny kontekst modelu", gdzie limit zależy od wybranego modelu i musi
być policzony po odjęciu miejsca zarezerwowanego na odpowiedź."""
def _budget_chars(budget: str) -> int:
if budget not in BUDGETS:
raise ValueError(
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
)
if budget_chars and budget_chars > 0:
return budget_chars
return BUDGETS[budget]
@@ -316,10 +305,9 @@ def _finish(prompt: str, budget: str, limit: int, stats: dict, profile: str) ->
def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
moment_label: str | None = None,
budget_chars: int | None = None) -> dict:
moment_label: str | None = None) -> dict:
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
limit = _budget_chars(budget, budget_chars)
limit = _budget_chars(budget)
chart_sec = _chart_section(chart, moment_label)
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
@@ -328,10 +316,9 @@ def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
def build_period_prompt(chart: dict, events: list[dict], from_date: str, to_date: str,
budget: str = DEFAULT_BUDGET, moment_label: str | None = None,
budget_chars: int | None = None) -> dict:
budget: str = DEFAULT_BUDGET, moment_label: str | None = None) -> dict:
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
limit = _budget_chars(budget, budget_chars)
limit = _budget_chars(budget)
chart_sec = _chart_section(chart, moment_label)
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date}{to_date}**."
-50
View File
@@ -1,50 +0,0 @@
"""Uwierzytelnianie międzywarstwowe (LOG-32).
Warstwa logiczna oddaje treść baz interpretacyjnych, więc samo zalogowanie w
prezentacji nie wystarczy bez tego kontrolera wystarczyłoby uderzyć w logikę
z pominięciem UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
musi go przynieść w nagłówku X-Astrololo-Token.
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) wtedy przy
starcie leci ostrzeżenie.
"""
from __future__ import annotations
import logging
import os
import secrets
from fastapi import Request
from fastapi.responses import JSONResponse
log = logging.getLogger("astrololo.security")
HEADER = "X-Astrololo-Token"
PUBLIC_PATHS = frozenset({"/health"})
def token() -> str:
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
return os.getenv("INTERNAL_TOKEN", "")
def enabled() -> bool:
return bool(token())
def install(app, layer: str) -> None:
if not enabled():
log.warning(
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or not enabled():
return await call_next(request)
got = request.headers.get(HEADER, "")
if not secrets.compare_digest(got, token()):
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
status_code=401)
return await call_next(request)
+1 -23
View File
@@ -1,5 +1,5 @@
"""Testy aspektów (LOG-06) — czysta matematyka."""
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
from app.engine.aspects import find_aspects, separation
def test_separation_wraparound():
@@ -55,28 +55,6 @@ def test_separating_when_moving_apart():
assert a["applying"] is False and a["as"] == "S"
def test_rigid_pair_nodes_not_reported():
# SN = NN + 180° z definicji — trywialna opozycja, nie aspekt
pos = [
{"name": "North Node", "decimal": 42.0, "speed": -0.053},
{"name": "South Node", "decimal": 222.0, "speed": -0.053},
{"name": "Sun", "decimal": 42.5, "speed": 0.96},
]
pairs = {frozenset((a["obj1"], a["obj2"])) for a in find_aspects(pos)}
assert frozenset({"North Node", "South Node"}) not in pairs
# aspekty węzłów do innych obiektów zostają nietknięte
assert frozenset({"Sun", "North Node"}) in pairs
assert frozenset({"Sun", "South Node"}) in pairs
def test_no_rigid_pairs_in_real_chart(own_engine, reference_moment):
from app.engine.chart import build_chart
chart = build_chart(own_engine, reference_moment)
found = [a for a in chart["aspects"] if frozenset((a["obj1"], a["obj2"])) in RIGID_PAIRS]
assert not found, f"trywialne aspekty par sztywnych w horoskopie: {found}"
def test_no_as_flag_without_speeds():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert "as" not in find_aspects(pos)[0]
-91
View File
@@ -1,91 +0,0 @@
"""Okna kontekstu i planowanie budżetu tokenów (LOG-30/31).
Naczelna zasada, której pilnują te testy: **zawsze zostaje miejsce na odpowiedź**.
Prompt nigdy nie może wypełnić całego okna kontekstu, bo wtedy model kończy na
`max_tokens` z pustą albo uciętą treścią to był zgłoszony błąd.
"""
import pytest
from app.llm import limits
def test_known_models_have_documented_limits():
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
assert ctx == 1_000_000 and out == 128_000
ctx, out = limits.limits_for("anthropic", "claude-haiku-4-5")
assert ctx == 200_000 and out == 64_000
def test_local_model_tag_is_matched_by_prefix():
"""Modele lokalne niosą tag (`llama3.1:8b`) — dopasowanie musi to znieść."""
assert limits.limits_for("local", "llama3.1:8b") == limits.limits_for("local", "llama3.1")
def test_unknown_model_falls_back_conservatively():
ctx, out = limits.limits_for("local", "jakis-egzotyczny-model")
assert ctx == 8_192 and out == 4_096
def test_env_overrides_win(monkeypatch):
"""Modele wychodzą szybciej, niż aktualizuje się tabela."""
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "131072")
monkeypatch.setenv("LOCAL_MAX_OUTPUT", "8192")
assert limits.limits_for("local", "llama3.1:8b") == (131_072, 8_192)
def test_env_override_ignores_garbage(monkeypatch):
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "nie-liczba")
assert limits.limits_for("local", "llama3.1:8b")[0] == 8_192
# ------------------------------------------------- rezerwa miejsca na odpowiedź
def test_prompt_budget_always_reserves_room_for_answer():
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
budget = limits.prompt_token_budget("anthropic", "claude-opus-4-8")
assert budget + out + limits.SAFETY_MARGIN <= ctx
assert budget > 0
def test_small_context_model_still_leaves_room():
budget = limits.prompt_token_budget("local", "llama3.1:8b")
ctx, out = limits.limits_for("local", "llama3.1:8b")
assert budget + out + limits.SAFETY_MARGIN <= ctx
def test_plan_shrinks_output_when_prompt_is_huge():
"""Duży prompt nie może dostać pełnego okna wyjścia — musi się zmieścić."""
ctx, model_out = limits.limits_for("local", "llama3.1:8b")
p = limits.plan("local", "llama3.1:8b", prompt_tokens=6_000)
assert p["max_output"] <= ctx - 6_000 - limits.SAFETY_MARGIN
assert p["max_output"] < model_out
def test_plan_reports_not_fitting_instead_of_failing_silently():
p = limits.plan("local", "llama3.1:8b", prompt_tokens=8_000)
assert p["fits"] is False
assert any("odpowied" in w for w in p["warnings"])
def test_plan_warns_above_90k_but_still_fits():
"""Powyżej 90 tys. tokenów ostrzegamy — ale wysłanie MA być nadal możliwe."""
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=120_000)
assert p["fits"] is True, "duży prompt nadal musi dać się wysłać"
assert p["max_output"] >= limits.MIN_OUTPUT
assert any("dużo" in w for w in p["warnings"])
def test_no_warning_below_threshold():
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=10_000)
assert p["warnings"] == [] and p["fits"] is True
def test_requested_output_is_capped_by_model_maximum():
p = limits.plan("anthropic", "claude-haiku-4-5", prompt_tokens=1_000, want_output=999_999)
assert p["max_output"] == 64_000
@pytest.mark.parametrize("tokens", [0, 1_000, 50_000, 200_000, 900_000])
def test_plan_never_returns_negative_output(tokens):
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=tokens)
assert p["max_output"] >= 0
+2 -260
View File
@@ -3,8 +3,6 @@
Transport podstawiamy przez httpx.MockTransport, więc testy szybkie,
deterministyczne i nic nie wychodzi na zewnątrz.
"""
import json
import httpx
import pytest
@@ -117,7 +115,7 @@ def test_anthropic_generates(monkeypatch):
def test_anthropic_requires_key():
with pytest.raises(LLMError, match="ANTHROPIC_API_KEY"):
with pytest.raises(LLMError, match="LLM_API_KEY"):
AnthropicProvider("https://api.anthropic.com", "m", "").generate("p", 10)
@@ -135,9 +133,7 @@ def test_default_provider_is_local(monkeypatch):
def test_openai_requires_key(monkeypatch):
monkeypatch.delenv("LLM_API_KEY", raising=False)
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"):
with pytest.raises(LLMError, match="LLM_API_KEY"):
factory.build_provider("openai")
@@ -151,257 +147,3 @@ def test_env_overrides_model_and_url(monkeypatch):
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"
# ---------------------------------------- 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="nie zwrócił żadnej treści"):
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 "powód zakończenia: length" 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="nie zwrócił żadnej treści"):
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="nie zwrócił żadnej treści"):
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."
# ------------------------------------- kontynuacja: horoskop MA powstac zawsze
# Sedno wymagania: niezaleznie od objetosci promptu i limitu wyjscia, pelna tresc
# ma wrocic do uzytkownika. Model urwany na max_tokens jest proszony o dokonczenie
# w ramach tej samej rozmowy, a kawalki sa sklejane.
def _scripted(responses):
"""Transport oddajacy kolejne odpowiedzi z listy (po jednej na ture)."""
seq = list(responses)
seen = []
def handler(request):
seen.append(request)
return httpx.Response(200, json=seq.pop(0) if seq else seq_last)
seq_last = responses[-1]
return handler, seen
def _chat(text, finish):
return {"choices": [{"message": {"content": text}, "finish_reason": finish}],
"usage": {"completion_tokens": 10}}
def test_truncated_answer_is_continued_and_joined(monkeypatch):
handler, seen = _scripted([
_chat("Czesc pierwsza.", "length"),
_chat("Czesc druga. KONIEC", "stop"),
])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
assert "Czesc pierwsza." in out.text and "Czesc druga." in out.text
assert "KONIEC" not in out.text # znacznik nie trafia do horoskopu
assert out.usage["turns"] == 2
def test_continuation_asks_in_same_conversation(monkeypatch):
"""Kontynuacja musi isc jako kolejna tura rozmowy, a ostatnia wiadomosc MUSI
byc od uzytkownika Claude odrzuca prefill w turze asystenta (400)."""
handler, seen = _scripted([_chat("Poczatek", "length"), _chat("Reszta", "stop")])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
msgs = json.loads(seen[1].content)["messages"]
assert msgs[-1]["role"] == "user", "ostatnia wiadomosc nie moze byc prefillem asystenta"
assert msgs[1]["role"] == "assistant" and "Poczatek" in msgs[1]["content"]
def test_anthropic_thinking_only_turn_is_continued(monkeypatch):
"""DOKLADNIE zgloszony objaw: cala tura poszla na myslenie, tekst pusty.
Wczesniej konczylo sie to pusta strona; teraz pytamy o tresc dalej."""
seq = [
{"content": [{"type": "thinking", "thinking": ""}], "stop_reason": "max_tokens",
"usage": {"output_tokens": 2000}},
{"content": [{"type": "text", "text": "Horoskop urodzeniowy..."}],
"stop_reason": "end_turn", "usage": {"output_tokens": 500}},
]
def handler(request):
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 40000)
assert out.text == "Horoskop urodzeniowy..."
assert out.usage["turns"] == 2
def test_anthropic_sends_thinking_config(monkeypatch):
"""Bez jawnego `thinking` Sonnet 5 wlacza myslenie sam — konfigurujemy to wprost."""
seen = []
def handler(request):
seen.append(json.loads(request.content))
return httpx.Response(200, json={"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn"})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
monkeypatch.delenv("ANTHROPIC_THINKING", raising=False)
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
assert seen[0]["thinking"] == {"type": "adaptive"}
seen.clear()
monkeypatch.setenv("ANTHROPIC_THINKING", "off")
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
assert seen[0]["thinking"] == {"type": "disabled"}
def test_complete_answer_does_not_loop(monkeypatch):
"""Straznik nie moze mnozyc zapytan, gdy model skonczyl normalnie."""
calls = {"n": 0}
def handler(request):
calls["n"] += 1
return httpx.Response(200, json=_chat("Gotowe.", "stop"))
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 40000)
assert out.text == "Gotowe." and calls["n"] == 1
# ------------------------------------------- wybor modelu przez uzytkownika (UI)
def test_model_from_request_wins_over_config(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
p = factory.build_provider("anthropic", "claude-fable-5")
assert p.model == "claude-fable-5", "wybor z UI musi wygrac nad konfiguracja"
def test_blank_model_falls_back_to_configured_default(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-sonnet-5")
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
assert factory.build_provider("anthropic", " ").model == "claude-sonnet-5"
def test_resolve_model_needs_no_api_key(monkeypatch):
"""Budzet promptu zalezy od okna kontekstu modelu — nie moze wymagac klucza.
Wczesniej liczenie budzetu szlo przez build_provider(), ktory bez klucza
rzuca bledem, wiec maksymalny kontekst" cicho spadal do wartosci zapasowej.
"""
_clear(monkeypatch)
provider, model = factory.resolve_model("anthropic", "claude-haiku-4-5")
assert (provider, model) == ("anthropic", "claude-haiku-4-5")
with pytest.raises(LLMError): # samo zbudowanie nadal wymaga klucza
factory.build_provider("anthropic", "claude-haiku-4-5")
def test_max_budget_differs_between_models(monkeypatch):
"""Sedno funkcji: wieksze okno = wiekszy budzet promptu."""
from app.llm.limits import prompt_token_budget
_clear(monkeypatch)
opus = prompt_token_budget(*factory.resolve_model("anthropic", "claude-opus-4-8"))
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
assert opus > haiku > local > 0
-65
View File
@@ -1,65 +0,0 @@
"""Token międzywarstwowy (LOG-32).
Warstwa logiczna oddaje treść baz, więc musi odrzucać żądania z pominięciem UI.
"""
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
from app import security
TOKEN = "tajny-token-testowy"
def _app() -> FastAPI:
app = FastAPI()
security.install(app, "testowa")
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/secret")
def secret():
return {"rows": ["treść z bazy"]}
return app
@pytest.fixture
def guarded(monkeypatch):
monkeypatch.setenv("INTERNAL_TOKEN", TOKEN)
return TestClient(_app())
@pytest.fixture
def open_app(monkeypatch):
monkeypatch.delenv("INTERNAL_TOKEN", raising=False)
return TestClient(_app())
def test_rejects_request_without_token(guarded):
assert guarded.get("/secret").status_code == 401
def test_rejects_wrong_token(guarded):
r = guarded.get("/secret", headers={security.HEADER: "zly"})
assert r.status_code == 401
assert "treść z bazy" not in r.text
def test_accepts_correct_token(guarded):
r = guarded.get("/secret", headers={security.HEADER: TOKEN})
assert r.status_code == 200
assert r.json()["rows"] == ["treść z bazy"]
def test_health_stays_public(guarded):
"""Sonda k8s nie zna tokenu — /health musi działać bez niego."""
assert guarded.get("/health").status_code == 200
def test_disabled_when_token_unset(open_app):
"""Brak konfiguracji = zgodność wstecz (dev), nie blokada."""
assert not security.enabled()
assert open_app.get("/secret").status_code == 200
-216
View File
@@ -1,216 +0,0 @@
"""Tabele pomocnicze horoskopu (LOG-23).
Wartości referencyjne dla horoskopu 30.04.1984 09:20 UTC, Kraków (50.0647N, 19.9450E)
sprawdzone wobec faktów NIEZALEŻNYCH od naszego kodu:
* 30.04.1984 to poniedziałek władca dnia Księżyc; 5. godzina poniedziałku
w porządku chaldejskim to Słońce (Mo, Sa, Ju, Ma, Su),
* wschód/zachód dla Krakowa końcem kwietnia 5:18 / 19:57 czasu lokalnego
(CEST = UTC+2), czyli 03:18 / 17:57 UTC,
* pełnia poprzedzająca urodzenie: 15.04.1984 ok. 19:11 UTC.
"""
from datetime import datetime, timezone
import pytest
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.engine.tables import (
build_tables,
critical_degrees,
dwadasamsa,
element_of,
moon_phase,
navamsa,
planetary_hours,
prenatal_syzygy,
quality_of,
tally,
)
@pytest.fixture(scope="module")
def krakow():
return ChartMoment(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
lat=50.0647, lon=19.9450)
@pytest.fixture(scope="module")
def tables(own_engine, krakow):
return build_tables(own_engine, krakow, build_chart(own_engine, krakow))
# ------------------------------------------------------ żywioły i jakości
def test_element_and_quality_mapping():
assert element_of("Aries") == "Fire" and element_of("Cancer") == "Water"
assert quality_of("Aries") == "Cardinal" and quality_of("Taurus") == "Fixed"
assert quality_of("Gemini") == "Mutable"
def test_tally_matches_hand_count(tables):
"""Ręcznie przeliczone dla horoskopu referencyjnego (10 planet + Asc)."""
base = tables["tally"]["with_modern_10_plus_asc"]
assert base["elements"] == {"Fire": 4, "Earth": 4, "Air": 0, "Water": 3}
assert base["qualities"] == {"Cardinal": 4, "Fixed": 6, "Mutable": 1}
assert base["total"] == 11
def test_missing_element_detected(tables):
"""Klasyczne „no air" — podstawa pod scoring siły (LOG-21)."""
assert tables["tally"]["missing_elements"] == ["Air"]
assert tables["tally"]["missing_qualities"] == []
def test_tally_variants_differ_by_object_count(tables):
t = tables["tally"]
assert t["classical_7"]["total"] == 7
assert t["with_modern_10"]["total"] == 10
assert t["classical_7_plus_asc"]["total"] == 8
assert t["with_modern_10_plus_asc"]["total"] == 11
def test_tally_sums_equal_counted_objects(tables):
for variant in ("classical_7", "with_modern_10", "with_modern_10_plus_asc"):
v = tables["tally"][variant]
assert sum(v["elements"].values()) == v["total"]
assert sum(v["qualities"].values()) == v["total"]
# ---------------------------------------------------------- faza Księżyca
def test_moon_phase_reference_is_balsamic_new(tables):
"""Nów wypadł 1.05.1984, więc 30.04 Księżyc jest tuż przed nowiem."""
mp = tables["moon_phase"]
assert mp["phase"] == "New Moon"
assert 340.0 < mp["angle"] < 360.0
assert mp["illumination"] < 0.05
assert mp["waxing"] is False # elongacja > 180 = ubywa
@pytest.mark.parametrize("angle,expected", [
(0.0, "New Moon"), (90.0, "First Quarter"), (180.0, "Full Moon"),
(270.0, "Last Quarter"), (46.0, "Waxing Crescent"), (300.0, "Waning Crescent"),
])
def test_moon_phase_buckets(angle, expected):
assert moon_phase(0.0, angle)["phase"] == expected
def test_moon_phase_illumination_extremes():
assert moon_phase(0.0, 0.0)["illumination"] == 0.0
assert moon_phase(0.0, 180.0)["illumination"] == 1.0
assert moon_phase(0.0, 90.0)["illumination"] == pytest.approx(0.5)
# ------------------------------------------------------- stopnie krytyczne
def test_critical_degrees_reference(tables):
found = {c["name"]: c["flags"] for c in tables["critical_degrees"]}
assert "Jupiter" in found # Cap 12°57' -> 13° kardynalny
assert any("13" in f for f in found["Jupiter"])
assert "Pluto" in found # Sco 0°28' -> wejście w znak
def test_anaretic_degree_flagged():
flags = critical_degrees([{"name": "X", "sign": "Leo", "decimal": 149.5,
"in_sign": "Leo 29°30'"}])
assert flags and any("anaretyczny" in f for f in flags[0]["flags"])
def test_no_flags_for_ordinary_degree():
assert critical_degrees([{"name": "X", "sign": "Leo", "decimal": 135.0,
"in_sign": "Leo 15°"}]) == []
# --------------------------------------------------------------- podziały
def test_dwadasamsa_starts_from_own_sign():
"""12. część liczy się OD znaku, w którym stoi punkt."""
assert dwadasamsa(0.0) == pytest.approx(0.0) # Ari 0 -> Ari
assert dwadasamsa(2.5) == pytest.approx(30.0) # Ari 2°30' -> Tau 0
assert dwadasamsa(30.0) == pytest.approx(30.0) # Tau 0 -> Tau
def test_navamsa_classic_starts():
"""Znaki kardynalne zaczynają od siebie, stałe od 9. znaku."""
assert navamsa(0.0) == pytest.approx(0.0) # Ari -> Ari
assert navamsa(30.0) == pytest.approx(270.0) # Tau -> Cap (9. od Byka)
assert navamsa(60.0) == pytest.approx(180.0) # Gem -> Lib
def test_divisional_covers_all_positions(tables, own_engine, krakow):
chart = build_chart(own_engine, krakow)
assert len(tables["divisional"]) == len(chart["positions"])
# ------------------------------------------------- dzień i godziny planetarne
def test_planetary_day_ruler_is_moon_on_monday(tables):
"""30.04.1984 to poniedziałek → władcą dnia jest Księżyc."""
assert tables["planetary_hours"]["day_ruler"] == "Moon"
def test_planetary_hour_matches_chaldean_sequence(tables):
"""Poniedziałek: 1=Mo, 2=Sa, 3=Ju, 4=Ma, 5=Su — urodzenie w 5. godzinie dnia."""
ph = tables["planetary_hours"]
assert ph["hour_number"] == 5
assert ph["hour_ruler"] == "Sun"
assert ph["daytime"] is True
def test_sunrise_sunset_match_krakow_late_april(tables):
"""Wschód ≈ 03:18 UTC, zachód ≈ 17:57 UTC (5:18 i 19:57 czasu lokalnego)."""
ph = tables["planetary_hours"]
assert ph["period_start"].startswith("1984-04-30T03:1")
assert ph["period_end"].startswith("1984-04-30T17:5")
def test_planetary_hours_are_unequal_and_complete(tables):
"""Godziny są nierówne: wiosną dzienna trwa dłużej niż 60 minut."""
ph = tables["planetary_hours"]
assert ph["hour_length_minutes"] > 60.0
assert len(ph["hours"]) == 12
assert sum(1 for h in ph["hours"] if h["current"]) == 1
def test_polar_night_returns_none(own_engine):
"""Za kołem podbiegunowym w grudniu Słońce nie wschodzi — brak godzin."""
polar = ChartMoment(when_utc=datetime(2024, 12, 21, 12, 0, tzinfo=timezone.utc),
lat=78.0, lon=15.0)
assert planetary_hours(own_engine, polar) is None
# ------------------------------------------------------- syzygia prenatalna
def test_prenatal_syzygy_is_april_1984_full_moon(tables):
"""Rzeczywista pełnia: 15.04.1984 ok. 19:11 UTC."""
s = tables["prenatal_syzygy"]
assert s["type"] == "full_moon"
assert s["when_utc"].startswith("1984-04-15T19:1")
def test_prenatal_syzygy_precedes_birth_within_a_cycle(tables):
days = tables["prenatal_syzygy"]["days_before_birth"]
assert 0 < days < 29.6, "syzygia musi być w ostatnim cyklu przed urodzeniem"
def test_prenatal_syzygy_elongation_is_at_target(own_engine, krakow):
"""W znalezionym momencie elongacja MUSI wynosić 0° albo 180°."""
from app.engine.formats import norm360
s = prenatal_syzygy(own_engine, krakow)
when = datetime.fromisoformat(s["when_utc"])
pts = {p.name: p.longitude for p in
own_engine.positions(ChartMoment(when_utc=when, lat=krakow.lat, lon=krakow.lon),
["Sun", "Moon"])}
elong = norm360(pts["Moon"] - pts["Sun"])
target = 0.0 if s["type"] == "new_moon" else 180.0
assert abs(((elong - target + 180.0) % 360.0) - 180.0) < 0.02
# ------------------------------------------------------------------ całość
def test_build_tables_light_skips_numeric_search(own_engine, krakow):
"""heavy=False pomija to, co wymaga szukania numerycznego."""
light = build_tables(own_engine, krakow, build_chart(own_engine, krakow), heavy=False)
assert "tally" in light and "moon_phase" in light
assert "planetary_hours" not in light and "prenatal_syzygy" not in light
@@ -5,7 +5,6 @@ opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
@@ -13,12 +12,6 @@ import httpx
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
class LogicClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/")
@@ -26,7 +19,7 @@ class LogicClient:
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.post(f"{self.base_url}/api/query", json=payload, headers=_auth_headers())
r = client.post(f"{self.base_url}/api/query", json=payload)
r.raise_for_status()
return r.json()
@@ -39,7 +32,6 @@ class LogicClient:
house_system: str = "whole_sign",
stations: bool = False,
zodiac: str = "tropical",
tables: bool = False,
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
@@ -50,11 +42,10 @@ class LogicClient:
"house_system": house_system,
"stations": stations,
"zodiac": zodiac,
"tables": tables,
}
# stacje wymagają root-findów — dłuższy timeout
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout) as client:
r = client.post(f"{self.base_url}/chart/positions", json=payload, headers=_auth_headers())
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
r = client.post(f"{self.base_url}/chart/positions", json=payload)
r.raise_for_status()
return r.json()
@@ -64,34 +55,29 @@ class LogicClient:
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/chart/report", json=payload, headers=_auth_headers())
r = client.post(f"{self.base_url}/chart/report", json=payload)
r.raise_for_status()
return r.json()
def prompt(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
provider: str | None = None, model: str | None = None,
) -> dict[str, Any]:
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
`provider` jest potrzebny dla budżetu maksymalny kontekst modelu": limit
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt."""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso,
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
"model": model,
"lat": lat, "lon": lon, "budget": budget,
}
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
r = client.post(f"{self.base_url}/chart/prompt", json=payload, headers=_auth_headers())
r = client.post(f"{self.base_url}/chart/prompt", json=payload)
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, model: str | None = None,
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.
@@ -104,19 +90,10 @@ class LogicClient:
}
if provider:
payload["provider"] = provider
if model:
payload["model"] = model
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, headers=_auth_headers())
r.raise_for_status()
return r.json()
def llm_models(self) -> dict[str, Any]:
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/llm/models", headers=_auth_headers())
r = client.post(f"{self.base_url}/chart/horoscope", json=payload)
r.raise_for_status()
return r.json()
@@ -130,6 +107,6 @@ class LogicClient:
"from_date": from_date, "to_date": to_date, "interpret": interpret,
}
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
r = client.post(f"{self.base_url}/chart/timeline", json=payload, headers=_auth_headers())
r = client.post(f"{self.base_url}/chart/timeline", json=payload)
r.raise_for_status()
return r.json()
+12 -32
View File
@@ -17,7 +17,7 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app import geocode, security
from app import geocode
from app.clients.logic_client import LogicClient
from app.config import DEFAULT_LOCATION_LABEL, default_form
@@ -25,7 +25,6 @@ app = FastAPI(title="astrololo · warstwa prezentacji")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
@@ -39,15 +38,6 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
return utc.isoformat(), label
def _llm_catalog() -> dict:
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
try:
return logic.llm_models()
except httpx.HTTPError:
return {"providers": {}, "defaults": {}}
def _logic_error(e: Exception) -> str:
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
return (
@@ -62,8 +52,7 @@ def _logic_error(e: Exception) -> str:
def chart_form(request: Request):
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@@ -78,18 +67,17 @@ def chart_compute(
house_system: str = Form("whole_sign"),
stations: bool = Form(False),
zodiac: str = Form("tropical"),
tables: bool = Form(False),
):
form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
"zodiac": zodiac, "tables": tables}
"zodiac": zodiac}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
house_system=house_system, stations=stations, zodiac=zodiac,
)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
@@ -126,8 +114,7 @@ def significators_search(
def interpret_form(request: Request):
return templates.TemplateResponse(
request, "interpret.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@@ -143,25 +130,22 @@ def interpret_run(
action: str = Form("report"),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
):
form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
"llm_provider": llm_provider, "llm_model": llm_model}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
"llm_catalog": _llm_catalog()}
"llm_provider": llm_provider}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
if action == "prompt":
ctx["prompt_result"] = logic.prompt(
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
provider=llm_provider, model=llm_model,
)
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, model=llm_model,
budget=prompt_budget, provider=llm_provider,
)
else:
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
@@ -177,8 +161,7 @@ def interpret_run(
def timeline_form(request: Request):
return templates.TemplateResponse(
request, "timeline.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@@ -195,13 +178,11 @@ def timeline_run(
action: str = Form("timeline"),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
):
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,
"llm_provider": llm_provider, "llm_model": llm_model}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
"llm_catalog": _llm_catalog()}
"llm_provider": llm_provider}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
@@ -209,12 +190,11 @@ def timeline_run(
ctx["prompt_result"] = logic.prompt(
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, from_date=from_date, to_date=to_date,
provider=llm_provider, model=llm_model,
)
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, model=llm_model,
budget=prompt_budget, provider=llm_provider,
from_date=from_date, to_date=to_date,
)
else:
-120
View File
@@ -1,120 +0,0 @@
"""Ochrona dostępu do aplikacji (LOG-32).
Rdzeniem produktu oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
uwierzytelnienia oznacza, że każdy w sieci może je wypompować bez udziału
jakiegokolwiek modelu językowego. Ten moduł zamyka drogę.
Dwa mechanizmy:
* **HTTP Basic** wejście do aplikacji; włącza się, gdy ustawiono APP_PASSWORD.
* **limit żądań** hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań).
Świadomie NIE logujemy treści żądań ani promptów logi to kolejny nośnik wycieku.
UWAGA: bez APP_PASSWORD ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev).
Wtedy przy starcie leci głośne ostrzeżenie żeby nikt nie wdrożył tego w
przekonaniu, że jest chroniony.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import secrets
import time
from collections import deque
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse
log = logging.getLogger("astrololo.security")
MAX_TRACKED_CLIENTS_DEFAULT = 4096
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
# konfiguracja czytana leniwie — testy i restart mogą ją zmienić bez importu modułu
def app_user() -> str:
return os.getenv("APP_USER", "astrololo")
def app_password() -> str:
return os.getenv("APP_PASSWORD", "")
def rate_limit_per_min() -> int:
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
PUBLIC_PATHS = frozenset({"/health"})
PUBLIC_PREFIXES = ("/static/",)
_hits: dict[str, deque[float]] = {}
def auth_enabled() -> bool:
return bool(app_password())
def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
def _authorized(header: str | None) -> bool:
if not header or not header.lower().startswith("basic "):
return False
try:
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
user, _, password = raw.partition(":")
except (binascii.Error, UnicodeDecodeError, IndexError):
return False
# porównanie odporne na atak czasowy; oba pola muszą się zgadzać
ok_user = secrets.compare_digest(user, app_user())
ok_pass = secrets.compare_digest(password, app_password())
return ok_user and ok_pass
def _rate_limited(client: str) -> bool:
cap = rate_limit_per_min()
if cap <= 0:
return False
now = time.monotonic()
window = _hits.get(client)
if window is None:
if len(_hits) >= MAX_TRACKED_CLIENTS:
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
window = _hits[client] = deque()
while window and now - window[0] > 60.0:
window.popleft()
if len(window) >= cap:
return True
window.append(now)
return False
def install(app) -> None:
"""Podpina ochronę pod wszystkie ścieżki poza /health i /static."""
if not auth_enabled():
log.warning(
"UWAGA: APP_PASSWORD nie ustawione — aplikacja jest OTWARTA dla każdego, "
"kto ma do niej dostęp sieciowy, wraz z treścią baz interpretacyjnych."
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if _is_public(request.url.path):
return await call_next(request)
client = request.client.host if request.client else "?"
if _rate_limited(client):
return JSONResponse(
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
status_code=429, headers={"Retry-After": "60"},
)
if auth_enabled() and not _authorized(request.headers.get("authorization")):
return HTMLResponse(
"<h1>401 — wymagane logowanie</h1>", status_code=401,
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
)
return await call_next(request)
@@ -1,58 +0,0 @@
// Podpowiedzi modeli zależne od wybranego dostawcy.
//
// Pole modelu jest CELOWO tekstowe (input + datalist), a nie zamkniętym <select>:
// katalog to tylko wygoda, a konto może mieć dostęp do modeli, o których kod nie
// wie. Puste pole = model domyślny dostawcy.
document.addEventListener('DOMContentLoaded', function () {
const providerEl = document.getElementById('llmProvider');
const modelEl = document.getElementById('llmModel');
const listEl = document.getElementById('llmModelList');
const hintEl = document.getElementById('llmModelHint');
const dataEl = document.getElementById('llmCatalog');
if (!providerEl || !modelEl || !listEl || !dataEl) return;
let catalog = {};
let defaults = {};
try {
const parsed = JSON.parse(dataEl.textContent || '{}');
catalog = parsed.providers || {};
defaults = parsed.defaults || {};
} catch (e) {
return; // brak katalogu — pole nadal działa jako wolny tekst
}
const fmt = n =>
n >= 1000000 ? (n / 1000000) + 'M' : (n >= 1000 ? Math.round(n / 1000) + 'k' : String(n));
function refresh(resetValue) {
const provider = providerEl.value;
const models = catalog[provider] || [];
listEl.innerHTML = '';
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.label = m.label + ' · kontekst ' + fmt(m.context_window);
listEl.appendChild(opt);
});
const fallback = defaults[provider] || '';
modelEl.placeholder = fallback ? 'domyślny: ' + fallback : 'domyślny dostawcy';
// po zmianie dostawcy stary model nie ma sensu (np. gpt-4o u Anthropica)
if (resetValue) modelEl.value = '';
if (hintEl) {
const chosen = models.find(m => m.id === modelEl.value.trim());
hintEl.textContent = chosen
? chosen.label + ' — okno kontekstu ' + fmt(chosen.context_window) +
' tokenów, maksymalna odpowiedź ' + fmt(chosen.max_output) + '.'
: 'Zostaw puste, by użyć modelu domyślnego. Możesz też wpisać dowolny ' +
'identyfikator modelu, do którego Twoje konto ma dostęp.';
}
}
providerEl.addEventListener('change', () => refresh(true));
modelEl.addEventListener('input', () => refresh(false));
refresh(false);
});
@@ -1,6 +1,3 @@
{# Katalog modeli wstrzykiwany przez handler — pole jest tekstowe, więc
można wpisać dowolny model, do którego konto ma dostęp. #}
<script type="application/json" id="llmCatalog">{{ llm_catalog | tojson }}</script>
{# Wspólny blok: sterowanie generowaniem + prompt (LOG-29/30) + horoskop (LOG-31).
Używany na ekranach Interpretacje i Kalendarz. #}
<div class="opts">
@@ -10,26 +7,17 @@
<option value="concise" {{ 'selected' if pb == 'concise' else '' }}>zwięzły (~4 tys. znaków)</option>
<option value="medium" {{ 'selected' if pb == 'medium' else '' }}>średni (~12 tys.)</option>
<option value="extensive" {{ 'selected' if pb == 'extensive' else '' }}>obszerny (~30 tys.)</option>
<option value="huge" {{ 'selected' if pb == 'huge' else '' }}>bardzo obszerny (~120 tys.)</option>
<option value="max" {{ 'selected' if pb == 'max' else '' }}>maksymalny kontekst modelu</option>
</select>
</label>
<label>Dostawca
<select name="llm_provider" id="llmProvider">
<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>
<label>Model
<input type="text" name="llm_model" id="llmModel" list="llmModelList"
value="{{ form.llm_model or '' }}" placeholder="domyślny dostawcy"
autocomplete="off">
<datalist id="llmModelList"></datalist>
</label>
</div>
<p class="muted small" id="llmModelHint"></p>
<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ą
@@ -63,23 +51,6 @@
</p>
{% endif %}
{% if prompt_result.warnings %}
{% for w in prompt_result.warnings %}
<p class="muted small"><strong>Uwaga:</strong> {{ w }}</p>
{% endfor %}
{% endif %}
{% if prompt_result.token_plan %}
{% set tp = prompt_result.token_plan %}
<p class="muted small">
Tokeny: prompt {{ tp.prompt_tokens }} · okno modelu {{ tp.context_window }} ·
zarezerwowane na odpowiedź {{ tp.max_output }}
{% if prompt_result.usage and prompt_result.usage.turns and prompt_result.usage.turns > 1 %}
· odpowiedź złożona z {{ prompt_result.usage.turns }} tur (model dokańczał urwany tekst)
{% endif %}
</p>
{% endif %}
{% if prompt_result.llm_error %}
<div class="error">
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
@@ -47,8 +47,6 @@
<div class="opts">
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
licz stacje planet (wolniejsze)</label>
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
</div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
<div class="actions">
@@ -144,87 +142,6 @@
</table>
{% endif %}
{% if result.tables %}
{% set tb = result.tables %}
<div class="meta">Tabele dodatkowe (LOG-23)</div>
{% set bal = tb.tally.with_modern_10_plus_asc %}
<table class="angles">
<thead><tr><th>Bilans (10 planet + Asc)</th><th colspan="4">Rozkład</th></tr></thead>
<tbody>
<tr><td>Żywioły</td>
{% for e, n in bal.elements.items() %}
<td>{{ tb.tally.labels.elements[e] }}: <strong>{{ n }}</strong></td>
{% endfor %}
</tr>
<tr><td>Jakości</td>
{% for q, n in bal.qualities.items() %}
<td>{{ tb.tally.labels.qualities[q] }}: <strong>{{ n }}</strong></td>
{% endfor %}
<td></td>
</tr>
</tbody>
</table>
{% if tb.tally.missing_elements or tb.tally.missing_qualities %}
<p class="muted small">Brak:
{% for e in tb.tally.missing_elements %}<strong>{{ tb.tally.labels.elements[e] }}</strong>{{ ", " if not loop.last }}{% endfor %}
{% for q in tb.tally.missing_qualities %}<strong>{{ tb.tally.labels.qualities[q] }}</strong>{{ ", " if not loop.last }}{% endfor %}
</p>
{% endif %}
{% if tb.moon_phase %}
<p class="muted small">
<strong>Faza Księżyca:</strong> {{ tb.moon_phase.phase_pl }} ·
elongacja {{ '%.2f'|format(tb.moon_phase.angle) }}° ·
oświetlenie {{ '%.1f'|format(tb.moon_phase.illumination * 100) }}% ·
{{ 'przybywa' if tb.moon_phase.waxing else 'ubywa' }}
</p>
{% endif %}
{% if tb.planetary_hours %}
{% set ph = tb.planetary_hours %}
<p class="muted small">
<strong>Godziny planetarne:</strong> władca dnia {{ ph.day_ruler }} ·
{{ ph.hour_number }}. godzina ({{ ph.period }}), władca {{ ph.hour_ruler }} ·
godzina trwa {{ ph.hour_length_minutes }} min
</p>
{% endif %}
{% if tb.prenatal_syzygy %}
{% set s = tb.prenatal_syzygy %}
<p class="muted small">
<strong>Syzygia prenatalna:</strong> {{ s.type_pl }} {{ s.when_utc }}
({{ s.days_before_birth }} dni przed) w {{ s.in_sign }}
</p>
{% endif %}
{% if tb.critical_degrees %}
<div class="meta">Stopnie krytyczne</div>
<table class="angles">
<thead><tr><th>Obiekt</th><th>Pozycja</th><th>Uwaga</th></tr></thead>
<tbody>
{% for c in tb.critical_degrees %}
<tr><td>{{ c.name }}</td><td class="mono">{{ c.in_sign }}</td>
<td class="muted small">{{ c.flags | join('; ') }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<details class="loc">
<summary>Podziały: dwunastniki (D12) i nawamsa (D9)</summary>
<table>
<thead><tr><th>Obiekt</th><th>D12</th><th>D9</th></tr></thead>
<tbody>
{% for d in tb.divisional %}
<tr><td>{{ d.name }}</td><td class="mono">{{ d.d12_in_sign }}</td>
<td class="mono">{{ d.d9_in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% if result.cusps %}
<details class="loc">
<summary>Cusps domów ({{ result.house_system }})</summary>
@@ -90,5 +90,4 @@
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
<script src="/static/models.js"></script>
{% endblock %}
@@ -80,5 +80,4 @@
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
<script src="/static/models.js"></script>
{% endblock %}
@@ -1,2 +0,0 @@
-r requirements.txt
pytest>=8.0
@@ -1,111 +0,0 @@
"""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)
# --------------------------------------------- wybor modelu musi dojsc do logiki
# Ta sama klasa bledu co przy tokenie: dokladajac nowa sciezke latwo zapomniec
# przekazac parametr, a objaw (cichy powrot do modelu domyslnego) jest niewidoczny.
def _calls_to(path_fragment: str) -> list[int]:
"""Linie wywolan logic.<metoda>(...) w handlerach prezentacji."""
main = CLIENT.parent.parent / "main.py"
tree = ast.parse(main.read_text(encoding="utf-8"))
out = []
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr == path_fragment
and isinstance(node.func.value, ast.Name) and node.func.value.id == "logic"):
out.append(node.lineno)
return out
def _has_kwarg(main_src: str, lineno: int, name: str) -> bool:
tree = ast.parse(main_src)
for node in ast.walk(tree):
if isinstance(node, ast.Call) and node.lineno == lineno:
return any(kw.arg == name for kw in node.keywords)
return False
def test_every_llm_call_passes_selected_model():
main = CLIENT.parent.parent / "main.py"
src = main.read_text(encoding="utf-8")
missing = []
for method in ("prompt", "horoscope"):
for line in _calls_to(method):
if not _has_kwarg(src, line, "model"):
missing.append(f"main.py:{line} logic.{method}()")
assert not missing, (
"Wywołania bez wybranego modelu — po cichu użyją domyślnego: " + ", ".join(missing)
)
def test_every_llm_call_passes_provider():
main = CLIENT.parent.parent / "main.py"
src = main.read_text(encoding="utf-8")
missing = []
for method in ("prompt", "horoscope"):
for line in _calls_to(method):
if not _has_kwarg(src, line, "provider"):
missing.append(f"main.py:{line} logic.{method}()")
assert not missing, "Wywołania bez dostawcy: " + ", ".join(missing)
@@ -1,133 +0,0 @@
"""Logowanie do aplikacji i limit żądań (LOG-32).
To jest brama chroniąca oryginalne bazy interpretacyjne bez niej każdy w sieci
mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują,
że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą.
"""
import base64
import pytest
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from starlette.testclient import TestClient
from app import security
USER, PASSWORD = "astrololo", "haslo-testowe"
def _basic(user: str, password: str) -> dict[str, str]:
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {raw}"}
def _app() -> FastAPI:
app = FastAPI()
security.install(app)
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/significators", response_class=HTMLResponse)
def significators():
return "<p>treść z bazy interpretacyjnej</p>"
return app
@pytest.fixture(autouse=True)
def _reset_rate_limit():
security._hits.clear()
yield
security._hits.clear()
@pytest.fixture
def guarded(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120")
return TestClient(_app())
# ------------------------------------------------------------------ logowanie
def test_blocks_anonymous_access(guarded):
r = guarded.get("/significators")
assert r.status_code == 401
assert "treść z bazy" not in r.text
def test_challenges_with_basic_realm(guarded):
"""Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania."""
assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "")
def test_rejects_wrong_password(guarded):
assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401
def test_rejects_wrong_user(guarded):
assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401
def test_rejects_malformed_header(guarded):
for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"):
assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401
def test_allows_correct_credentials(guarded):
r = guarded.get("/significators", headers=_basic(USER, PASSWORD))
assert r.status_code == 200
assert "treść z bazy" in r.text
def test_health_stays_public(guarded):
assert guarded.get("/health").status_code == 200
def test_open_when_password_unset(monkeypatch):
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie)."""
monkeypatch.delenv("APP_PASSWORD", raising=False)
assert not security.auth_enabled()
assert TestClient(_app()).get("/significators").status_code == 200
# --------------------------------------------------------------- limit żądań
def test_rate_limit_blocks_flood(monkeypatch):
"""Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5")
client = TestClient(_app())
auth = _basic(USER, PASSWORD)
codes = [client.get("/significators", headers=auth).status_code for _ in range(8)]
assert codes[:5] == [200] * 5
assert 429 in codes[5:]
def test_rate_limited_response_has_retry_after(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1")
client = TestClient(_app())
auth = _basic(USER, PASSWORD)
client.get("/significators", headers=auth)
r = client.get("/significators", headers=auth)
assert r.status_code == 429 and r.headers.get("Retry-After") == "60"
def test_rate_limit_precedes_auth(monkeypatch):
"""Limit musi działać także dla niezalogowanych — inaczej zgadywanie hasła
i sondowanie API jest darmowe."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
client = TestClient(_app())
codes = [client.get("/significators").status_code for _ in range(6)]
assert 429 in codes
def test_rate_limit_disabled_when_zero(monkeypatch):
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
monkeypatch.delenv("APP_PASSWORD", raising=False)
client = TestClient(_app())
assert all(client.get("/significators").status_code == 200 for _ in range(30))