Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8b2321dd7 | |||
| 80c008f076 |
+15
-61
@@ -51,26 +51,6 @@ jobs:
|
|||||||
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
|
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
|
||||||
run: pytest tests -q -rs
|
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:
|
swisseph-image:
|
||||||
name: Build obrazu silnika B (swisseph)
|
name: Build obrazu silnika B (swisseph)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -82,51 +62,25 @@ jobs:
|
|||||||
- name: docker build
|
- name: docker build
|
||||||
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
|
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
|
||||||
|
|
||||||
# Test biegnie WEWNĄTRZ obrazu, bez sieci i bez kontenera w tle. Poprzednia
|
- name: Smoke test (health + pozycje)
|
||||||
# wersja startowała kontener w tle (--name swe) i pukała curl-em w
|
|
||||||
# localhost:8003 — co miało dwie wady:
|
|
||||||
# 1. nie sprzątała kontenera, więc każdy kolejny przebieg padał na
|
|
||||||
# konflikcie nazwy (Conflict. The container name "/swe" is already in use),
|
|
||||||
# 2. job Gitea Actions sam działa w kontenerze, a -p publikuje port na
|
|
||||||
# HOŚCIE — więc localhost joba to nie ten sam localhost.
|
|
||||||
# Wywołanie funkcji endpointów wprost omija oba problemy, a sprawdza to samo:
|
|
||||||
# obraz się zbudował, pyswisseph liczy, kontrakt /positions się zgadza.
|
|
||||||
# --rm gwarantuje, że nic nie zostaje po przebiegu.
|
|
||||||
- name: Smoke test (health + pozycje) wewnątrz obrazu
|
|
||||||
run: |
|
run: |
|
||||||
docker run --rm astrololo/engine-swisseph:ci python - <<'PY'
|
docker run -d --name swe -p 8003:8003 astrololo/engine-swisseph:ci
|
||||||
from datetime import datetime, timezone
|
for i in $(seq 1 30); do
|
||||||
|
curl -fsS http://localhost:8003/health >/dev/null 2>&1 && break
|
||||||
from app.main import DEFAULT_OBJECTS, PositionsRequest, health, positions
|
sleep 1
|
||||||
|
done
|
||||||
h = health()
|
curl -fsS http://localhost:8003/health
|
||||||
assert h["status"] == "ok", h
|
echo
|
||||||
print("health:", h)
|
|
||||||
|
|
||||||
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
|
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
|
||||||
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
|
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
|
||||||
req = PositionsRequest(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
curl -fsS -X POST http://localhost:8003/positions \
|
||||||
lat=50.0647, lon=19.9450)
|
-H 'Content-Type: application/json' \
|
||||||
out = positions(req)
|
-d '{"when_utc":"1984-04-30T09:20:00Z","lat":50.0647,"lon":19.9450}'
|
||||||
by = {p["name"]: p for p in out["positions"]}
|
echo
|
||||||
|
|
||||||
assert out["engine"] == "swisseph", out["engine"]
|
- name: Logi kontenera (gdy coś padło)
|
||||||
assert len(by) == len(DEFAULT_OBJECTS), sorted(by)
|
if: failure()
|
||||||
sun = by["Sun"]["longitude"]
|
run: docker logs swe || true
|
||||||
assert 39.5 < sun < 41.0, f"Slonce poza oczekiwanym zakresem: {sun}"
|
|
||||||
nn, sn = by["North Node"]["longitude"], by["South Node"]["longitude"]
|
|
||||||
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
|
|
||||||
|
|
||||||
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
|
|
||||||
print("SMOKE OK")
|
|
||||||
PY
|
|
||||||
|
|
||||||
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
|
|
||||||
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
|
|
||||||
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
|
|
||||||
- name: Usuń osierocony kontener ze starych przebiegów
|
|
||||||
if: always()
|
|
||||||
run: docker rm -f swe 2>/dev/null || true
|
|
||||||
|
|
||||||
compile-all:
|
compile-all:
|
||||||
name: Kontrola składni wszystkich warstw
|
name: Kontrola składni wszystkich warstw
|
||||||
|
|||||||
Binary file not shown.
@@ -10,7 +10,6 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from app import security
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||||
from app.providers.factory import build_provider
|
from app.providers.factory import build_provider
|
||||||
@@ -25,7 +24,6 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
||||||
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/search", response_model=SearchResult)
|
@app.post("/search", response_model=SearchResult)
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ class SearchQuery(BaseModel):
|
|||||||
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
||||||
value: str = Field(..., description="Szukana wartość.")
|
value: str = Field(..., description="Szukana wartość.")
|
||||||
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
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
|
limit: int = Field(50, ge=1, le=50000)
|
||||||
# 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)
|
|
||||||
fields: list[str] | None = Field(
|
fields: list[str] | None = Field(
|
||||||
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -5,7 +5,6 @@ Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -13,12 +12,6 @@ import httpx
|
|||||||
from app.config import settings
|
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:
|
class DataClient:
|
||||||
def __init__(self, base_url: str | None = None) -> None:
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||||
@@ -33,12 +26,12 @@ class DataClient:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
|
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def health(self) -> dict[str, Any]:
|
def health(self) -> dict[str, Any]:
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -27,18 +27,6 @@ LUMINARIES = {"Sun", "Moon"}
|
|||||||
DEFAULT_ORB = 8.0
|
DEFAULT_ORB = 8.0
|
||||||
LUMINARY_BONUS = 2.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:
|
def separation(a: float, b: float) -> float:
|
||||||
"""Najmniejsza separacja kątowa [0,180]."""
|
"""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
|
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).
|
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] = []
|
out: list[dict] = []
|
||||||
n = len(positions)
|
n = len(positions)
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
for j in range(i + 1, n):
|
for j in range(i + 1, n):
|
||||||
a, b = positions[i], positions[j]
|
a, b = positions[i], positions[j]
|
||||||
if _is_rigid(a["name"], b["name"]):
|
|
||||||
continue
|
|
||||||
la, lb = a.get("decimal"), b.get("decimal")
|
la, lb = a.get("decimal"), b.get("decimal")
|
||||||
if la is None or lb is None:
|
if la is None or lb is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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")}
|
|
||||||
@@ -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 —
|
domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
|
||||||
przez konfigurację albo pojedyncze żądanie.
|
przez konfigurację albo pojedyncze żądanie.
|
||||||
|
|
||||||
Konfiguracja jest **per dostawca**, bo UI pozwala przełączać go przy każdym żądaniu.
|
|
||||||
Wspólne `LLM_*` nie wystarczy: ustawienie `LLM_BASE_URL` na lokalny model kierowałoby
|
|
||||||
tam także żądania do OpenAI, a `LLM_MODEL=llama3.1:8b` kazałoby Anthropic użyć modelu
|
|
||||||
llama. Dlatego każdy dostawca ma własny komplet zmiennych.
|
|
||||||
|
|
||||||
Zmienne środowiskowe:
|
Zmienne środowiskowe:
|
||||||
LLM_PROVIDER local (domyślnie) | openai | anthropic — 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_TIMEOUT sekundy (domyślnie 120)
|
||||||
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -40,11 +27,7 @@ PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
|
|||||||
_DEFAULT_MODEL = {
|
_DEFAULT_MODEL = {
|
||||||
LOCAL: "llama3.1:8b",
|
LOCAL: "llama3.1:8b",
|
||||||
OPENAI: "gpt-4o-mini",
|
OPENAI: "gpt-4o-mini",
|
||||||
# Opus 4.8 świadomie zamiast Sonnet 5: Sonnet uruchamia myślenie adaptacyjne,
|
ANTHROPIC: "claude-sonnet-5",
|
||||||
# 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",
|
|
||||||
}
|
}
|
||||||
_DEFAULT_URL = {
|
_DEFAULT_URL = {
|
||||||
# Ollama i vLLM wystawiają zgodne API pod /v1
|
# Ollama i vLLM wystawiają zgodne API pod /v1
|
||||||
@@ -66,54 +49,20 @@ def timeout() -> float:
|
|||||||
return float(os.getenv("LLM_TIMEOUT", "120"))
|
return float(os.getenv("LLM_TIMEOUT", "120"))
|
||||||
|
|
||||||
|
|
||||||
def setting(provider: str, suffix: str, fallback: str = "") -> str:
|
def build_provider(name: str | None = None) -> LLMProvider:
|
||||||
"""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ą."""
|
|
||||||
name = (name or default_provider_name()).lower()
|
name = (name or default_provider_name()).lower()
|
||||||
if name not in PROVIDERS:
|
if name not in PROVIDERS:
|
||||||
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
|
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
|
||||||
|
|
||||||
model = (model or "").strip() or setting(name, "MODEL", _DEFAULT_MODEL[name])
|
model = os.getenv("LLM_MODEL") or _DEFAULT_MODEL[name]
|
||||||
base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
|
base_url = os.getenv("LLM_BASE_URL") or _DEFAULT_URL[name]
|
||||||
api_key = setting(name, "API_KEY")
|
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:
|
if name == ANTHROPIC:
|
||||||
return AnthropicProvider(base_url, model, api_key, timeout())
|
return AnthropicProvider(base_url, model, api_key, timeout())
|
||||||
if name == OPENAI:
|
if name == OPENAI:
|
||||||
|
if not api_key:
|
||||||
|
raise LLMError("Brak LLM_API_KEY — dostawca openai wymaga klucza.")
|
||||||
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
|
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
|
||||||
leaves_lan=True)
|
leaves_lan=True)
|
||||||
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
|
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
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
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -32,23 +17,6 @@ from app.llm.base import Completion, LLMError, LLMProvider
|
|||||||
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||||
MAX_ATTEMPTS = 3
|
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:
|
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict:
|
||||||
"""POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx."""
|
"""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)
|
time.sleep(2 ** attempt)
|
||||||
continue
|
continue
|
||||||
raise LLMError(
|
raise LLMError(
|
||||||
f"Model nie odpowiedział w czasie {timeout:.0f}s. Zwiększ LLM_TIMEOUT "
|
f"Model nie odpowiedział w czasie {timeout:.0f}s. Dłuższe horoskopy "
|
||||||
f"albo zmniejsz budżet promptu."
|
f"wymagają większego LLM_TIMEOUT albo mniejszego budżetu promptu."
|
||||||
) from e
|
) from e
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
last = 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}")
|
raise LLMError(f"Nie udało się wywołać modelu: {last}")
|
||||||
|
|
||||||
|
|
||||||
def _merge_usage(total: dict, turn: dict) -> dict:
|
class ChatCompletionsProvider(LLMProvider):
|
||||||
"""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):
|
|
||||||
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
|
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
|
||||||
|
|
||||||
def __init__(self, name: str, base_url: str, model: str, api_key: str = "",
|
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}"
|
h["Authorization"] = f"Bearer {self.api_key}"
|
||||||
return h
|
return h
|
||||||
|
|
||||||
def _turn(self, messages: list[dict], max_tokens: int):
|
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||||
data = _post_with_retry(
|
data = _post_with_retry(
|
||||||
f"{self.base_url}/chat/completions", self._headers(),
|
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,
|
self.timeout,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
choice = data["choices"][0]
|
text = data["choices"][0]["message"]["content"]
|
||||||
text = choice["message"].get("content") or ""
|
|
||||||
except (KeyError, IndexError, TypeError) as e:
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||||
stop = choice.get("finish_reason")
|
return Completion(
|
||||||
return (text, stop == "length", data.get("usage") or {},
|
text=text, model=data.get("model", self.model), provider=self.name,
|
||||||
data.get("model", self.model), stop)
|
leaves_lan=self.leaves_lan, usage=data.get("usage") or {},
|
||||||
|
)
|
||||||
|
|
||||||
def health(self) -> dict:
|
def health(self) -> dict:
|
||||||
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
|
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
|
||||||
@@ -226,7 +97,7 @@ class ChatCompletionsProvider(_Driver, LLMProvider):
|
|||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
class AnthropicProvider(_Driver, LLMProvider):
|
class AnthropicProvider(LLMProvider):
|
||||||
"""Protokół Anthropic `/v1/messages`."""
|
"""Protokół Anthropic `/v1/messages`."""
|
||||||
|
|
||||||
leaves_lan = True
|
leaves_lan = True
|
||||||
@@ -239,68 +110,34 @@ class AnthropicProvider(_Driver, LLMProvider):
|
|||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
def _headers(self) -> dict:
|
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||||
return {
|
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",
|
"Content-Type": "application/json",
|
||||||
"x-api-key": self.api_key,
|
"x-api-key": self.api_key,
|
||||||
"anthropic-version": "2023-06-01",
|
"anthropic-version": "2023-06-01",
|
||||||
}
|
},
|
||||||
|
{
|
||||||
def _thinking(self) -> dict:
|
"model": self.model,
|
||||||
"""Konfiguracja myślenia. Domyślnie adaptacyjne — podnosi jakość tekstu.
|
"max_tokens": max_tokens,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
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
|
self.timeout,
|
||||||
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)
|
|
||||||
try:
|
try:
|
||||||
blocks = data["content"]
|
text = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text")
|
||||||
text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
|
|
||||||
except (KeyError, TypeError) as e:
|
except (KeyError, TypeError) as e:
|
||||||
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||||
|
return Completion(
|
||||||
stop = data.get("stop_reason")
|
text=text, model=data.get("model", self.model), provider=self.name,
|
||||||
# tura złożona z samego myślenia = budżet poszedł na rozumowanie; traktujemy
|
leaves_lan=True, usage=data.get("usage") or {},
|
||||||
# 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 (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:
|
def health(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"provider": self.name, "model": self.model, "leaves_lan": True,
|
"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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,12 @@ import httpx
|
|||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app import security
|
|
||||||
from app.clients.data_client import DataClient
|
from app.clients.data_client import DataClient
|
||||||
from app.models import QueryRequest, QueryResponse
|
from app.models import QueryRequest, QueryResponse
|
||||||
from app.service import QueryService
|
from app.service import QueryService
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||||
service = QueryService()
|
service = QueryService()
|
||||||
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
|
||||||
|
|
||||||
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
|
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
|
||||||
_engine = None
|
_engine = None
|
||||||
@@ -41,7 +39,6 @@ class PositionsRequest(BaseModel):
|
|||||||
objects: list[str] | None = None
|
objects: list[str] | None = None
|
||||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||||
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
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
|
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"])
|
st = find_stations(engine, moment, p["name"])
|
||||||
if st:
|
if st:
|
||||||
p["stations"] = st
|
p["stations"] = st
|
||||||
if req.tables:
|
|
||||||
from app.engine.tables import build_tables
|
|
||||||
|
|
||||||
chart["tables"] = build_tables(engine, moment, chart)
|
|
||||||
return chart
|
return chart
|
||||||
|
|
||||||
|
|
||||||
@@ -134,10 +127,8 @@ class PromptRequest(BaseModel):
|
|||||||
when_utc: datetime
|
when_utc: datetime
|
||||||
lat: float = 0.0
|
lat: float = 0.0
|
||||||
lon: float = 0.0
|
lon: float = 0.0
|
||||||
budget: str = "medium" # concise | medium | extensive | huge | max
|
budget: str = "medium" # concise | medium | extensive
|
||||||
limit: int = 5000
|
limit: int = 5000
|
||||||
provider: str | None = None # do wyliczenia budżetu „max" wg okna modelu
|
|
||||||
model: str | None = None
|
|
||||||
# tylko dla profilu period:
|
# tylko dla profilu period:
|
||||||
from_date: str | None = None
|
from_date: str | None = None
|
||||||
to_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.chart import build_chart
|
||||||
from app.engine.models import ChartMoment
|
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()
|
engine = get_engine()
|
||||||
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
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")
|
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
|
||||||
data_error = None
|
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ą
|
# 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.
|
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
|
||||||
try:
|
try:
|
||||||
@@ -189,7 +169,7 @@ def chart_prompt(req: PromptRequest) -> dict:
|
|||||||
)
|
)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
data_error = f"Warstwa danych niedostępna: {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":
|
elif req.profile == "period":
|
||||||
if not (req.from_date and req.to_date):
|
if not (req.from_date and req.to_date):
|
||||||
@@ -209,7 +189,7 @@ def chart_prompt(req: PromptRequest) -> dict:
|
|||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
|
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
|
||||||
out = build_period_prompt(chart, events, req.from_date, req.to_date,
|
out = build_period_prompt(chart, events, req.from_date, req.to_date,
|
||||||
req.budget, label, budget_chars)
|
req.budget, label)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
|
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
|
||||||
except ValueError as e: # nieznany budżet
|
except ValueError as e: # nieznany budżet
|
||||||
@@ -222,7 +202,8 @@ def chart_prompt(req: PromptRequest) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
class HoroscopeRequest(PromptRequest):
|
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
|
max_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -235,26 +216,12 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
|
|||||||
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
|
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
|
||||||
"""
|
"""
|
||||||
from app.llm.base import LLMError
|
from app.llm.base import LLMError
|
||||||
from app.llm.factory import build_provider
|
from app.llm.factory import build_provider, max_tokens
|
||||||
from app.llm.limits import plan
|
|
||||||
|
|
||||||
out = chart_prompt(req) # ten sam prompt co w podglądzie
|
out = chart_prompt(req) # ten sam prompt co w podglądzie
|
||||||
try:
|
try:
|
||||||
provider = build_provider(req.provider, req.model)
|
provider = build_provider(req.provider)
|
||||||
|
result = provider.generate(out["prompt"], req.max_tokens or max_tokens())
|
||||||
# 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"])
|
|
||||||
except LLMError as e:
|
except LLMError as e:
|
||||||
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
|
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
|
||||||
out["llm_error"] = str(e)
|
out["llm_error"] = str(e)
|
||||||
@@ -270,19 +237,6 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
|
|||||||
return out
|
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")
|
@app.get("/llm/health")
|
||||||
def llm_health(provider: str | None = None) -> dict:
|
def llm_health(provider: str | None = None) -> dict:
|
||||||
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
|
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
|
||||||
|
|||||||
@@ -28,14 +28,8 @@ BUDGETS: dict[str, int] = {
|
|||||||
"concise": 4_000,
|
"concise": 4_000,
|
||||||
"medium": 12_000,
|
"medium": 12_000,
|
||||||
"extensive": 30_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"
|
DEFAULT_BUDGET = "medium"
|
||||||
MAX_BUDGET = "max"
|
|
||||||
|
|
||||||
MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji)
|
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
|
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}"])
|
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:
|
def _budget_chars(budget: str) -> 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ź."""
|
|
||||||
if budget not in BUDGETS:
|
if budget not in BUDGETS:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
|
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
|
||||||
)
|
)
|
||||||
if budget_chars and budget_chars > 0:
|
|
||||||
return budget_chars
|
|
||||||
return BUDGETS[budget]
|
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,
|
def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
|
||||||
moment_label: str | None = None,
|
moment_label: str | None = None) -> dict:
|
||||||
budget_chars: int | None = None) -> dict:
|
|
||||||
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
|
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
|
||||||
limit = _budget_chars(budget, budget_chars)
|
limit = _budget_chars(budget)
|
||||||
chart_sec = _chart_section(chart, moment_label)
|
chart_sec = _chart_section(chart, moment_label)
|
||||||
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
|
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
|
||||||
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
|
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,
|
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: str = DEFAULT_BUDGET, moment_label: str | None = None) -> dict:
|
||||||
budget_chars: int | None = None) -> dict:
|
|
||||||
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
|
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
|
||||||
limit = _budget_chars(budget, budget_chars)
|
limit = _budget_chars(budget)
|
||||||
chart_sec = _chart_section(chart, moment_label)
|
chart_sec = _chart_section(chart, moment_label)
|
||||||
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date} — {to_date}**."
|
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date} — {to_date}**."
|
||||||
|
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
"""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():
|
def test_separation_wraparound():
|
||||||
@@ -55,28 +55,6 @@ def test_separating_when_moving_apart():
|
|||||||
assert a["applying"] is False and a["as"] == "S"
|
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():
|
def test_no_as_flag_without_speeds():
|
||||||
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||||
assert "as" not in find_aspects(pos)[0]
|
assert "as" not in find_aspects(pos)[0]
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -3,8 +3,6 @@
|
|||||||
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
|
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
|
||||||
deterministyczne i nic nie wychodzi na zewnątrz.
|
deterministyczne i nic nie wychodzi na zewnątrz.
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -117,7 +115,7 @@ def test_anthropic_generates(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_anthropic_requires_key():
|
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)
|
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):
|
def test_openai_requires_key(monkeypatch):
|
||||||
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
with pytest.raises(LLMError, match="LLM_API_KEY"):
|
||||||
# komunikat wskazuje ZMIENNĄ DO USTAWIENIA dla tego dostawcy, nie ogólne LLM_API_KEY
|
|
||||||
with pytest.raises(LLMError, match="OPENAI_API_KEY"):
|
|
||||||
factory.build_provider("openai")
|
factory.build_provider("openai")
|
||||||
|
|
||||||
|
|
||||||
@@ -151,257 +147,3 @@ def test_env_overrides_model_and_url(monkeypatch):
|
|||||||
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
|
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
|
||||||
p = factory.build_provider("local")
|
p = factory.build_provider("local")
|
||||||
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
|
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------- konfiguracja per dostawca (regresja LOG-31)
|
|
||||||
# UI pozwala przelaczac dostawce przy kazdym zadaniu, wiec ustawienia JEDNEGO nie moga
|
|
||||||
# przeciekac na pozostalych. Wczesniej wspolne LLM_BASE_URL/LLM_MODEL kierowaly zadania
|
|
||||||
# do OpenAI na adres lokalnej Ollamy i prosily Anthropic o model llama.
|
|
||||||
|
|
||||||
def _clear(monkeypatch):
|
|
||||||
for v in ("LLM_PROVIDER", "LLM_MODEL", "LLM_BASE_URL", "LLM_API_KEY",
|
|
||||||
"LOCAL_MODEL", "LOCAL_BASE_URL", "LOCAL_API_KEY",
|
|
||||||
"OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY",
|
|
||||||
"ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
|
|
||||||
monkeypatch.delenv(v, raising=False)
|
|
||||||
|
|
||||||
|
|
||||||
def test_local_config_does_not_leak_to_cloud(monkeypatch):
|
|
||||||
"""Sedno bledu: skonfigurowany model lokalny przejmowal zadania do chmury."""
|
|
||||||
_clear(monkeypatch)
|
|
||||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
|
||||||
monkeypatch.setenv("LLM_BASE_URL", "http://ollama:11434/v1") # konfiguracja lokalnego
|
|
||||||
monkeypatch.setenv("LLM_MODEL", "llama3.1:8b")
|
|
||||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
||||||
|
|
||||||
local = factory.build_provider("local")
|
|
||||||
assert local.base_url == "http://ollama:11434/v1" and local.model == "llama3.1:8b"
|
|
||||||
|
|
||||||
openai = factory.build_provider("openai")
|
|
||||||
assert openai.base_url == "https://api.openai.com/v1", "zadanie do OpenAI poszloby do Ollamy"
|
|
||||||
assert openai.model == "gpt-4o-mini", "OpenAI dostalby nazwe modelu llama"
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_specific_settings_win(monkeypatch):
|
|
||||||
_clear(monkeypatch)
|
|
||||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
|
||||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
|
|
||||||
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
|
|
||||||
p = factory.build_provider("anthropic")
|
|
||||||
assert p.model == "claude-opus-4-8" and p.api_key == "sk-ant"
|
|
||||||
|
|
||||||
|
|
||||||
def test_generic_vars_apply_only_to_default_provider(monkeypatch):
|
|
||||||
"""Zgodnosc wstecz: wspolne LLM_* konfiguruja dostawce domyslnego i tylko jego."""
|
|
||||||
_clear(monkeypatch)
|
|
||||||
monkeypatch.setenv("LLM_PROVIDER", "openai")
|
|
||||||
monkeypatch.setenv("LLM_API_KEY", "sk-generic")
|
|
||||||
monkeypatch.setenv("LLM_MODEL", "gpt-4o")
|
|
||||||
assert factory.build_provider("openai").model == "gpt-4o"
|
|
||||||
assert factory.build_provider("local").model == "llama3.1:8b" # nie dziedziczy
|
|
||||||
|
|
||||||
|
|
||||||
def test_cloud_without_key_is_rejected_clearly(monkeypatch):
|
|
||||||
_clear(monkeypatch)
|
|
||||||
monkeypatch.setenv("LLM_PROVIDER", "local")
|
|
||||||
for name in ("openai", "anthropic"):
|
|
||||||
with pytest.raises(LLMError, match=f"{name.upper()}_API_KEY"):
|
|
||||||
factory.build_provider(name)
|
|
||||||
|
|
||||||
|
|
||||||
def test_local_needs_no_key(monkeypatch):
|
|
||||||
_clear(monkeypatch)
|
|
||||||
assert factory.build_provider("local").api_key == ""
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------- pusta odpowiedz modelu (cicha awaria)
|
|
||||||
# Regresja: model potrafi oddac pusta tresc (prompt zjadl caly kontekst ->
|
|
||||||
# finish_reason=length, completion_tokens=0). Wczesniej generate() zwracalo pusty
|
|
||||||
# tekst BEZ bledu, widok nic nie renderowal i uzytkownik dostawal pusta strone
|
|
||||||
# bez zadnego wyjasnienia. Pusta odpowiedz MUSI byc bledem.
|
|
||||||
|
|
||||||
def test_empty_completion_raises_instead_of_silent_blank(monkeypatch):
|
|
||||||
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
|
||||||
"model": "llama3.1:8b",
|
|
||||||
"choices": [{"message": {"content": ""}, "finish_reason": "length"}],
|
|
||||||
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
|
|
||||||
})))
|
|
||||||
with pytest.raises(LLMError, match="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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -13,12 +12,6 @@ import httpx
|
|||||||
from app.config import settings
|
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:
|
class LogicClient:
|
||||||
def __init__(self, base_url: str | None = None) -> None:
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
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]:
|
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
@@ -39,7 +32,6 @@ class LogicClient:
|
|||||||
house_system: str = "whole_sign",
|
house_system: str = "whole_sign",
|
||||||
stations: bool = False,
|
stations: bool = False,
|
||||||
zodiac: str = "tropical",
|
zodiac: str = "tropical",
|
||||||
tables: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||||
payload = {
|
payload = {
|
||||||
@@ -50,11 +42,10 @@ class LogicClient:
|
|||||||
"house_system": house_system,
|
"house_system": house_system,
|
||||||
"stations": stations,
|
"stations": stations,
|
||||||
"zodiac": zodiac,
|
"zodiac": zodiac,
|
||||||
"tables": tables,
|
|
||||||
}
|
}
|
||||||
# stacje wymagają root-findów — dłuższy timeout
|
# 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:
|
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, headers=_auth_headers())
|
r = client.post(f"{self.base_url}/chart/positions", json=payload)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
@@ -64,34 +55,29 @@ class LogicClient:
|
|||||||
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
|
"""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}
|
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:
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def prompt(
|
def prompt(
|
||||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||||
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
|
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
|
||||||
provider: str | None = None, model: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
|
"""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."""
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"profile": profile, "when_utc": when_utc_iso,
|
"profile": profile, "when_utc": when_utc_iso,
|
||||||
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
|
"lat": lat, "lon": lon, "budget": budget,
|
||||||
"model": model,
|
|
||||||
}
|
}
|
||||||
if from_date and to_date:
|
if from_date and to_date:
|
||||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
||||||
r = client.post(f"{self.base_url}/chart/prompt", json=payload, headers=_auth_headers())
|
r = client.post(f"{self.base_url}/chart/prompt", json=payload)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def horoscope(
|
def horoscope(
|
||||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
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,
|
from_date: str | None = None, to_date: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
|
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
|
||||||
@@ -104,19 +90,10 @@ class LogicClient:
|
|||||||
}
|
}
|
||||||
if provider:
|
if provider:
|
||||||
payload["provider"] = provider
|
payload["provider"] = provider
|
||||||
if model:
|
|
||||||
payload["model"] = model
|
|
||||||
if from_date and to_date:
|
if from_date and to_date:
|
||||||
payload["from_date"], payload["to_date"] = from_date, to_date
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client:
|
with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client:
|
||||||
r = client.post(f"{self.base_url}/chart/horoscope", json=payload, headers=_auth_headers())
|
r = client.post(f"{self.base_url}/chart/horoscope", json=payload)
|
||||||
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.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
@@ -130,6 +107,6 @@ class LogicClient:
|
|||||||
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
||||||
}
|
}
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
||||||
r = client.post(f"{self.base_url}/chart/timeline", json=payload, headers=_auth_headers())
|
r = client.post(f"{self.base_url}/chart/timeline", json=payload)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app import geocode, security
|
from app import geocode
|
||||||
from app.clients.logic_client import LogicClient
|
from app.clients.logic_client import LogicClient
|
||||||
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
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")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
logic = LogicClient()
|
logic = LogicClient()
|
||||||
security.install(app) # logowanie + limit żądań (LOG-32)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
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
|
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:
|
def _logic_error(e: Exception) -> str:
|
||||||
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
||||||
return (
|
return (
|
||||||
@@ -62,8 +52,7 @@ def _logic_error(e: Exception) -> str:
|
|||||||
def chart_form(request: Request):
|
def chart_form(request: Request):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "chart.html",
|
request, "chart.html",
|
||||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||||
"llm_catalog": _llm_catalog()},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -78,18 +67,17 @@ def chart_compute(
|
|||||||
house_system: str = Form("whole_sign"),
|
house_system: str = Form("whole_sign"),
|
||||||
stations: bool = Form(False),
|
stations: bool = Form(False),
|
||||||
zodiac: str = Form("tropical"),
|
zodiac: str = Form("tropical"),
|
||||||
tables: bool = Form(False),
|
|
||||||
):
|
):
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
|
"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}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
ctx["result"] = logic.positions(
|
ctx["result"] = logic.positions(
|
||||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
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:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
@@ -126,8 +114,7 @@ def significators_search(
|
|||||||
def interpret_form(request: Request):
|
def interpret_form(request: Request):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "interpret.html",
|
request, "interpret.html",
|
||||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||||
"llm_catalog": _llm_catalog()},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -143,25 +130,22 @@ def interpret_run(
|
|||||||
action: str = Form("report"),
|
action: str = Form("report"),
|
||||||
prompt_budget: str = Form("medium"),
|
prompt_budget: str = Form("medium"),
|
||||||
llm_provider: str = Form("local"),
|
llm_provider: str = Form("local"),
|
||||||
llm_model: str = Form(""),
|
|
||||||
):
|
):
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
"llm_provider": llm_provider}
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
"llm_catalog": _llm_catalog()}
|
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
if action == "prompt":
|
if action == "prompt":
|
||||||
ctx["prompt_result"] = logic.prompt(
|
ctx["prompt_result"] = logic.prompt(
|
||||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
||||||
provider=llm_provider, model=llm_model,
|
|
||||||
)
|
)
|
||||||
elif action == "horoscope":
|
elif action == "horoscope":
|
||||||
ctx["prompt_result"] = logic.horoscope(
|
ctx["prompt_result"] = logic.horoscope(
|
||||||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
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:
|
else:
|
||||||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
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):
|
def timeline_form(request: Request):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "timeline.html",
|
request, "timeline.html",
|
||||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||||
"llm_catalog": _llm_catalog()},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -195,13 +178,11 @@ def timeline_run(
|
|||||||
action: str = Form("timeline"),
|
action: str = Form("timeline"),
|
||||||
prompt_budget: str = Form("medium"),
|
prompt_budget: str = Form("medium"),
|
||||||
llm_provider: str = Form("local"),
|
llm_provider: str = Form("local"),
|
||||||
llm_model: str = Form(""),
|
|
||||||
):
|
):
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
"llm_provider": llm_provider}
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
"llm_catalog": _llm_catalog()}
|
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
@@ -209,12 +190,11 @@ def timeline_run(
|
|||||||
ctx["prompt_result"] = logic.prompt(
|
ctx["prompt_result"] = logic.prompt(
|
||||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
||||||
provider=llm_provider, model=llm_model,
|
|
||||||
)
|
)
|
||||||
elif action == "horoscope":
|
elif action == "horoscope":
|
||||||
ctx["prompt_result"] = logic.horoscope(
|
ctx["prompt_result"] = logic.horoscope(
|
||||||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
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,
|
from_date=from_date, to_date=to_date,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
"""Ochrona dostępu do aplikacji (LOG-32).
|
|
||||||
|
|
||||||
Rdzeniem produktu są 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 tę 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).
|
{# Wspólny blok: sterowanie generowaniem + prompt (LOG-29/30) + horoskop (LOG-31).
|
||||||
Używany na ekranach Interpretacje i Kalendarz. #}
|
Używany na ekranach Interpretacje i Kalendarz. #}
|
||||||
<div class="opts">
|
<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="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="medium" {{ 'selected' if pb == 'medium' else '' }}>średni (~12 tys.)</option>
|
||||||
<option value="extensive" {{ 'selected' if pb == 'extensive' else '' }}>obszerny (~30 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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Dostawca
|
<label>Model
|
||||||
<select name="llm_provider" id="llmProvider">
|
<select name="llm_provider">
|
||||||
{% set lp = form.llm_provider or 'local' %}
|
{% set lp = form.llm_provider or 'local' %}
|
||||||
<option value="local" {{ 'selected' if lp == 'local' else '' }}>lokalny — nic nie opuszcza sieci</option>
|
<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="anthropic" {{ 'selected' if lp == 'anthropic' else '' }}>Anthropic — dane wychodzą</option>
|
||||||
<option value="openai" {{ 'selected' if lp == 'openai' else '' }}>OpenAI — dane wychodzą</option>
|
<option value="openai" {{ 'selected' if lp == 'openai' else '' }}>OpenAI — dane wychodzą</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
<p class="muted small" id="llmModelHint"></p>
|
|
||||||
<p class="muted small">
|
<p class="muted small">
|
||||||
Prompt zawiera <strong>oryginalne opisy z baz</strong> oraz dane urodzeniowe. Model lokalny
|
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ą
|
przetwarza je u nas; wybór dostawcy w chmurze oznacza, że ta treść <strong>opuszcza naszą
|
||||||
@@ -63,23 +51,6 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if prompt_result.llm_error %}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
|
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
|
||||||
|
|||||||
@@ -47,8 +47,6 @@
|
|||||||
<div class="opts">
|
<div class="opts">
|
||||||
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||||
licz stacje planet (wolniejsze)</label>
|
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>
|
</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 %}
|
{% 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">
|
<div class="actions">
|
||||||
@@ -144,87 +142,6 @@
|
|||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if result.cusps %}
|
||||||
<details class="loc">
|
<details class="loc">
|
||||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||||
|
|||||||
@@ -90,5 +90,4 @@
|
|||||||
|
|
||||||
<script src="/static/now.js"></script>
|
<script src="/static/now.js"></script>
|
||||||
<script src="/static/copy.js"></script>
|
<script src="/static/copy.js"></script>
|
||||||
<script src="/static/models.js"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -80,5 +80,4 @@
|
|||||||
|
|
||||||
<script src="/static/now.js"></script>
|
<script src="/static/now.js"></script>
|
||||||
<script src="/static/copy.js"></script>
|
<script src="/static/copy.js"></script>
|
||||||
<script src="/static/models.js"></script>
|
|
||||||
{% endblock %}
|
{% 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))
|
|
||||||
Reference in New Issue
Block a user