Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ace76183e5 | |||
| f1956a08ff | |||
| e8f868e907 | |||
| 56fdf01d7d | |||
| 23416cb1f9 | |||
| 473d059a6a | |||
| 929b691238 | |||
| 114b7eebdf | |||
| 163ace4283 | |||
| f33cdc88f4 | |||
| 877ec91ff0 | |||
| 487dbb8fd3 | |||
| 5203ba9e76 | |||
| 64d1afc76d | |||
| 4da5f5fe7e | |||
| 4ef90b30bc | |||
| 73fd41b9d3 | |||
| 752f477a27 | |||
| 04b26afa6d | |||
| 2892b71be3 | |||
| 6f87b2b323 | |||
| a2aabd37a5 | |||
| de5d58958c | |||
| ab6fc0722c | |||
| 6ced2ddd22 | |||
| 79bce8ae90 | |||
| 85e9182f12 | |||
| c75f8377bf | |||
| 5234e86c95 | |||
| 0ac8df250c | |||
| 9323803cb4 | |||
| 678c3bad76 | |||
| 88a1ecd3c4 | |||
| b28c612bf7 | |||
| 2bda4e311d | |||
| 5d0d86cb90 | |||
| 9d5b65ddbd | |||
| cff0ac194c | |||
| 73f39e7df8 | |||
| 96d983b26a | |||
| c4a181b810 | |||
| 97ad21d2e4 |
@@ -0,0 +1,32 @@
|
|||||||
|
name: build-swisseph
|
||||||
|
|
||||||
|
# Osobny pipeline dla silnika B (Swiss Ephemeris, AGPL) — celowo ODDZIELONY od
|
||||||
|
# głównego build.yaml (data/logic/presentation). Buduje się tylko, gdy zmienia się
|
||||||
|
# sam silnik, i nie miesza obrazu AGPL do pipeline'u permisywnego produktu.
|
||||||
|
#
|
||||||
|
# Obraz konsumuje profil deployu `astrololo-swisseph` w repo `deploy`.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'services/engine-swisseph/**'
|
||||||
|
- '.gitea/workflows/build-swisseph.yaml'
|
||||||
|
workflow_dispatch: {} # ręczne odpalenie (bootstrap pierwszego obrazu)
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||||
|
- name: Build & push engine-swisseph (AGPL, izolowany)
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_SHA::8}
|
||||||
|
IMG=gitea.czernobog.pl/gitea/astrololo-engine-swisseph
|
||||||
|
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312) — build jest
|
||||||
|
# zarazem realnym testem Dockerfile'a.
|
||||||
|
docker build -t $IMG:$TAG -t $IMG:latest ./services/engine-swisseph
|
||||||
|
docker push $IMG:$TAG
|
||||||
|
docker push $IMG:latest
|
||||||
|
echo "Zbudowano i wypchnięto: $IMG:$TAG (+ latest)"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||||
|
- name: Build & push (data, logic, presentation)
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_SHA::8}
|
||||||
|
for SVC in data logic presentation; do
|
||||||
|
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
|
||||||
|
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
|
||||||
|
done
|
||||||
|
echo "Tag: $TAG"
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
name: Testy
|
||||||
|
|
||||||
|
# Odpala się przy każdym pushu (dowolna gałąź) oraz dla pull requestów do master.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
logic-tests:
|
||||||
|
name: Testy warstwy logicznej (silnik)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Python 3.12 (jak w obrazach Dockera)
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: services/logic/requirements-dev.txt
|
||||||
|
|
||||||
|
# Jądro efemeryd JPL (de421.bsp, ~17 MB). Cache'ujemy je między runami.
|
||||||
|
- name: Cache jądra efemeryd
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: services/logic/.ephemeris
|
||||||
|
key: ephemeris-de421
|
||||||
|
|
||||||
|
# Pobieramy jawnie (a nie licząc na auto-pobranie przez Skyfield), żeby
|
||||||
|
# brak jądra był twardym błędem, a nie cichym pomijaniem testów.
|
||||||
|
- name: Pobierz jądro efemeryd (gdy brak w cache)
|
||||||
|
run: |
|
||||||
|
mkdir -p services/logic/.ephemeris
|
||||||
|
if [ ! -s services/logic/.ephemeris/de421.bsp ]; then
|
||||||
|
curl -fSL --retry 3 --max-time 300 \
|
||||||
|
-o services/logic/.ephemeris/de421.bsp \
|
||||||
|
https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp
|
||||||
|
fi
|
||||||
|
ls -lh services/logic/.ephemeris/de421.bsp
|
||||||
|
|
||||||
|
- name: Instalacja zależności
|
||||||
|
run: pip install -r services/logic/requirements-dev.txt
|
||||||
|
|
||||||
|
# CI=true (ustawiane przez GitHub) sprawia, że brak silnika = błąd,
|
||||||
|
# a nie pominięcie — patrz tests/conftest.py.
|
||||||
|
- name: Testy (pytest)
|
||||||
|
working-directory: services/logic
|
||||||
|
env:
|
||||||
|
PYTHONPATH: .
|
||||||
|
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
|
||||||
|
run: pytest tests -q -rs
|
||||||
|
|
||||||
|
presentation-tests:
|
||||||
|
name: Testy warstwy prezentacji (dostęp do baz)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: services/presentation/requirements-dev.txt
|
||||||
|
- name: Instalacja zależności
|
||||||
|
run: pip install -r services/presentation/requirements-dev.txt
|
||||||
|
# Bramka chroniąca oryginalne bazy — nietestowany kod ochronny jest gorszy
|
||||||
|
# niż jego brak, bo daje złudzenie zabezpieczenia.
|
||||||
|
- name: Testy (pytest)
|
||||||
|
working-directory: services/presentation
|
||||||
|
env:
|
||||||
|
PYTHONPATH: .
|
||||||
|
run: pytest tests -q -rs
|
||||||
|
|
||||||
|
swisseph-image:
|
||||||
|
name: Build obrazu silnika B (swisseph)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312), więc ten
|
||||||
|
# build jest realnym testem Dockerfile'a — nie tylko pobraniem paczek.
|
||||||
|
- name: docker build
|
||||||
|
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
|
||||||
|
|
||||||
|
# Test biegnie WEWNĄTRZ obrazu, bez sieci i bez kontenera w tle. Poprzednia
|
||||||
|
# wersja startowała kontener w tle (--name swe) i pukała curl-em w
|
||||||
|
# localhost:8003 — co miało dwie wady:
|
||||||
|
# 1. nie sprzątała kontenera, więc każdy kolejny przebieg padał na
|
||||||
|
# konflikcie nazwy (Conflict. The container name "/swe" is already in use),
|
||||||
|
# 2. job Gitea Actions sam działa w kontenerze, a -p publikuje port na
|
||||||
|
# HOŚCIE — więc localhost joba to nie ten sam localhost.
|
||||||
|
# Wywołanie funkcji endpointów wprost omija oba problemy, a sprawdza to samo:
|
||||||
|
# obraz się zbudował, pyswisseph liczy, kontrakt /positions się zgadza.
|
||||||
|
# --rm gwarantuje, że nic nie zostaje po przebiegu.
|
||||||
|
- name: Smoke test (health + pozycje) wewnątrz obrazu
|
||||||
|
run: |
|
||||||
|
docker run --rm astrololo/engine-swisseph:ci python - <<'PY'
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.main import DEFAULT_OBJECTS, PositionsRequest, health, positions
|
||||||
|
|
||||||
|
h = health()
|
||||||
|
assert h["status"] == "ok", h
|
||||||
|
print("health:", h)
|
||||||
|
|
||||||
|
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
|
||||||
|
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
|
||||||
|
req = PositionsRequest(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
||||||
|
lat=50.0647, lon=19.9450)
|
||||||
|
out = positions(req)
|
||||||
|
by = {p["name"]: p for p in out["positions"]}
|
||||||
|
|
||||||
|
assert out["engine"] == "swisseph", out["engine"]
|
||||||
|
assert len(by) == len(DEFAULT_OBJECTS), sorted(by)
|
||||||
|
sun = by["Sun"]["longitude"]
|
||||||
|
assert 39.5 < sun < 41.0, f"Slonce poza oczekiwanym zakresem: {sun}"
|
||||||
|
nn, sn = by["North Node"]["longitude"], by["South Node"]["longitude"]
|
||||||
|
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
|
||||||
|
|
||||||
|
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
|
||||||
|
print("SMOKE OK")
|
||||||
|
PY
|
||||||
|
|
||||||
|
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
|
||||||
|
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
|
||||||
|
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
|
||||||
|
- name: Usuń osierocony kontener ze starych przebiegów
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f swe 2>/dev/null || true
|
||||||
|
|
||||||
|
compile-all:
|
||||||
|
name: Kontrola składni wszystkich warstw
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
# Sam kompilator — bez instalowania zależności warstw (w tym AGPL-owego
|
||||||
|
# silnika swisseph, który nie wchodzi do produktu).
|
||||||
|
- name: py_compile
|
||||||
|
run: python -m compileall -q services
|
||||||
@@ -73,3 +73,4 @@ export DATA_PROVIDER=sql # przełącz całą warstwę
|
|||||||
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
|
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
|
||||||
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
|
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
|
||||||
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
|
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
|
||||||
|
# updater test Mon 20 Jul 2026 19:52:08 CEST
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,296 @@
|
|||||||
|
# Wdrożenie PRE-16 — HTTPS na wejściu i szyfrowanie łączy między warstwami
|
||||||
|
|
||||||
|
Instrukcja krok po kroku. **Kolejność ma znaczenie** — punkt „Dlaczego taka
|
||||||
|
kolejność" niżej tłumaczy, co się stanie, jeśli ją zamienić.
|
||||||
|
|
||||||
|
Dotyczy dwóch pull requestów:
|
||||||
|
|
||||||
|
| Repo | PR | Co wnosi |
|
||||||
|
|---|---|---|
|
||||||
|
| `gitea/astrololo` | [#21](https://gitea.czernobog.pl/gitea/astrololo/pulls/21) | kod: szyfrowanie łączy, limit żądań za proxy |
|
||||||
|
| `gitea/deploy` | [#4](https://gitea.czernobog.pl/gitea/deploy/pulls/4) | manifesty: Ingress, certyfikat, klucze łączy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Co się właściwie zmienia
|
||||||
|
|
||||||
|
**Na wejściu do aplikacji.** Dotąd logowanie szło przez HTTP Basic po zwykłym
|
||||||
|
http — czyli hasło leciało siecią w postaci trywialnej do podsłuchania (base64 to
|
||||||
|
nie szyfrowanie). Po zmianie wejście jest po https, a http odsyła na https.
|
||||||
|
Przy okazji **odblokowują się dwie funkcje zepsute dziś z tego samego powodu**:
|
||||||
|
geolokalizacja („Tu i teraz") i kopiowanie promptu do schowka działają wyłącznie
|
||||||
|
w tzw. secure context i po http po prostu odmawiały.
|
||||||
|
|
||||||
|
**Między warstwami.** Prezentacja, logika i dane rozmawiały ze sobą otwartym
|
||||||
|
tekstem wewnątrz klastra. Token międzywarstwowy mówił *kto* pyta, ale nie ukrywał
|
||||||
|
*czego dotyczy odpowiedź* — a płyną nią surowe wiersze oryginalnych baz. Teraz
|
||||||
|
każde ciało żądania i odpowiedzi jest szyfrowane **AES-256-GCM**, osobnym kluczem
|
||||||
|
na każdą parę rozmówców.
|
||||||
|
|
||||||
|
**Wejście na świat pozostaje jedno: prompt do modelu.** Ta zmiana niczego tu nie
|
||||||
|
rusza — dotyczy wyłącznie ruchu wewnątrz sieci i wejścia z przeglądarki.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zanim zaczniesz — stan wyjściowy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get deploy,svc
|
||||||
|
kubectl -n astrololo get secret # powinny być: astrololo-auth, gitea-registry
|
||||||
|
kubectl -n kube-system get svc traefik -o jsonpath='{.status.loadBalancer.ingress[*].ip}'; echo
|
||||||
|
```
|
||||||
|
|
||||||
|
Zanotuj adres Traefika — będzie potrzebny w kroku 3. Sprawdź też, czy działa
|
||||||
|
aplikacja w obecnej postaci (przez NodePort), żeby mieć punkt odniesienia.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 1 — sekret z kluczami łączy
|
||||||
|
|
||||||
|
**Przed czymkolwiek innym.** Klucze muszą istnieć, zanim pody spróbują wstać
|
||||||
|
z nową konfiguracją, bo bez nich celowo **nie wystartują**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo create secret generic astrololo-link \
|
||||||
|
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(openssl rand -hex 32)" \
|
||||||
|
--from-literal=LINK_KEY_LOGIC_DATA="$(openssl rand -hex 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Kluczy nikt nigdy nie musi oglądać — służą tylko usługom. Nie ma ich w repo
|
||||||
|
GitOps i **nie ma ich tam wkładać**: cokolwiek trafi do gita, zostaje w historii
|
||||||
|
na zawsze.
|
||||||
|
|
||||||
|
Dwa osobne klucze to nie ozdobnik. Przejęcie klucza prezentacji nie daje dostępu
|
||||||
|
do warstwy danych, gdzie leżą całe bazy. Logika dostaje oba, bo rozmawia w obie
|
||||||
|
strony; prezentacja i dane dostają wyłącznie swój.
|
||||||
|
|
||||||
|
Sprawdź:
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
|
||||||
|
# oczekiwane: dwa klucze, każdy 64 znaki po odkodowaniu (32 bajty)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 2 — cert-manager
|
||||||
|
|
||||||
|
Jednorazowo, na cały klaster:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml
|
||||||
|
kubectl -n cert-manager rollout status deploy/cert-manager deploy/cert-manager-webhook --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
Poczekaj, aż **webhook** będzie gotowy — dopóki nie wstanie, tworzenie obiektów
|
||||||
|
`Certificate` kończy się błędem połączenia i wygląda jak zepsuty manifest.
|
||||||
|
|
||||||
|
Sprawdź:
|
||||||
|
```bash
|
||||||
|
kubectl get crd | grep cert-manager | head -3 # muszą się pojawić
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Dlaczego własne CA, a nie Let's Encrypt.** Klaster stoi w LAN (Traefik trzyma
|
||||||
|
> LoadBalancera na adresach 192.168.1.x), więc walidacja HTTP-01 nie ma jak dojść
|
||||||
|
> z internetu, a DNS-01 wymagałby trzymania w klastrze tokena API do domeny.
|
||||||
|
> Własne CA nie potrzebuje niczego z zewnątrz i odnawia certyfikaty samo. Cena:
|
||||||
|
> raz na urządzenie importujesz korzeń (krok 6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 3 — DNS
|
||||||
|
|
||||||
|
Wpis `astrololo.czernobog.pl` → adres Traefika z kroku „stan wyjściowy”.
|
||||||
|
W routerze, lokalnym DNS-ie albo doraźnie w `/etc/hosts`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "192.168.1.73 astrololo.czernobog.pl" | sudo tee -a /etc/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
**To nie jest krok opcjonalny.** Service `presentation` przestaje być NodePortem
|
||||||
|
(był drugą, nieszyfrowaną drogą do aplikacji — czyli obejściem całego PRE-16),
|
||||||
|
więc po wdrożeniu manifestów nazwa jest jedynym wejściem. Awaryjnie zawsze zostaje:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo port-forward svc/presentation 8000:8000 # http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 4 — merge PR-a aplikacji (astrololo #21)
|
||||||
|
|
||||||
|
Teraz, **przed** manifestami.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr merge --login gitea --repo gitea/astrololo 21
|
||||||
|
```
|
||||||
|
|
||||||
|
Po merge'u CI zbuduje obrazy, a image-updater sam podbije tagi w repo `deploy`,
|
||||||
|
skąd ArgoCD wymieni pody. Poczekaj, aż to się przetoczy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
|
||||||
|
kubectl -n astrololo get pods -o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Na tym etapie **nic się jeszcze nie szyfruje** — nowy kod to potrafi, ale zmienne
|
||||||
|
z kluczami dokłada dopiero PR do `deploy`. Aplikacja działa dokładnie jak dotąd.
|
||||||
|
To celowe: chcemy, żeby *cała* obsada podów umiała szyfrować, zanim ktokolwiek
|
||||||
|
tego zażąda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 5 — merge PR-a manifestów (deploy #4)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr merge --login gitea --repo gitea/deploy 4
|
||||||
|
```
|
||||||
|
|
||||||
|
ArgoCD zsynchronizuje się sam (`automated`, `selfHeal`). Wjeżdża naraz: Ingress,
|
||||||
|
certyfikat, zmienne z kluczami, `TRUST_PROXY` i zdjęcie NodePortu.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n argocd get application astrololo
|
||||||
|
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
|
||||||
|
kubectl -n astrololo get certificate # astrololo-ca i astrololo-tls: READY=True
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Spodziewaj się kilkudziesięciu sekund błędów w trakcie.** Pody wymieniają się
|
||||||
|
> po kolei, więc przez chwilę stara prezentacja (jeszcze bez klucza) rozmawia
|
||||||
|
> z nową logiką (już z kluczem) i dostaje odmowę. To zamierzone: alternatywą byłby
|
||||||
|
> tryb „przyjmuj i szyfrowane, i jawne”, który zwykle zostaje włączony na zawsze.
|
||||||
|
|
||||||
|
Merge nie cofnie tagów obrazów — PR dotyka w `kustomization.yaml` wyłącznie listy
|
||||||
|
`resources`, nie bloku `images`, więc git złoży to z nowszymi tagami z mastera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 6 — zaufanie do własnego CA (raz na urządzenie)
|
||||||
|
|
||||||
|
Bez tego przeglądarka pokaże ostrzeżenie o certyfikacie. Korzeń jest ważny 10 lat,
|
||||||
|
więc robisz to raz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get secret astrololo-ca -o jsonpath='{.data.ca\.crt}' \
|
||||||
|
| base64 -d > astrololo-ca.crt
|
||||||
|
|
||||||
|
# macOS — do systemowego zaufania (poprosi o hasło administratora)
|
||||||
|
sudo security add-trusted-cert -d -r trustRoot \
|
||||||
|
-k /Library/Keychains/System.keychain astrololo-ca.crt
|
||||||
|
|
||||||
|
# Linux (Debian/Ubuntu)
|
||||||
|
sudo cp astrololo-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
Firefox ma **własny** magazyn certyfikatów — import przez *Ustawienia →
|
||||||
|
Prywatność i bezpieczeństwo → Wyświetl certyfikaty → Organy certyfikacji*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 7 — sprawdzenie, że działa to, co miało zadziałać
|
||||||
|
|
||||||
|
### Wejście po https
|
||||||
|
```bash
|
||||||
|
curl -sI http://astrololo.czernobog.pl/ | head -2 # 301 → https
|
||||||
|
curl -s -o /dev/null -w "bez hasła: %{http_code}\n" https://astrololo.czernobog.pl/
|
||||||
|
curl -s -o /dev/null -w "z hasłem: %{http_code}\n" -u astrololo:'<hasło>' https://astrololo.czernobog.pl/
|
||||||
|
curl -sI -u astrololo:'<hasło>' https://astrololo.czernobog.pl/ | grep -i strict-transport
|
||||||
|
```
|
||||||
|
Oczekiwane: **301**, **401**, **200**, nagłówek HSTS obecny. Brak ostrzeżenia
|
||||||
|
o certyfikacie w przeglądarce oznacza, że krok 6 się udał.
|
||||||
|
|
||||||
|
### W przeglądarce
|
||||||
|
Kliknij **„Tu i teraz"** — powinno pobrać lokalizację (po http odmawiało).
|
||||||
|
Wygeneruj prompt i kliknij **kopiuj** — schowek powinien zadziałać bez obejść.
|
||||||
|
|
||||||
|
### Szyfrowanie łączy — sprawdzenie wprost
|
||||||
|
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **bez klucza**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo exec deploy/presentation -- \
|
||||||
|
python -c "
|
||||||
|
import httpx, os
|
||||||
|
r = httpx.post('http://logic:8001/chart/report',
|
||||||
|
json={'when_utc':'1984-04-30T09:20:00+00:00','lat':50.06,'lon':19.94},
|
||||||
|
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
|
||||||
|
print(r.status_code, r.text[:120])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Zwróć uwagę, że
|
||||||
|
żądanie miało **prawidłowy token** — sam token już nie wystarcza, i o to chodziło.
|
||||||
|
|
||||||
|
To samo w dół, do warstwy danych:
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo exec deploy/logic -- \
|
||||||
|
python -c "
|
||||||
|
import httpx, os
|
||||||
|
r = httpx.post('http://data:8002/search',
|
||||||
|
json={'key':'significator','value':'[Sat','exact':False,'limit':5},
|
||||||
|
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
|
||||||
|
print(r.status_code, r.text[:120])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logi startowe
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo logs deploy/logic | grep -i "łącze\|UWAGA"
|
||||||
|
```
|
||||||
|
Powinno być `łącze szyfrowane (AES-256-GCM…)`. Jeśli widzisz ostrzeżenie
|
||||||
|
o rozmowie **jawnym tekstem** — klucz nie doszedł do poda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dlaczego taka kolejność
|
||||||
|
|
||||||
|
| Kolejność | Skutek zamiany |
|
||||||
|
|---|---|
|
||||||
|
| Sekret **przed** manifestami | `LINK_ENCRYPTION_REQUIRED=true` bez klucza celowo wywraca start. Pody wpadną w CrashLoop i będą tak siedzieć do czasu utworzenia sekretu. |
|
||||||
|
| cert-manager **przed** manifestami | API odrzuci `Certificate`/`Issuer` jako nieznane rodzaje zasobów, ArgoCD pokaże aplikację jako niezsynchronizowaną i sam tego nie naprawi. |
|
||||||
|
| DNS **przed** manifestami | NodePort znika razem z nimi. Bez wpisu DNS zostaje tylko `port-forward`. |
|
||||||
|
| Aplikacja **przed** manifestami | Odwrotnie: manifesty włączyłyby szyfrowanie na obrazach, które go nie znają — wszystkie żądania kończyłyby się odmową do czasu przebudowy obrazów. |
|
||||||
|
|
||||||
|
Fail-closed w obie strony jest zamierzony. Usługa, która wstała i **po cichu nie
|
||||||
|
szyfruje**, jest gorsza niż pod w CrashLoop — awarii nie widać, a bazy jadą
|
||||||
|
otwartym tekstem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wycofanie
|
||||||
|
|
||||||
|
Manifestów: `git revert` merge'a w `deploy` — ArgoCD samo wróci do NodePortu
|
||||||
|
i ruchu bez szyfrowania. Kod aplikacji **nie wymaga wycofania**: bez zmiennych
|
||||||
|
`LINK_KEY_*` moduł przepuszcza ruch jak dotąd (i głośno o tym mówi w logach).
|
||||||
|
|
||||||
|
Certyfikat i CA zostają w namespace; usunięcie: `kubectl -n astrololo delete
|
||||||
|
certificate astrololo-ca astrololo-tls`. cert-managera można zostawić — nie
|
||||||
|
przeszkadza.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gdy coś nie gra
|
||||||
|
|
||||||
|
| Objaw | Przyczyna | Co zrobić |
|
||||||
|
|---|---|---|
|
||||||
|
| Pody w `CrashLoopBackOff`, w logach `LINK_ENCRYPTION_REQUIRED … nie ustawiony` | brak sekretu `astrololo-link` | krok 1, potem `rollout restart` |
|
||||||
|
| `400 Łącze międzywarstwowe wymaga szyfrowania` przy normalnym korzystaniu | jedna warstwa ma klucz, druga nie (albo trwa rollout) | `rollout status`; sprawdź, czy wszystkie trzy pody mają zmienną |
|
||||||
|
| `400 Nie udało się odczytać zaszyfrowanego żądania` | klucze po obu stronach łącza są **różne** | wymień sekret i zrestartuj **wszystkie trzy** naraz |
|
||||||
|
| `Certificate` stoi w `READY=False` | webhook cert-managera jeszcze nie wstał | `kubectl -n cert-manager get pods`, poczekaj i sprawdź `kubectl -n astrololo describe certificate astrololo-tls` |
|
||||||
|
| Przeglądarka: „połączenie nie jest prywatne” | korzeń CA nieimportowany na tym urządzeniu | krok 6 (pamiętaj, że Firefox ma osobny magazyn) |
|
||||||
|
| `404` z Traefika pod adresem aplikacji | DNS wskazuje gdzie indziej niż LoadBalancer Traefika | porównaj `dig +short astrololo.czernobog.pl` z adresem z kroku „stan wyjściowy” |
|
||||||
|
| Limit żądań odcina wszystkich naraz | brak `TRUST_PROXY=true` — cały ruch liczony jako jeden klient | sprawdź zmienną w `deploy/presentation` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Czego to nie załatwia
|
||||||
|
|
||||||
|
- **Szyfrowane są ciała żądań, nie nagłówki.** Ścieżka (`/search`) i token
|
||||||
|
międzywarstwowy jadą czytelnie. Sam token nikomu nic nie daje — bez klucza łącza
|
||||||
|
każde żądanie kończy się odmową — ale metadanych to nie ukrywa. Pełne ukrycie
|
||||||
|
wymagałoby mTLS.
|
||||||
|
- **Własne CA to nie publiczne zaufanie.** Każde nowe urządzenie wymaga importu
|
||||||
|
korzenia. Gdyby aplikacja miała kiedyś wyjść na świat, właściwą drogą jest
|
||||||
|
Let's Encrypt przez DNS-01.
|
||||||
|
- **NFS z plikami baz** stoi obok aplikacji — kto ma dostęp do share'u, bierze
|
||||||
|
pliki z pominięciem wszystkich powyższych zabezpieczeń. Do zamknięcia po stronie
|
||||||
|
infrastruktury (eksport tylko dla IP węzłów, `root_squash`, najlepiej read-only).
|
||||||
|
- **Sekrety w etcd** są tylko zakodowane base64. Docelowo: szyfrowanie etcd
|
||||||
|
at-rest albo Sealed Secrets / SOPS.
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
||||||
|
|
||||||
|
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
||||||
|
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
||||||
|
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
||||||
|
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
||||||
|
zrzut z węzła), miał je w całości.
|
||||||
|
|
||||||
|
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
||||||
|
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
||||||
|
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
||||||
|
podatne na modyfikację".
|
||||||
|
|
||||||
|
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
||||||
|
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
||||||
|
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
||||||
|
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
||||||
|
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
||||||
|
|
||||||
|
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
||||||
|
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
||||||
|
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
||||||
|
|
||||||
|
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
||||||
|
|
||||||
|
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
||||||
|
|
||||||
|
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
||||||
|
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
||||||
|
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
||||||
|
|
||||||
|
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
||||||
|
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
||||||
|
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
||||||
|
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.link")
|
||||||
|
|
||||||
|
MAGIC = b"AL1"
|
||||||
|
VERSION = "v1"
|
||||||
|
NONCE_BYTES = 12
|
||||||
|
KEY_BYTES = 32 # AES-256
|
||||||
|
LENGTH_PREFIX = 4
|
||||||
|
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
||||||
|
MAX_SKEW_SECONDS = 300.0
|
||||||
|
|
||||||
|
HEADER_ENC = "X-Astrololo-Enc"
|
||||||
|
HEADER_TS = "X-Astrololo-Enc-Ts"
|
||||||
|
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
||||||
|
|
||||||
|
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||||
|
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||||
|
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||||
|
|
||||||
|
REQUEST, RESPONSE = b"req", b"res"
|
||||||
|
|
||||||
|
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
||||||
|
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
class LinkError(Exception):
|
||||||
|
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- klucze
|
||||||
|
|
||||||
|
def parse_key(raw: str) -> bytes:
|
||||||
|
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
raise LinkError("pusty klucz łącza")
|
||||||
|
try:
|
||||||
|
key = bytes.fromhex(text)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(text, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
||||||
|
if len(key) != KEY_BYTES:
|
||||||
|
raise LinkError(
|
||||||
|
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
||||||
|
f"`openssl rand -hex 32`"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def key_from_env(env_name: str) -> bytes | None:
|
||||||
|
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
||||||
|
raw = os.getenv(env_name, "")
|
||||||
|
return parse_key(raw) if raw.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_required() -> bool:
|
||||||
|
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
||||||
|
|
||||||
|
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
||||||
|
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
||||||
|
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
||||||
|
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
||||||
|
"""
|
||||||
|
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
||||||
|
return HKDF(
|
||||||
|
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
||||||
|
info=b"astrololo/link/" + direction,
|
||||||
|
).derive(link_key)
|
||||||
|
|
||||||
|
|
||||||
|
class Link:
|
||||||
|
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
||||||
|
|
||||||
|
def __init__(self, link_key: bytes) -> None:
|
||||||
|
self._by_direction = {
|
||||||
|
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
||||||
|
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- pojedyncza ramka
|
||||||
|
|
||||||
|
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
||||||
|
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
||||||
|
stamp.encode("ascii"), str(seq).encode("ascii")])
|
||||||
|
|
||||||
|
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
plaintext: bytes) -> bytes:
|
||||||
|
nonce = os.urandom(NONCE_BYTES)
|
||||||
|
sealed = self._by_direction[direction].encrypt(
|
||||||
|
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
||||||
|
return MAGIC + nonce + sealed
|
||||||
|
|
||||||
|
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
frame: bytes) -> bytes:
|
||||||
|
if not frame.startswith(MAGIC):
|
||||||
|
raise LinkError("ramka bez znacznika astrololo")
|
||||||
|
body = frame[len(MAGIC):]
|
||||||
|
if len(body) <= NONCE_BYTES:
|
||||||
|
raise LinkError("ramka za krótka")
|
||||||
|
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
|
||||||
|
try:
|
||||||
|
return self._by_direction[direction].decrypt(
|
||||||
|
nonce, sealed, self._aad(direction, path, stamp, seq))
|
||||||
|
except InvalidTag as exc:
|
||||||
|
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
|
||||||
|
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
|
||||||
|
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
|
||||||
|
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ strumień ramek
|
||||||
|
|
||||||
|
def seal_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
chunks: Iterable[bytes]) -> Iterator[bytes]:
|
||||||
|
for seq, chunk in enumerate(chunks):
|
||||||
|
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
|
||||||
|
|
||||||
|
def open_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
raw: bytes) -> Iterator[bytes]:
|
||||||
|
for seq, frame in enumerate(frames_in(raw)):
|
||||||
|
yield self.open(direction, path, stamp, seq, frame)
|
||||||
|
|
||||||
|
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
|
||||||
|
return b"".join(self.open_stream(direction, path, stamp, raw))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ramkowanie
|
||||||
|
|
||||||
|
def frame_out(payload: bytes) -> bytes:
|
||||||
|
return struct.pack(">I", len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def frames_in(raw: bytes) -> Iterator[bytes]:
|
||||||
|
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
|
||||||
|
offset = 0
|
||||||
|
while offset < len(raw):
|
||||||
|
if offset + LENGTH_PREFIX > len(raw):
|
||||||
|
raise LinkError("urwana ramka (brak nagłówka długości)")
|
||||||
|
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
offset += LENGTH_PREFIX
|
||||||
|
if offset + size > len(raw):
|
||||||
|
raise LinkError("urwana ramka (za mało danych)")
|
||||||
|
yield raw[offset:offset + size]
|
||||||
|
offset += size
|
||||||
|
|
||||||
|
|
||||||
|
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
|
||||||
|
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
|
||||||
|
|
||||||
|
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
|
||||||
|
rozjechać się między dwa odczyty.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
if len(buffer) < LENGTH_PREFIX:
|
||||||
|
return
|
||||||
|
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
if len(buffer) < LENGTH_PREFIX + size:
|
||||||
|
return
|
||||||
|
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
|
||||||
|
del buffer[:LENGTH_PREFIX + size]
|
||||||
|
yield frame
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- świeżość ruchu
|
||||||
|
|
||||||
|
def stamp_now() -> str:
|
||||||
|
return f"{time.time():.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def check_stamp(stamp: str) -> None:
|
||||||
|
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
|
||||||
|
odtworzyć w dowolnym momencie w przyszłości."""
|
||||||
|
try:
|
||||||
|
sent = float(stamp)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise LinkError("brak albo błędny znacznik czasu") from exc
|
||||||
|
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
|
||||||
|
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================== strona serwerowa
|
||||||
|
|
||||||
|
class LinkCryptoMiddleware:
|
||||||
|
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
|
||||||
|
|
||||||
|
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
|
||||||
|
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
|
||||||
|
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, link: Link | None, layer: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.link = link
|
||||||
|
self.layer = layer
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
path = scope["path"]
|
||||||
|
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
|
||||||
|
|
||||||
|
if headers.get(HEADER_ENC.lower()) != VERSION:
|
||||||
|
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
|
||||||
|
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
|
||||||
|
# chcemy po cichu wrócić do jawnego ruchu.
|
||||||
|
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
|
||||||
|
self.layer, path)
|
||||||
|
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
|
||||||
|
|
||||||
|
stamp = headers.get(HEADER_TS.lower(), "")
|
||||||
|
try:
|
||||||
|
check_stamp(stamp)
|
||||||
|
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
|
||||||
|
except LinkError as exc:
|
||||||
|
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
|
||||||
|
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
|
||||||
|
|
||||||
|
scope = dict(scope)
|
||||||
|
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
|
||||||
|
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
|
||||||
|
|
||||||
|
def _sealing_send(self, send, path: str):
|
||||||
|
state: dict = {"stamp": "", "seq": 0}
|
||||||
|
|
||||||
|
async def sealing(message):
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
state["stamp"] = stamp_now()
|
||||||
|
keep = [(k, v) for k, v in message.get("headers", [])
|
||||||
|
if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
message = dict(message)
|
||||||
|
message["headers"] = keep + [
|
||||||
|
(b"content-type", CONTENT_TYPE.encode()),
|
||||||
|
(HEADER_ENC.lower().encode(), VERSION.encode()),
|
||||||
|
(HEADER_TS.lower().encode(), state["stamp"].encode()),
|
||||||
|
]
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
if message["type"] == "http.response.body":
|
||||||
|
chunk = message.get("body", b"")
|
||||||
|
sealed = b""
|
||||||
|
if chunk:
|
||||||
|
sealed = frame_out(self.link.seal(
|
||||||
|
RESPONSE, path, state["stamp"], state["seq"], chunk))
|
||||||
|
state["seq"] += 1
|
||||||
|
return await send({"type": "http.response.body", "body": sealed,
|
||||||
|
"more_body": message.get("more_body", False)})
|
||||||
|
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
return sealing
|
||||||
|
|
||||||
|
|
||||||
|
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
|
||||||
|
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
|
||||||
|
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
|
||||||
|
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
kept.append((b"content-length", str(length).encode()))
|
||||||
|
if length:
|
||||||
|
kept.append((b"content-type", b"application/json"))
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_body(receive) -> bytes:
|
||||||
|
body = bytearray()
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.disconnect":
|
||||||
|
raise LinkError("rozłączenie w trakcie odbioru żądania")
|
||||||
|
body += message.get("body", b"")
|
||||||
|
if not message.get("more_body", False):
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _replay(body: bytes, original):
|
||||||
|
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
|
||||||
|
|
||||||
|
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
|
||||||
|
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
|
||||||
|
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
|
||||||
|
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
|
||||||
|
"""
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
if delivered:
|
||||||
|
return await original()
|
||||||
|
delivered = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return receive
|
||||||
|
|
||||||
|
|
||||||
|
async def _refuse(send, detail: str) -> None:
|
||||||
|
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
|
||||||
|
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
|
||||||
|
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
|
||||||
|
await send({"type": "http.response.start", "status": 400, "headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"content-length", str(len(payload)).encode()),
|
||||||
|
]})
|
||||||
|
await send({"type": "http.response.body", "body": payload})
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, env_name: str, layer: str):
|
||||||
|
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
|
||||||
|
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
|
||||||
|
link_key = key_from_env(env_name)
|
||||||
|
if link_key is None and encryption_required():
|
||||||
|
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
|
||||||
|
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
|
||||||
|
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
|
||||||
|
f"warstwa {layer} nie wystartuje bez klucza łącza"
|
||||||
|
)
|
||||||
|
if link_key is None:
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
|
||||||
|
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
|
||||||
|
"ruch wewnątrz sieci.", env_name, layer,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
link = Link(link_key)
|
||||||
|
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
|
||||||
|
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ strona kliencka
|
||||||
|
|
||||||
|
def call(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
|
||||||
|
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
|
||||||
|
|
||||||
|
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
|
||||||
|
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
|
||||||
|
się niejasnym błędem odszyfrowania.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
|
||||||
|
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
|
||||||
|
# sygnifikatory, o które pytamy bazę.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
|
||||||
|
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
response = client.request(method, url, json=payload, headers=request_headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
path = httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
|
||||||
|
"Content-Type": CONTENT_TYPE})
|
||||||
|
|
||||||
|
response = client.request(method, url, content=body, headers=request_headers)
|
||||||
|
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
log.error("łącze %s odmówiło: %s", path, response.text[:200])
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
|
||||||
|
reply_stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(reply_stamp)
|
||||||
|
return link.open_all(RESPONSE, path, reply_stamp, response.content)
|
||||||
|
|
||||||
|
|
||||||
|
def call_json(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None):
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
return _json.loads(call(client, method, url, payload=payload,
|
||||||
|
headers=headers, link=link))
|
||||||
|
|
||||||
|
|
||||||
|
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
|
||||||
|
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
|
||||||
|
|
||||||
|
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
|
||||||
|
w buforze zamiast zakładać, że każdy kawałek to komplet.
|
||||||
|
"""
|
||||||
|
if link is None:
|
||||||
|
yield from response.iter_bytes()
|
||||||
|
return
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
|
||||||
|
stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(stamp)
|
||||||
|
path = response.request.url.path
|
||||||
|
buffer = bytearray()
|
||||||
|
seq = 0
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
buffer += chunk
|
||||||
|
for frame in unframe_incremental(buffer):
|
||||||
|
yield link.open(RESPONSE, path, stamp, seq, frame)
|
||||||
|
seq += 1
|
||||||
|
if buffer:
|
||||||
|
raise LinkError("strumień urwał się w połowie ramki")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
|
||||||
|
link: Link | None) -> Iterator[str]:
|
||||||
|
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
|
||||||
|
|
||||||
|
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
|
||||||
|
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
|
||||||
|
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
|
||||||
|
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
|
||||||
|
dopiero na znakach nowej linii.
|
||||||
|
|
||||||
|
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
|
||||||
|
działał bez zmian.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx as _httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
|
||||||
|
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
|
||||||
|
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
|
||||||
|
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
with client.stream("POST", url, json=payload, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
for text_line in response.iter_lines():
|
||||||
|
if text_line:
|
||||||
|
yield text_line
|
||||||
|
return
|
||||||
|
|
||||||
|
path = _httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
|
||||||
|
with client.stream("POST", url, content=body, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
buffer = bytearray()
|
||||||
|
for plain in open_response_stream(response, link):
|
||||||
|
buffer += plain
|
||||||
|
while True:
|
||||||
|
nl = buffer.find(b"\n")
|
||||||
|
if nl < 0:
|
||||||
|
break
|
||||||
|
text_line = bytes(buffer[:nl])
|
||||||
|
del buffer[:nl + 1]
|
||||||
|
if text_line:
|
||||||
|
yield text_line.decode("utf-8")
|
||||||
|
if buffer:
|
||||||
|
yield bytes(buffer).decode("utf-8")
|
||||||
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app import link_crypto, 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
|
||||||
@@ -24,6 +25,10 @@ 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)
|
||||||
|
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
|
||||||
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
|
link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/search", response_model=SearchResult)
|
@app.post("/search", response_model=SearchResult)
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ 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).")
|
||||||
limit: int = Field(50, ge=1, le=50000)
|
# Górny limit celowo niski: to zapytanie oddaje SUROWE wiersze baz, więc wysoki
|
||||||
|
# pułap zamienia je w narzędzie do masowego pobrania (LOG-32). 5000 = tyle, ile
|
||||||
|
# realnie potrzebuje build_report na jeden obiekt.
|
||||||
|
limit: int = Field(50, ge=1, le=5000)
|
||||||
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."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""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)
|
||||||
@@ -7,3 +7,5 @@ openpyxl>=3.1
|
|||||||
pyarrow>=18.0
|
pyarrow>=18.0
|
||||||
SQLAlchemy>=2.0
|
SQLAlchemy>=2.0
|
||||||
pydantic>=2.10
|
pydantic>=2.10
|
||||||
|
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||||
|
cryptography>=44.0
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
|
# Build wieloetapowy — bo `pyswisseph` to rozszerzenie C bez gotowych wheeli.
|
||||||
|
#
|
||||||
|
# Na PyPI (2.10.3.2) wheels kończą się na cp311 i obejmują wyłącznie i686/x86_64.
|
||||||
|
# Dla Pythona 3.12 oraz dla arm64 pip ZAWSZE kompiluje ze źródeł, a `-slim` nie ma
|
||||||
|
# kompilatora — dlatego jednoetapowy build tu padał. Kompilujemy w etapie builder,
|
||||||
|
# a do obrazu finalnego wchodzi już tylko gotowy wheel (bez toolchaina).
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY --from=builder /wheels /wheels
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
|
||||||
|
&& rm -rf /wheels
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Sanity check na etapie budowania: brak działającego swissepha ma wywalić build,
|
||||||
|
# a nie dopiero pierwszy request.
|
||||||
|
RUN python -c "import swisseph as swe; swe.set_ephe_path(None); print('swisseph OK', swe.version)"
|
||||||
|
|
||||||
EXPOSE 8003
|
EXPOSE 8003
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
|
||||||
|
|||||||
@@ -15,9 +15,23 @@ permisywnej reszty systemu. **Nie wchodzi do dystrybucji zamkniętego produktu.*
|
|||||||
|
|
||||||
Tryb Moshiera (`FLG_MOSEPH`) — bez plików efemeryd, zero konfiguracji.
|
Tryb Moshiera (`FLG_MOSEPH`) — bez plików efemeryd, zero konfiguracji.
|
||||||
|
|
||||||
|
## Build obrazu
|
||||||
|
```bash
|
||||||
|
docker compose --profile comparison build engine-swisseph
|
||||||
|
```
|
||||||
|
Dockerfile jest **wieloetapowy** i to nie jest ozdobnik: `pyswisseph` to rozszerzenie
|
||||||
|
C, a na PyPI (2.10.3.2) gotowe wheels kończą się na **cp311** i obejmują wyłącznie
|
||||||
|
**i686/x86_64**. Dla Pythona 3.12 oraz dla arm64 pip musi kompilować ze źródeł, więc
|
||||||
|
sam `python:3.12-slim` (bez kompilatora) build wywracał. Kompilacja idzie w etapie
|
||||||
|
`builder` (`build-essential`), a do obrazu finalnego trafia już tylko gotowy wheel —
|
||||||
|
runtime zostaje czysty i mały. Pierwszy build trwa ~1–2 min, kolejne idą z cache warstw.
|
||||||
|
|
||||||
|
Build kończy się sanity-checkiem (`import swisseph`), żeby niedziałający silnik
|
||||||
|
wykrzaczył build, a nie dopiero pierwszy request.
|
||||||
|
|
||||||
## Uruchomienie (tylko profil porównawczy / dev / CI)
|
## Uruchomienie (tylko profil porównawczy / dev / CI)
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt # wymaga kompilatora C (patrz wyżej)
|
||||||
uvicorn app.main:app --port 8003
|
uvicorn app.main:app --port 8003
|
||||||
```
|
```
|
||||||
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
|
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ i nie w bazie.
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
||||||
- `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
|
- `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Zwraca też sektę i 7 Lots hermetycznych z domami (LOG-08). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
|
||||||
- `POST /chart/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów)
|
- `POST /chart/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów)
|
||||||
- `POST /chart/profections` → `{when_utc, lat, lon, start_age?, count?}` → profekcje roczne: wiek, profektowany Asc, Władca Roku (+MC/Su/Mo) (LOG-10)
|
- `POST /chart/profections` → `{when_utc, lat, lon, start_age?, count?}` → profekcje roczne: wiek, profektowany Asc, Władca Roku (+MC/Su/Mo) (LOG-10)
|
||||||
- `POST /chart/return` → `{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12)
|
- `POST /chart/return` → `{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12)
|
||||||
- `POST /chart/timeline` → `{when_utc, lat, lon, from_date, to_date, techniques?}` → zbiorcza oś czasu: profekcje + Solar Return + dyrekcje solar-arc, posortowane (technique | significator | start | exact | end) (LOG-14)
|
- `POST /chart/firdaria` → `{when_utc, lat, lon}` → Firdaria: sekta (dzień/noc), okresy główne i podokresy time-lordów (LOG-11)
|
||||||
|
- `POST /chart/timeline` → `{when_utc, lat, lon, from_date, to_date, techniques?}` → zbiorcza oś czasu: profekcje + Solar Return + dyrekcje solar-arc + Firdaria, posortowane (technique | significator | start | exact | end); z interpret=true dopina interpretacje z bazy do dat (LOG-14, 1B->2B)
|
||||||
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
|
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
|
||||||
- `GET /health` (sprawdza też warstwę bazodanową)
|
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ ABBREVIATIONS: dict[str, str] = {
|
|||||||
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||||
# osie
|
# osie
|
||||||
"Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli",
|
"Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli",
|
||||||
|
# Lots (punkty arabskie)
|
||||||
|
"PF": "Part of Fortune", "Fortune": "Part of Fortune", "Spirit": "Lot of Spirit",
|
||||||
# aspekty
|
# aspekty
|
||||||
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
|
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
|
||||||
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
|
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
|
||||||
|
|||||||
@@ -5,13 +5,28 @@ 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
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
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 {}
|
||||||
|
|
||||||
|
|
||||||
|
def _link() -> link_crypto.Link | None:
|
||||||
|
"""Klucz łącza logika↔dane. Czytany przy każdym wywołaniu, bo konfiguracja
|
||||||
|
może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
|
||||||
|
key = link_crypto.key_from_env(link_crypto.ENV_LOGIC_DATA)
|
||||||
|
return link_crypto.Link(key) if key else None
|
||||||
|
|
||||||
|
|
||||||
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("/")
|
||||||
@@ -26,12 +41,14 @@ 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)
|
return link_crypto.call_json(client, "POST", f"{self.base_url}/search",
|
||||||
r.raise_for_status()
|
payload=payload, headers=_auth_headers(),
|
||||||
return r.json()
|
link=_link())
|
||||||
|
|
||||||
def health(self) -> dict[str, Any]:
|
def health(self) -> dict[str, Any]:
|
||||||
|
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
||||||
|
# nie mają, a nie przechodzi tędy nic z baz.
|
||||||
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")
|
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ 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]."""
|
||||||
@@ -56,12 +68,16 @@ 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
|
||||||
|
|||||||
@@ -7,27 +7,84 @@ pozycje.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.engine import houses as H
|
from app.engine import houses as H
|
||||||
|
from app.engine import zodiac as Z
|
||||||
from app.engine.base import EphemerisEngine
|
from app.engine.base import EphemerisEngine
|
||||||
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
|
from app.engine.formats import SIGNS, absolute, decimal, in_sign, norm360, sign_index
|
||||||
from app.engine.models import ChartMoment
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
|
||||||
def _fmt(name: str, lon: float) -> dict:
|
def _fmt(name: str, lon: float, off: float = 0.0) -> dict:
|
||||||
|
lon = norm360(lon - off)
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
"sign": SIGNS[sign_index(lon)],
|
"sign": SIGNS[sign_index(lon)],
|
||||||
"in_sign": in_sign(lon),
|
"in_sign": in_sign(lon),
|
||||||
"decimal": round(norm360(lon), 6),
|
"decimal": round(lon, 6),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> dict:
|
def _shift_pos(pdict: dict, off: float) -> None:
|
||||||
|
"""Przelicza etykiety pozycji na wybrany zodiak (in-place). off=0 → bez zmian."""
|
||||||
|
if not off:
|
||||||
|
return
|
||||||
|
lon = norm360(pdict["decimal"] - off)
|
||||||
|
pdict["sign"] = SIGNS[sign_index(lon)]
|
||||||
|
pdict["in_sign"] = in_sign(lon)
|
||||||
|
pdict["absolute"] = absolute(lon)
|
||||||
|
pdict["decimal"] = decimal(lon)
|
||||||
|
|
||||||
|
|
||||||
|
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||||
|
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
||||||
|
from app.engine import glyphs as GL
|
||||||
from app.engine.aspects import find_aspects
|
from app.engine.aspects import find_aspects
|
||||||
|
from app.engine.houses import mean_obliquity
|
||||||
|
from app.engine.out_of_zodiac import (
|
||||||
|
declination,
|
||||||
|
find_antiscia,
|
||||||
|
find_declination_aspects,
|
||||||
|
is_out_of_bounds,
|
||||||
|
)
|
||||||
|
|
||||||
positions = engine.positions(moment)
|
positions = engine.positions(moment)
|
||||||
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
||||||
|
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
|
||||||
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
||||||
|
|
||||||
|
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
|
||||||
|
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
|
||||||
|
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
|
||||||
|
# z surowych długości/szerokości silnika.
|
||||||
|
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
||||||
|
result["obliquity"] = round(eps, 6)
|
||||||
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
|
dec = declination(obj.longitude, obj.latitude, eps)
|
||||||
|
pdict["declination"] = round(dec, 4)
|
||||||
|
if is_out_of_bounds(dec, eps):
|
||||||
|
pdict["out_of_bounds"] = True
|
||||||
|
result["parallels"] = find_declination_aspects(result["positions"])
|
||||||
|
result["antiscia"] = find_antiscia(result["positions"])
|
||||||
|
|
||||||
|
# glify aspektów (LOG-22) — symbol aspektu nie zależy od zodiaku
|
||||||
|
for a in result["aspects"]:
|
||||||
|
a["glyph"] = GL.aspect_glyph(a["aspect"])
|
||||||
|
|
||||||
|
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
||||||
|
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
||||||
|
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
||||||
|
result["zodiac"] = zodiac
|
||||||
|
if zodiac in Z.SIDEREAL:
|
||||||
|
result["ayanamsha"] = round(off, 6)
|
||||||
|
for pdict in result["positions"]:
|
||||||
|
_shift_pos(pdict, off)
|
||||||
|
|
||||||
|
# glify obiektów (LOG-22): symbol planety jest niezmienniczy, symbol ZNAKU
|
||||||
|
# zależy od zodiaku, więc po przesunięciu. Pierścień 12 znaków pod kosmogram.
|
||||||
|
for pdict in result["positions"]:
|
||||||
|
pdict["glyph"] = GL.glyph_for(pdict["name"])
|
||||||
|
pdict["sign_glyph"] = GL.sign_glyph(pdict["sign"])
|
||||||
|
result["sign_glyphs"] = [{"sign": s, "glyph": GL.sign_glyph(s)} for s in SIGNS]
|
||||||
|
|
||||||
if not hasattr(engine, "sidereal"):
|
if not hasattr(engine, "sidereal"):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -35,19 +92,42 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
asc = H.compute_asc(ramc, eps, moment.lat)
|
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||||
mc = H.compute_mc(ramc, eps)
|
mc = H.compute_mc(ramc, eps)
|
||||||
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||||
cusp_list = H.cusps(asc, mc, system)
|
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
|
||||||
|
|
||||||
result["house_system"] = system
|
result["house_system"] = system
|
||||||
result["angles"] = {
|
result["angles"] = {
|
||||||
"Asc": _fmt("Asc", asc),
|
"Asc": _fmt("Asc", asc, off),
|
||||||
"MC": _fmt("MC", mc),
|
"MC": _fmt("MC", mc, off),
|
||||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0)),
|
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
||||||
"IC": _fmt("IC", norm360(mc + 180.0)),
|
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
||||||
}
|
}
|
||||||
|
for a in result["angles"].values(): # glif znaku osi (LOG-22)
|
||||||
|
a["sign_glyph"] = GL.sign_glyph(a["sign"])
|
||||||
result["cusps"] = [
|
result["cusps"] = [
|
||||||
{"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)}
|
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||||
|
"in_sign": in_sign(norm360(c - off)),
|
||||||
|
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
|
||||||
for i, c in enumerate(cusp_list)
|
for i, c in enumerate(cusp_list)
|
||||||
]
|
]
|
||||||
for pdict, obj in zip(result["positions"], positions):
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
pdict["house"] = H.assign_house(obj.longitude, cusp_list)
|
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
|
||||||
|
|
||||||
|
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
|
||||||
|
from app.engine.firdaria import is_day_birth
|
||||||
|
from app.engine.lots import compute_lots
|
||||||
|
|
||||||
|
pts = {p.name: p.longitude for p in positions}
|
||||||
|
pts["Asc"] = asc
|
||||||
|
day = is_day_birth(pts["Sun"], asc, mc) if "Sun" in pts else True
|
||||||
|
result["sect"] = "day" if day else "night"
|
||||||
|
result["lots"] = [
|
||||||
|
{**lot,
|
||||||
|
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
||||||
|
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
||||||
|
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
||||||
|
"house": H.assign_house(lot["longitude"], cusp_list), # dom po długości tropikalnej
|
||||||
|
"glyph": GL.glyph_for(lot["name"]), # ⊗ dla Fortuny; reszta None (LOG-22)
|
||||||
|
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(lot["longitude"] - off))])}
|
||||||
|
for lot in compute_lots(pts, day, lots_method)
|
||||||
|
]
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Firdaria (LOG-11) — perska technika time-lord.
|
||||||
|
|
||||||
|
Sekwencja okresów głównych zależy od sekty (dzień/noc). Sekta: urodzenie dzienne,
|
||||||
|
gdy Słońce jest nad horyzontem, czyli po tej samej stronie osi Asc–Dsc co MC.
|
||||||
|
|
||||||
|
Klasyczne długości okresów (lata): Su 10, Ve 8, Me 13, Mo 9, Sa 11, Ju 12, Ma 7
|
||||||
|
(razem 70) + Węzeł Północny 3 + Węzeł Południowy 2 = 75 lat. Każdy okres główny
|
||||||
|
planety dzieli się na 7 podokresów (sub-lord w tej samej kolejności, cyklicznie).
|
||||||
|
Węzły — bez podokresów (najczęstsza konwencja).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
DAY_ORDER = ["Sun", "Venus", "Mercury", "Moon", "Saturn", "Jupiter", "Mars"]
|
||||||
|
NIGHT_ORDER = ["Moon", "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury"]
|
||||||
|
YEARS = {"Sun": 10, "Venus": 8, "Mercury": 13, "Moon": 9,
|
||||||
|
"Saturn": 11, "Jupiter": 12, "Mars": 7}
|
||||||
|
NODES = [("North Node", 3), ("South Node", 2)]
|
||||||
|
DAYS_PER_YEAR = 365.2422
|
||||||
|
|
||||||
|
|
||||||
|
def is_day_birth(sun_lon: float, asc: float, mc: float) -> bool:
|
||||||
|
"""Słońce nad horyzontem = ta sama półkula osi Asc–Dsc co MC."""
|
||||||
|
return (((sun_lon - asc) % 360.0) < 180.0) == (((mc - asc) % 360.0) < 180.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _date(birth: datetime, years: float) -> str:
|
||||||
|
return (birth + timedelta(days=years * DAYS_PER_YEAR)).date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def firdaria(birth: datetime, sun_lon: float, asc: float, mc: float) -> dict:
|
||||||
|
"""Pełny rozkład Firdarii: sekta, kolejność, okresy główne i podokresy."""
|
||||||
|
day = is_day_birth(sun_lon, asc, mc)
|
||||||
|
order = DAY_ORDER if day else NIGHT_ORDER
|
||||||
|
majors = [(lord, YEARS[lord]) for lord in order] + NODES
|
||||||
|
|
||||||
|
periods: list[dict] = []
|
||||||
|
age = 0.0
|
||||||
|
for lord, yrs in majors:
|
||||||
|
period = {"lord": lord, "years": yrs,
|
||||||
|
"start": _date(birth, age), "end": _date(birth, age + yrs)}
|
||||||
|
if lord in YEARS: # planeta -> 7 podokresów
|
||||||
|
sub_len = yrs / 7.0
|
||||||
|
i = order.index(lord)
|
||||||
|
sub_age = age
|
||||||
|
subs: list[dict] = []
|
||||||
|
for k in range(7):
|
||||||
|
sub_lord = order[(i + k) % 7]
|
||||||
|
subs.append({"lord": sub_lord,
|
||||||
|
"start": _date(birth, sub_age),
|
||||||
|
"end": _date(birth, sub_age + sub_len)})
|
||||||
|
sub_age += sub_len
|
||||||
|
period["sub"] = subs
|
||||||
|
periods.append(period)
|
||||||
|
age += yrs
|
||||||
|
return {"sect": "day" if day else "night", "order": order, "periods": periods}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
|
||||||
|
|
||||||
|
Litery i słowa (Sa Pis 26°08' conj Fortune) ↔ glify (♄ ♓ 26°08' ☌ ⊗). Potrzebne
|
||||||
|
pod rysowanie kosmogramu (PRE-12) — planety i znaki na kole rysujemy symbolami.
|
||||||
|
|
||||||
|
DWIE zasady z bazy wymagań, obie krytyczne:
|
||||||
|
* DAN-18: TYLKO tekstowy Unicode, NIGDY emoji. Część znaków (zodiak, ♀, ♂) ma
|
||||||
|
domyślnie prezentację emoji — kolorowy kwadrat zamiast czarno-białego glifu,
|
||||||
|
nieczytelny i niesterowalny przez CSS. Wymuszamy prezentację tekstową
|
||||||
|
selektorem wariantu U+FE0E (NIE U+FE0F, który robi odwrotnie).
|
||||||
|
* DAN-17: łańcuch musi znosić dowolny Unicode. Reverse (symbol→tekst)
|
||||||
|
normalizuje wejście, zdejmując selektory wariantu, żeby glif z FE0E i bez
|
||||||
|
dawał ten sam wynik.
|
||||||
|
|
||||||
|
Glify zapisane przez \\u — jednoznaczne code-pointy, odporne na zniekształcenia
|
||||||
|
edytorów i samodokumentujące (widać, który to znak Unicode).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# selektory wariantu prezentacji
|
||||||
|
_TEXT = "︎" # VS15 — wymusza glif tekstowy (czarno-biały)
|
||||||
|
_EMOJI = "️" # VS16 — prezentacja emoji; NIGDY nie emitujemy, zdejmujemy przy reverse
|
||||||
|
|
||||||
|
|
||||||
|
def _t(cp: str) -> str:
|
||||||
|
"""Znak z wymuszoną prezentacją tekstową (dokleja VS15)."""
|
||||||
|
return cp + _TEXT
|
||||||
|
|
||||||
|
|
||||||
|
# ── planety i światła ────────────────────────────────────────────────────
|
||||||
|
# ♀ i ♂ mają wariant emoji → wymuszamy tekst; reszta jest tekstowa domyślnie.
|
||||||
|
PLANET = {
|
||||||
|
"Sun": "☉", # ☉
|
||||||
|
"Moon": "☽", # ☽
|
||||||
|
"Mercury": "☿", # ☿
|
||||||
|
"Venus": _t("♀"), # ♀︎
|
||||||
|
"Mars": _t("♂"), # ♂︎
|
||||||
|
"Jupiter": "♃", # ♃
|
||||||
|
"Saturn": "♄", # ♄
|
||||||
|
"Uranus": "♅", # ♅
|
||||||
|
"Neptune": "♆", # ♆
|
||||||
|
"Pluto": "♇", # ♇
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── punkty wirtualne ─────────────────────────────────────────────────────
|
||||||
|
POINT = {
|
||||||
|
"North Node": "☊", # ☊
|
||||||
|
"South Node": "☋", # ☋
|
||||||
|
"Lilith": "⚸", # ⚸ (Black Moon Lilith)
|
||||||
|
"Chiron": "⚷", # ⚷
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Lots (punkty arabskie) ───────────────────────────────────────────────
|
||||||
|
# Standardowy glif ma tylko Fortuna (⊗). Reszta Lotów nie ma powszechnie
|
||||||
|
# wspieranego symbolu → zwracamy None i UI pokazuje nazwę.
|
||||||
|
LOT = {
|
||||||
|
"Fortune": "⊗", # ⊗
|
||||||
|
"Part of Fortune": "⊗",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── znaki zodiaku (wszystkie mają wariant emoji → wszystkie z VS15) ──────
|
||||||
|
_SIGN_CP = {
|
||||||
|
"Aries": "♈", "Taurus": "♉", "Gemini": "♊", "Cancer": "♋",
|
||||||
|
"Leo": "♌", "Virgo": "♍", "Libra": "♎", "Scorpio": "♏",
|
||||||
|
"Sagittarius": "♐", "Capricorn": "♑", "Aquarius": "♒", "Pisces": "♓",
|
||||||
|
}
|
||||||
|
SIGN = {name: _t(cp) for name, cp in _SIGN_CP.items()}
|
||||||
|
|
||||||
|
# ── aspekty (nazwy jak w aspects.py + minor z abbreviations) ─────────────
|
||||||
|
ASPECT = {
|
||||||
|
"conjunction": "☌", # ☌
|
||||||
|
"opposition": "☍", # ☍
|
||||||
|
"trine": "△", # △
|
||||||
|
"square": "□", # □
|
||||||
|
"sextile": "⚹", # ⚹
|
||||||
|
"semisextile": "⚺", # ⚺
|
||||||
|
"quincunx": "⚻", # ⚻
|
||||||
|
"semisquare": "∠", # ∠
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── ruch ─────────────────────────────────────────────────────────────────
|
||||||
|
RETROGRADE = "℞" # ℞
|
||||||
|
DIRECT = "D" # zwykłe „D" — brak dedykowanego glifu prostego ruchu
|
||||||
|
|
||||||
|
|
||||||
|
# ── forward: nazwa → glif ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def glyph_for(name: str) -> str | None:
|
||||||
|
"""Glif obiektu/punktu/Lota po nazwie (jak w silniku). None, gdy brak."""
|
||||||
|
return PLANET.get(name) or POINT.get(name) or LOT.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_glyph(sign: str) -> str | None:
|
||||||
|
return SIGN.get(sign)
|
||||||
|
|
||||||
|
|
||||||
|
def aspect_glyph(aspect: str) -> str | None:
|
||||||
|
return ASPECT.get(aspect)
|
||||||
|
|
||||||
|
|
||||||
|
# ── reverse: glif → nazwa ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _strip_variants(g: str) -> str:
|
||||||
|
"""Zdejmuje selektory wariantu — glif z FE0E i bez daje ten sam klucz."""
|
||||||
|
return g.replace(_TEXT, "").replace(_EMOJI, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _reverse(mapping: dict[str, str]) -> dict[str, str]:
|
||||||
|
# pierwsze wystąpienie wygrywa (Fortune vs Part of Fortune → 'Fortune')
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for name, g in mapping.items():
|
||||||
|
out.setdefault(_strip_variants(g), name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_PLANET_POINT_REV = _reverse({**PLANET, **POINT, **{"Fortune": LOT["Fortune"]}})
|
||||||
|
_SIGN_REV = _reverse(SIGN)
|
||||||
|
_ASPECT_REV = _reverse(ASPECT)
|
||||||
|
|
||||||
|
|
||||||
|
def name_for_glyph(g: str) -> str | None:
|
||||||
|
"""Obiekt/punkt po glifie (znosi obecność lub brak selektora wariantu)."""
|
||||||
|
return _PLANET_POINT_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
def sign_for_glyph(g: str) -> str | None:
|
||||||
|
return _SIGN_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
def aspect_for_glyph(g: str) -> str | None:
|
||||||
|
return _ASPECT_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
# ── glifikacja tekstu sygnifikatora (składnia [XX z bazy) ────────────────
|
||||||
|
# Nasze sygnifikatory zapisane są tokenami z prefiksem [ (np. „[Sa [conj [PF").
|
||||||
|
# glyphify zamienia znane tokeny na glify, resztę zostawia. To druga strona
|
||||||
|
# abbreviations.expand: tam token → słowo, tu token → symbol.
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
# token po nawiasie (np. „Su", „Tau", „conj", „PF") → glif
|
||||||
|
_TOKEN_GLYPH: dict[str, str] = {}
|
||||||
|
_ABBR_TO_NAME = {
|
||||||
|
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus", "Ma": "Mars",
|
||||||
|
"Ju": "Jupiter", "Sa": "Saturn", "Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
|
||||||
|
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||||
|
"PF": "Fortune", "Fortune": "Fortune",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _ABBR_TO_NAME.items():
|
||||||
|
_g = glyph_for(_name)
|
||||||
|
if _g:
|
||||||
|
_TOKEN_GLYPH[_abbr] = _g
|
||||||
|
_SIGN_ABBR_NAME = {
|
||||||
|
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer", "Leo": "Leo",
|
||||||
|
"Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio", "Sag": "Sagittarius",
|
||||||
|
"Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _SIGN_ABBR_NAME.items():
|
||||||
|
_TOKEN_GLYPH[_abbr] = SIGN[_name]
|
||||||
|
_ASP_ABBR_NAME = {
|
||||||
|
"conj": "conjunction", "opp": "opposition", "tri": "trine", "sq": "square",
|
||||||
|
"sex": "sextile", "semisex": "semisextile", "quincunx": "quincunx", "semisq": "semisquare",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _ASP_ABBR_NAME.items():
|
||||||
|
if _name in ASPECT:
|
||||||
|
_TOKEN_GLYPH[_abbr] = ASPECT[_name]
|
||||||
|
|
||||||
|
_TOKEN_RE = _re.compile(r"\[([A-Za-z]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def glyphify(text: str) -> str:
|
||||||
|
"""Zamienia tokeny [XX na glify; nieznane zostawia bez zmiany. Dodatkowo
|
||||||
|
»Rx«/»R« → ℞. Nie parsuje stopni — te i tak są czytelne (26°08')."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
out = _TOKEN_RE.sub(lambda m: _TOKEN_GLYPH.get(m.group(1), m.group(0)), text)
|
||||||
|
out = _re.sub(r"\bR[x]?\b", RETROGRADE, out)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Lots / punkty arabskie (LOG-08) — 7 Lots hermetycznych.
|
||||||
|
|
||||||
|
Formuła: Lot = C + A − B (od punktu C odmierzamy odległość między A i B).
|
||||||
|
Większość Lots **odwraca się w horoskopach nocnych** (zamiana A↔B) — np.
|
||||||
|
Fortuna: dzień Asc + Mo − Su, noc Asc + Su − Mo.
|
||||||
|
|
||||||
|
Dwa warianty liczenia (notes3):
|
||||||
|
- `degree` (domyślny) — dokładny stopień,
|
||||||
|
- `sign` — liczone całymi znakami (Lot wypada na 0° wyliczonego znaku).
|
||||||
|
|
||||||
|
Kolejność ma znaczenie: Fortuna i Duch liczone są pierwsze, bo pozostałe Lots
|
||||||
|
odwołują się do nich.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine.formats import norm360, sign_index
|
||||||
|
|
||||||
|
# (nazwa, C, A, B, odwracalny w nocy)
|
||||||
|
LOT_DEFS: list[tuple[str, str, str, str, bool]] = [
|
||||||
|
("Fortune", "Asc", "Moon", "Sun", True),
|
||||||
|
("Spirit", "Asc", "Sun", "Moon", True),
|
||||||
|
("Eros", "Asc", "Venus", "Spirit", True),
|
||||||
|
("Necessity", "Asc", "Fortune", "Mercury", True),
|
||||||
|
("Courage", "Asc", "Fortune", "Mars", True),
|
||||||
|
("Victory", "Asc", "Jupiter", "Spirit", True),
|
||||||
|
("Nemesis", "Asc", "Fortune", "Saturn", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
METHODS = ("degree", "sign")
|
||||||
|
|
||||||
|
|
||||||
|
def compute_lots(
|
||||||
|
points: dict[str, float], is_day: bool, method: str = "degree"
|
||||||
|
) -> list[dict]:
|
||||||
|
"""points: nazwa → długość natalna (wymagane Asc + planety formuł).
|
||||||
|
|
||||||
|
Zwraca listę {name, longitude, formula} w kolejności definicji.
|
||||||
|
"""
|
||||||
|
if method not in METHODS:
|
||||||
|
raise ValueError(f"nieznana metoda liczenia Lots: {method}")
|
||||||
|
|
||||||
|
vals = dict(points)
|
||||||
|
out: list[dict] = []
|
||||||
|
for name, c, a, b, reversible in LOT_DEFS:
|
||||||
|
first, second = (a, b) if (is_day or not reversible) else (b, a)
|
||||||
|
if any(k not in vals for k in (c, first, second)):
|
||||||
|
continue # brak składnika — pomijamy
|
||||||
|
if method == "sign":
|
||||||
|
idx = (sign_index(vals[c]) + sign_index(vals[first])
|
||||||
|
- sign_index(vals[second])) % 12
|
||||||
|
lon = idx * 30.0
|
||||||
|
else:
|
||||||
|
lon = norm360(vals[c] + vals[first] - vals[second])
|
||||||
|
vals[name] = lon # dostępny dla kolejnych Lots
|
||||||
|
out.append({
|
||||||
|
"name": name,
|
||||||
|
"longitude": lon,
|
||||||
|
"formula": f"{c} + {first} − {second}",
|
||||||
|
})
|
||||||
|
return out
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja.
|
||||||
|
|
||||||
|
Aspekty głowne (LOG-06) mierzą kąt wzdłuż EKLIPTYKI. Ale dwa ciała mogą być
|
||||||
|
powiązane też inaczej — a te powiązania klasyczna astrologia liczy naprawdę,
|
||||||
|
nie na oko:
|
||||||
|
|
||||||
|
* **Paralela / kontrparalela deklinacji.** Deklinacja to „szerokość" na równiku
|
||||||
|
niebieskim — jak daleko na północ/południe od równika stoi ciało. Dwa ciała na
|
||||||
|
tej samej deklinacji (parallel) działają jak koniunkcja, na przeciwnej
|
||||||
|
(kontrparalela) — jak opozycja, mimo że wzdłuż ekliptyki mogą być gdziekolwiek.
|
||||||
|
Baza interpretacyjna zna to zjawisko pod skrótem „P. Dec.".
|
||||||
|
|
||||||
|
* **Antyscja / kontrantyscja.** Odbicie punktu względem osi przesileń
|
||||||
|
(0° Raka – 0° Koziorożca). Dwa punkty w antyscji są równo odległe od tej osi —
|
||||||
|
„dzielą" tę samą długość dnia. Kontrantyscja to odbicie względem osi
|
||||||
|
równonocy (0° Barana – 0° Wagi).
|
||||||
|
|
||||||
|
Wszystko liczymy na współrzędnych TROPIKALNYCH of-date, bo:
|
||||||
|
- deklinacja jest wielkością fizyczną (równikową), niezależną od wyboru zodiaku;
|
||||||
|
- antyscja jest z definicji tropikalna — jej oś to punkty przesileń, czyli
|
||||||
|
kardynalne punkty zodiaku tropikalnego.
|
||||||
|
Dlatego moduł bierze surowe długości/szerokości z silnika, a nie etykiety po
|
||||||
|
przesunięciu na zodiak syderyczny/draconiczny.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine.aspects import LUMINARIES, RIGID_PAIRS, separation
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
from app.engine.zodiac import to_equatorial
|
||||||
|
|
||||||
|
# Orby — konfigurowalne (wymóg LOG-07). Paralele i antyscja są klasycznie CIASNE:
|
||||||
|
# to kontakty „punktowe", więc szeroki orb produkowałby fałszywe trafienia.
|
||||||
|
DECL_ORB = 1.0 # ° deklinacji dla paraleli/kontrparaleli
|
||||||
|
ANTISCIA_ORB = 1.0 # ° długości dla antyscji/kontrantyscji
|
||||||
|
LUMINARY_BONUS = 0.0 # świadomie 0 — te kontakty trzymamy ciasno; do podniesienia w API
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rigid(name_a: str, name_b: str) -> bool:
|
||||||
|
return frozenset({name_a, name_b}) in RIGID_PAIRS
|
||||||
|
|
||||||
|
|
||||||
|
def declination(lon: float, lat: float, eps: float) -> float:
|
||||||
|
"""Deklinacja [−90, 90]° z długości i szerokości ekliptycznej (pełny wzór,
|
||||||
|
z szerokością — istotne dla Księżyca i planet, które schodzą z ekliptyki)."""
|
||||||
|
_, dec = to_equatorial(lon, lat, eps)
|
||||||
|
return dec
|
||||||
|
|
||||||
|
|
||||||
|
def is_out_of_bounds(dec: float, eps: float) -> bool:
|
||||||
|
"""Deklinacja poza zakresem Słońca (|dec| > nachylenie ekliptyki).
|
||||||
|
|
||||||
|
„Out of bounds" — ciało zaszło dalej na północ/południe, niż Słońce kiedykolwiek
|
||||||
|
potrafi. Astrologicznie czytane jako działanie „poza normą", stąd wart odnotowania."""
|
||||||
|
return abs(dec) > eps
|
||||||
|
|
||||||
|
|
||||||
|
def antiscion(lon: float) -> float:
|
||||||
|
"""Odbicie długości względem osi przesileń (0° Raka – 0° Koziorożca)."""
|
||||||
|
return norm360(180.0 - lon)
|
||||||
|
|
||||||
|
|
||||||
|
def contra_antiscion(lon: float) -> float:
|
||||||
|
"""Odbicie długości względem osi równonocy (0° Barana – 0° Wagi)."""
|
||||||
|
return norm360(-lon)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed(orb: float, name_a: str, name_b: str, bonus: float) -> float:
|
||||||
|
if bonus and (name_a in LUMINARIES or name_b in LUMINARIES):
|
||||||
|
return orb + bonus
|
||||||
|
return orb
|
||||||
|
|
||||||
|
|
||||||
|
def find_declination_aspects(
|
||||||
|
bodies: list[dict], orb: float = DECL_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||||
|
) -> list[dict]:
|
||||||
|
"""bodies: dicty z 'name' i 'declination' (°).
|
||||||
|
|
||||||
|
Zwraca paralele (ta sama deklinacja) i kontrparalele (przeciwna). Pary z
|
||||||
|
RIGID_PAIRS pomijane — np. węzły są z definicji zawsze w kontrparaleli
|
||||||
|
(SN = NN+180 na ekliptyce → deklinacja przeciwna), co nie niesie informacji.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
n = len(bodies)
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
a, b = bodies[i], bodies[j]
|
||||||
|
if _is_rigid(a["name"], b["name"]):
|
||||||
|
continue
|
||||||
|
da, db = a.get("declination"), b.get("declination")
|
||||||
|
if da is None or db is None:
|
||||||
|
continue
|
||||||
|
da, db = float(da), float(db)
|
||||||
|
allowed = _allowed(orb, a["name"], b["name"], luminary_bonus)
|
||||||
|
parallel_dev = abs(da - db)
|
||||||
|
contra_dev = abs(da + db)
|
||||||
|
# ciało może wpaść tylko w jeden z dwóch — bierzemy ciaśniejszy
|
||||||
|
if parallel_dev <= allowed and parallel_dev <= contra_dev:
|
||||||
|
out.append(_row("parallel", a, b, parallel_dev, allowed, da, db))
|
||||||
|
elif contra_dev <= allowed:
|
||||||
|
out.append(_row("contraparallel", a, b, contra_dev, allowed, da, db))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_antiscia(
|
||||||
|
bodies: list[dict], orb: float = ANTISCIA_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||||
|
) -> list[dict]:
|
||||||
|
"""bodies: dicty z 'name' i 'decimal' (długość tropikalna of-date, °).
|
||||||
|
|
||||||
|
Zwraca antyscje (odbicie względem osi przesileń) i kontrantyscje (osi równonocy).
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
n = len(bodies)
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
a, b = bodies[i], bodies[j]
|
||||||
|
if _is_rigid(a["name"], b["name"]):
|
||||||
|
continue
|
||||||
|
la, lb = a.get("decimal"), b.get("decimal")
|
||||||
|
if la is None or lb is None:
|
||||||
|
continue
|
||||||
|
la, lb = float(la), float(lb)
|
||||||
|
allowed = _allowed(orb, a["name"], b["name"], luminary_bonus)
|
||||||
|
anti_dev = separation(la, antiscion(lb))
|
||||||
|
contra_dev = separation(la, contra_antiscion(lb))
|
||||||
|
if anti_dev <= allowed and anti_dev <= contra_dev:
|
||||||
|
out.append(_row("antiscion", a, b, anti_dev, allowed))
|
||||||
|
elif contra_dev <= allowed:
|
||||||
|
out.append(_row("contra_antiscion", a, b, contra_dev, allowed))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _row(kind: str, a: dict, b: dict, dev: float, allowed: float,
|
||||||
|
dec_a: float | None = None, dec_b: float | None = None) -> dict:
|
||||||
|
row = {
|
||||||
|
"obj1": a["name"], "obj2": b["name"],
|
||||||
|
"type": kind, "orb": round(dev, 3), "allowed": round(allowed, 3),
|
||||||
|
}
|
||||||
|
if dec_a is not None:
|
||||||
|
row["dec1"], row["dec2"] = round(dec_a, 3), round(dec_b, 3)
|
||||||
|
return row
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""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
|
||||||
@@ -58,13 +58,15 @@ def solar_arc_directions(
|
|||||||
if not (lo_age <= age <= hi_age) or (p == q and arc < 1e-6):
|
if not (lo_age <= age <= hi_age) or (p == q and arc < 1e-6):
|
||||||
continue
|
continue
|
||||||
exact = _add_years(birth, age)
|
exact = _add_years(birth, age)
|
||||||
out.append(_row(
|
row = _row(
|
||||||
"solar_arc",
|
"solar_arc",
|
||||||
f"dyr. {p} {PL_NAME[asp]} {q}",
|
f"dyr. {p} {PL_NAME[asp]} {q}",
|
||||||
_add_years(birth, age - orb_years),
|
_add_years(birth, age - orb_years),
|
||||||
exact,
|
exact,
|
||||||
_add_years(birth, age + orb_years),
|
_add_years(birth, age + orb_years),
|
||||||
))
|
)
|
||||||
|
row.update(directed=p, aspect=asp, target=q) # do budowy tokenów (1B->2B)
|
||||||
|
out.append(row)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -83,10 +85,13 @@ def profection_events(natal_asc: float, birth: datetime, lo: datetime, hi: datet
|
|||||||
if end < lo or start > hi:
|
if end < lo or start > hi:
|
||||||
continue
|
continue
|
||||||
sign = profected_sign(natal_asc, age)
|
sign = profected_sign(natal_asc, age)
|
||||||
out.append(_row(
|
lord = DOMICILE_RULERS[sign]
|
||||||
"profection", f"Władca Roku: {DOMICILE_RULERS[sign]} (Asc {sign}, wiek {age})",
|
row = _row(
|
||||||
|
"profection", f"Władca Roku: {lord} (Asc {sign}, wiek {age})",
|
||||||
start, start, end,
|
start, start, end,
|
||||||
))
|
)
|
||||||
|
row.update(lord=lord, sign=sign) # do budowy tokenów (1B->2B)
|
||||||
|
out.append(row)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +109,28 @@ def solar_return_events(engine, natal_moment, birth: datetime, lo: datetime, hi:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def firdaria_events(natal_points: dict[str, float], birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||||
|
"""Starty okresów/podokresów Firdarii (LOG-11) nachodzące na okno."""
|
||||||
|
from app.engine.firdaria import firdaria
|
||||||
|
|
||||||
|
fd = firdaria(birth, natal_points["Sun"], natal_points["Asc"], natal_points["MC"])
|
||||||
|
out: list[dict] = []
|
||||||
|
for period in fd["periods"]:
|
||||||
|
if "sub" in period:
|
||||||
|
for s in period["sub"]:
|
||||||
|
if lo <= _as_dt(s["start"]) <= hi:
|
||||||
|
row = _row("firdaria", f"Firdaria: {period['lord']} / {s['lord']}",
|
||||||
|
s["start"], s["start"], s["end"])
|
||||||
|
row.update(fd_major=period["lord"], fd_sub=s["lord"])
|
||||||
|
out.append(row)
|
||||||
|
elif lo <= _as_dt(period["start"]) <= hi: # węzeł — bez podokresów
|
||||||
|
row = _row("firdaria", f"Firdaria: {period['lord']}",
|
||||||
|
period["start"], period["start"], period["end"])
|
||||||
|
row.update(fd_major=period["lord"])
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _as_dt(d) -> datetime:
|
def _as_dt(d) -> datetime:
|
||||||
if isinstance(d, datetime):
|
if isinstance(d, datetime):
|
||||||
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
||||||
@@ -119,7 +146,7 @@ def build_timeline(
|
|||||||
"""Scala wybrane techniki w jedną oś czasu, posortowaną po dacie dokładnej."""
|
"""Scala wybrane techniki w jedną oś czasu, posortowaną po dacie dokładnej."""
|
||||||
lo, hi = _as_dt(from_d), _as_dt(to_d)
|
lo, hi = _as_dt(from_d), _as_dt(to_d)
|
||||||
birth = natal_moment.when_utc
|
birth = natal_moment.when_utc
|
||||||
want = set(techniques or ["profection", "solar_return", "solar_arc"])
|
want = set(techniques or ["profection", "solar_return", "solar_arc", "firdaria"])
|
||||||
events: list[dict] = []
|
events: list[dict] = []
|
||||||
if "profection" in want:
|
if "profection" in want:
|
||||||
events += profection_events(natal_points["Asc"], birth, lo, hi)
|
events += profection_events(natal_points["Asc"], birth, lo, hi)
|
||||||
@@ -127,5 +154,7 @@ def build_timeline(
|
|||||||
events += solar_return_events(engine, natal_moment, birth, lo, hi)
|
events += solar_return_events(engine, natal_moment, birth, lo, hi)
|
||||||
if "solar_arc" in want:
|
if "solar_arc" in want:
|
||||||
events += solar_arc_directions(natal_points, birth, lo, hi)
|
events += solar_arc_directions(natal_points, birth, lo, hi)
|
||||||
|
if "firdaria" in want:
|
||||||
|
events += firdaria_events(natal_points, birth, lo, hi)
|
||||||
events.sort(key=lambda e: e["exact"])
|
events.sort(key=lambda e: e["exact"])
|
||||||
return events
|
return events
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Systemy zodiaku (LOG-04): tropikalny, syderyczny (ayanamsy), draconic + RA.
|
||||||
|
|
||||||
|
Wszystkie pozycje silnika są liczone **tropikalnie of-date** (kontrakt LOG-28).
|
||||||
|
Zmiana zodiaku to — dla zodiaków ekliptycznych — jednolite przesunięcie długości:
|
||||||
|
|
||||||
|
długość_docelowa = (długość_tropikalna − offset) mod 360
|
||||||
|
|
||||||
|
gdzie offset to:
|
||||||
|
- **syderyczny**: ayanamsa (kąt między tropikalnym a syderycznym punktem Barana),
|
||||||
|
- **draconic**: długość wznoszącego węzła Księżyca (węzeł = 0° draconic),
|
||||||
|
- **tropikalny**: 0.
|
||||||
|
|
||||||
|
Ponieważ to stałe przesunięcie obiektu ORAZ cusps, **numery domów się nie zmieniają**
|
||||||
|
(geometria jest niezmiennicza względem obrotu) — przesuwamy tylko etykiety znaków.
|
||||||
|
|
||||||
|
Model ayanamsy: `ayan(jd) = ayan0 + B·x + C·x²`, gdzie `x = jd − J2000`. Prędkość
|
||||||
|
precesji (B, C) jest **wspólna** dla wszystkich ayanams; różni je tylko stała `ayan0`
|
||||||
|
(wybór syderycznego zera). Stałe skalibrowano do Swiss Ephemeris jako wyroczni —
|
||||||
|
zgodność do ~0,02" w latach 1900–2100 (patrz tests/test_zodiac.py).
|
||||||
|
|
||||||
|
RA (right ascension): konwersja ekliptyka→równik dla przyszłego widoku równikowego.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
TROPICAL = "tropical"
|
||||||
|
DRACONIC = "draconic"
|
||||||
|
|
||||||
|
# stała ayanamsy w J2000.0 (°) — skalibrowana do swisseph (get_ayanamsa_ut)
|
||||||
|
_AYAN0 = {
|
||||||
|
"lahiri": 23.857092,
|
||||||
|
"fagan_bradley": 24.740300,
|
||||||
|
"krishnamurti": 23.760240,
|
||||||
|
}
|
||||||
|
_J2000 = 2451545.0
|
||||||
|
_B = 3.824459e-5 # °/dobę — liniowy człon precesji (wspólny)
|
||||||
|
_C = 2.304e-13 # °/dobę² — drobne przyspieszenie (wspólne)
|
||||||
|
|
||||||
|
# nazwy zodiaków akceptowane przez API
|
||||||
|
SIDEREAL = tuple(f"sidereal_{k}" for k in _AYAN0) # sidereal_lahiri, ...
|
||||||
|
SYSTEMS = (TROPICAL, *SIDEREAL, DRACONIC)
|
||||||
|
|
||||||
|
|
||||||
|
def julian_day(dt: datetime) -> float:
|
||||||
|
"""Julian Day (UT) z momentu UTC — algorytm Meeusa (kalendarz gregoriański)."""
|
||||||
|
y, m = dt.year, dt.month
|
||||||
|
day = dt.day + (dt.hour + dt.minute / 60.0 + dt.second / 3600.0
|
||||||
|
+ dt.microsecond / 3.6e9) / 24.0
|
||||||
|
if m <= 2:
|
||||||
|
y -= 1
|
||||||
|
m += 12
|
||||||
|
a = y // 100
|
||||||
|
b = 2 - a + a // 4
|
||||||
|
return math.floor(365.25 * (y + 4716)) + math.floor(30.6001 * (m + 1)) + day + b - 1524.5
|
||||||
|
|
||||||
|
|
||||||
|
def ayanamsha(name: str, jd: float) -> float:
|
||||||
|
"""Ayanamsa [°] danej szkoły dla Julian Day (UT)."""
|
||||||
|
key = name[len("sidereal_"):] if name.startswith("sidereal_") else name
|
||||||
|
if key not in _AYAN0:
|
||||||
|
raise ValueError(f"Nieznana ayanamsa: {name!r} (dostępne: {', '.join(_AYAN0)})")
|
||||||
|
x = jd - _J2000
|
||||||
|
return _AYAN0[key] + _B * x + _C * x * x
|
||||||
|
|
||||||
|
|
||||||
|
def offset(zodiac: str, jd: float, node_lon: float | None = None) -> float:
|
||||||
|
"""Ile odjąć od długości tropikalnej, by dostać wybrany zodiak.
|
||||||
|
|
||||||
|
`node_lon` (tropikalna długość węzła wznoszącego) wymagana tylko dla draconic.
|
||||||
|
"""
|
||||||
|
if zodiac == TROPICAL:
|
||||||
|
return 0.0
|
||||||
|
if zodiac == DRACONIC:
|
||||||
|
if node_lon is None:
|
||||||
|
raise ValueError("draconic wymaga długości węzła (node_lon)")
|
||||||
|
return norm360(node_lon)
|
||||||
|
if zodiac in SIDEREAL:
|
||||||
|
return ayanamsha(zodiac, jd)
|
||||||
|
raise ValueError(f"Nieznany zodiak: {zodiac!r} (dostępne: {', '.join(SYSTEMS)})")
|
||||||
|
|
||||||
|
|
||||||
|
def apply(lon: float, off: float) -> float:
|
||||||
|
"""Długość w docelowym zodiaku."""
|
||||||
|
return norm360(lon - off)
|
||||||
|
|
||||||
|
|
||||||
|
def to_equatorial(lon: float, lat: float, eps: float) -> tuple[float, float]:
|
||||||
|
"""Ekliptyka (λ, β) → równik: (RA, deklinacja) w stopniach. Wszystko w °.
|
||||||
|
|
||||||
|
RA rośnie 0–360°; deklinacja w [−90, 90].
|
||||||
|
"""
|
||||||
|
lam, bet, e = math.radians(lon), math.radians(lat), math.radians(eps)
|
||||||
|
sin_dec = math.sin(bet) * math.cos(e) + math.cos(bet) * math.sin(e) * math.sin(lam)
|
||||||
|
dec = math.asin(max(-1.0, min(1.0, sin_dec)))
|
||||||
|
ra = math.atan2(
|
||||||
|
math.sin(lam) * math.cos(e) - math.tan(bet) * math.sin(e),
|
||||||
|
math.cos(lam),
|
||||||
|
)
|
||||||
|
return norm360(math.degrees(ra)), math.degrees(dec)
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
||||||
|
|
||||||
|
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
||||||
|
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
||||||
|
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
||||||
|
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
||||||
|
zrzut z węzła), miał je w całości.
|
||||||
|
|
||||||
|
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
||||||
|
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
||||||
|
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
||||||
|
podatne na modyfikację".
|
||||||
|
|
||||||
|
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
||||||
|
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
||||||
|
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
||||||
|
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
||||||
|
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
||||||
|
|
||||||
|
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
||||||
|
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
||||||
|
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
||||||
|
|
||||||
|
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
||||||
|
|
||||||
|
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
||||||
|
|
||||||
|
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
||||||
|
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
||||||
|
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
||||||
|
|
||||||
|
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
||||||
|
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
||||||
|
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
||||||
|
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.link")
|
||||||
|
|
||||||
|
MAGIC = b"AL1"
|
||||||
|
VERSION = "v1"
|
||||||
|
NONCE_BYTES = 12
|
||||||
|
KEY_BYTES = 32 # AES-256
|
||||||
|
LENGTH_PREFIX = 4
|
||||||
|
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
||||||
|
MAX_SKEW_SECONDS = 300.0
|
||||||
|
|
||||||
|
HEADER_ENC = "X-Astrololo-Enc"
|
||||||
|
HEADER_TS = "X-Astrololo-Enc-Ts"
|
||||||
|
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
||||||
|
|
||||||
|
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||||
|
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||||
|
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||||
|
|
||||||
|
REQUEST, RESPONSE = b"req", b"res"
|
||||||
|
|
||||||
|
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
||||||
|
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
class LinkError(Exception):
|
||||||
|
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- klucze
|
||||||
|
|
||||||
|
def parse_key(raw: str) -> bytes:
|
||||||
|
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
raise LinkError("pusty klucz łącza")
|
||||||
|
try:
|
||||||
|
key = bytes.fromhex(text)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(text, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
||||||
|
if len(key) != KEY_BYTES:
|
||||||
|
raise LinkError(
|
||||||
|
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
||||||
|
f"`openssl rand -hex 32`"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def key_from_env(env_name: str) -> bytes | None:
|
||||||
|
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
||||||
|
raw = os.getenv(env_name, "")
|
||||||
|
return parse_key(raw) if raw.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_required() -> bool:
|
||||||
|
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
||||||
|
|
||||||
|
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
||||||
|
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
||||||
|
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
||||||
|
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
||||||
|
"""
|
||||||
|
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
||||||
|
return HKDF(
|
||||||
|
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
||||||
|
info=b"astrololo/link/" + direction,
|
||||||
|
).derive(link_key)
|
||||||
|
|
||||||
|
|
||||||
|
class Link:
|
||||||
|
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
||||||
|
|
||||||
|
def __init__(self, link_key: bytes) -> None:
|
||||||
|
self._by_direction = {
|
||||||
|
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
||||||
|
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- pojedyncza ramka
|
||||||
|
|
||||||
|
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
||||||
|
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
||||||
|
stamp.encode("ascii"), str(seq).encode("ascii")])
|
||||||
|
|
||||||
|
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
plaintext: bytes) -> bytes:
|
||||||
|
nonce = os.urandom(NONCE_BYTES)
|
||||||
|
sealed = self._by_direction[direction].encrypt(
|
||||||
|
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
||||||
|
return MAGIC + nonce + sealed
|
||||||
|
|
||||||
|
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
frame: bytes) -> bytes:
|
||||||
|
if not frame.startswith(MAGIC):
|
||||||
|
raise LinkError("ramka bez znacznika astrololo")
|
||||||
|
body = frame[len(MAGIC):]
|
||||||
|
if len(body) <= NONCE_BYTES:
|
||||||
|
raise LinkError("ramka za krótka")
|
||||||
|
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
|
||||||
|
try:
|
||||||
|
return self._by_direction[direction].decrypt(
|
||||||
|
nonce, sealed, self._aad(direction, path, stamp, seq))
|
||||||
|
except InvalidTag as exc:
|
||||||
|
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
|
||||||
|
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
|
||||||
|
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
|
||||||
|
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ strumień ramek
|
||||||
|
|
||||||
|
def seal_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
chunks: Iterable[bytes]) -> Iterator[bytes]:
|
||||||
|
for seq, chunk in enumerate(chunks):
|
||||||
|
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
|
||||||
|
|
||||||
|
def open_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
raw: bytes) -> Iterator[bytes]:
|
||||||
|
for seq, frame in enumerate(frames_in(raw)):
|
||||||
|
yield self.open(direction, path, stamp, seq, frame)
|
||||||
|
|
||||||
|
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
|
||||||
|
return b"".join(self.open_stream(direction, path, stamp, raw))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ramkowanie
|
||||||
|
|
||||||
|
def frame_out(payload: bytes) -> bytes:
|
||||||
|
return struct.pack(">I", len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def frames_in(raw: bytes) -> Iterator[bytes]:
|
||||||
|
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
|
||||||
|
offset = 0
|
||||||
|
while offset < len(raw):
|
||||||
|
if offset + LENGTH_PREFIX > len(raw):
|
||||||
|
raise LinkError("urwana ramka (brak nagłówka długości)")
|
||||||
|
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
offset += LENGTH_PREFIX
|
||||||
|
if offset + size > len(raw):
|
||||||
|
raise LinkError("urwana ramka (za mało danych)")
|
||||||
|
yield raw[offset:offset + size]
|
||||||
|
offset += size
|
||||||
|
|
||||||
|
|
||||||
|
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
|
||||||
|
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
|
||||||
|
|
||||||
|
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
|
||||||
|
rozjechać się między dwa odczyty.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
if len(buffer) < LENGTH_PREFIX:
|
||||||
|
return
|
||||||
|
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
if len(buffer) < LENGTH_PREFIX + size:
|
||||||
|
return
|
||||||
|
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
|
||||||
|
del buffer[:LENGTH_PREFIX + size]
|
||||||
|
yield frame
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- świeżość ruchu
|
||||||
|
|
||||||
|
def stamp_now() -> str:
|
||||||
|
return f"{time.time():.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def check_stamp(stamp: str) -> None:
|
||||||
|
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
|
||||||
|
odtworzyć w dowolnym momencie w przyszłości."""
|
||||||
|
try:
|
||||||
|
sent = float(stamp)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise LinkError("brak albo błędny znacznik czasu") from exc
|
||||||
|
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
|
||||||
|
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================== strona serwerowa
|
||||||
|
|
||||||
|
class LinkCryptoMiddleware:
|
||||||
|
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
|
||||||
|
|
||||||
|
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
|
||||||
|
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
|
||||||
|
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, link: Link | None, layer: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.link = link
|
||||||
|
self.layer = layer
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
path = scope["path"]
|
||||||
|
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
|
||||||
|
|
||||||
|
if headers.get(HEADER_ENC.lower()) != VERSION:
|
||||||
|
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
|
||||||
|
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
|
||||||
|
# chcemy po cichu wrócić do jawnego ruchu.
|
||||||
|
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
|
||||||
|
self.layer, path)
|
||||||
|
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
|
||||||
|
|
||||||
|
stamp = headers.get(HEADER_TS.lower(), "")
|
||||||
|
try:
|
||||||
|
check_stamp(stamp)
|
||||||
|
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
|
||||||
|
except LinkError as exc:
|
||||||
|
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
|
||||||
|
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
|
||||||
|
|
||||||
|
scope = dict(scope)
|
||||||
|
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
|
||||||
|
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
|
||||||
|
|
||||||
|
def _sealing_send(self, send, path: str):
|
||||||
|
state: dict = {"stamp": "", "seq": 0}
|
||||||
|
|
||||||
|
async def sealing(message):
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
state["stamp"] = stamp_now()
|
||||||
|
keep = [(k, v) for k, v in message.get("headers", [])
|
||||||
|
if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
message = dict(message)
|
||||||
|
message["headers"] = keep + [
|
||||||
|
(b"content-type", CONTENT_TYPE.encode()),
|
||||||
|
(HEADER_ENC.lower().encode(), VERSION.encode()),
|
||||||
|
(HEADER_TS.lower().encode(), state["stamp"].encode()),
|
||||||
|
]
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
if message["type"] == "http.response.body":
|
||||||
|
chunk = message.get("body", b"")
|
||||||
|
sealed = b""
|
||||||
|
if chunk:
|
||||||
|
sealed = frame_out(self.link.seal(
|
||||||
|
RESPONSE, path, state["stamp"], state["seq"], chunk))
|
||||||
|
state["seq"] += 1
|
||||||
|
return await send({"type": "http.response.body", "body": sealed,
|
||||||
|
"more_body": message.get("more_body", False)})
|
||||||
|
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
return sealing
|
||||||
|
|
||||||
|
|
||||||
|
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
|
||||||
|
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
|
||||||
|
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
|
||||||
|
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
kept.append((b"content-length", str(length).encode()))
|
||||||
|
if length:
|
||||||
|
kept.append((b"content-type", b"application/json"))
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_body(receive) -> bytes:
|
||||||
|
body = bytearray()
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.disconnect":
|
||||||
|
raise LinkError("rozłączenie w trakcie odbioru żądania")
|
||||||
|
body += message.get("body", b"")
|
||||||
|
if not message.get("more_body", False):
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _replay(body: bytes, original):
|
||||||
|
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
|
||||||
|
|
||||||
|
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
|
||||||
|
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
|
||||||
|
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
|
||||||
|
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
|
||||||
|
"""
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
if delivered:
|
||||||
|
return await original()
|
||||||
|
delivered = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return receive
|
||||||
|
|
||||||
|
|
||||||
|
async def _refuse(send, detail: str) -> None:
|
||||||
|
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
|
||||||
|
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
|
||||||
|
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
|
||||||
|
await send({"type": "http.response.start", "status": 400, "headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"content-length", str(len(payload)).encode()),
|
||||||
|
]})
|
||||||
|
await send({"type": "http.response.body", "body": payload})
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, env_name: str, layer: str):
|
||||||
|
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
|
||||||
|
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
|
||||||
|
link_key = key_from_env(env_name)
|
||||||
|
if link_key is None and encryption_required():
|
||||||
|
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
|
||||||
|
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
|
||||||
|
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
|
||||||
|
f"warstwa {layer} nie wystartuje bez klucza łącza"
|
||||||
|
)
|
||||||
|
if link_key is None:
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
|
||||||
|
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
|
||||||
|
"ruch wewnątrz sieci.", env_name, layer,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
link = Link(link_key)
|
||||||
|
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
|
||||||
|
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ strona kliencka
|
||||||
|
|
||||||
|
def call(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
|
||||||
|
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
|
||||||
|
|
||||||
|
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
|
||||||
|
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
|
||||||
|
się niejasnym błędem odszyfrowania.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
|
||||||
|
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
|
||||||
|
# sygnifikatory, o które pytamy bazę.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
|
||||||
|
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
response = client.request(method, url, json=payload, headers=request_headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
path = httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
|
||||||
|
"Content-Type": CONTENT_TYPE})
|
||||||
|
|
||||||
|
response = client.request(method, url, content=body, headers=request_headers)
|
||||||
|
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
log.error("łącze %s odmówiło: %s", path, response.text[:200])
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
|
||||||
|
reply_stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(reply_stamp)
|
||||||
|
return link.open_all(RESPONSE, path, reply_stamp, response.content)
|
||||||
|
|
||||||
|
|
||||||
|
def call_json(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None):
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
return _json.loads(call(client, method, url, payload=payload,
|
||||||
|
headers=headers, link=link))
|
||||||
|
|
||||||
|
|
||||||
|
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
|
||||||
|
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
|
||||||
|
|
||||||
|
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
|
||||||
|
w buforze zamiast zakładać, że każdy kawałek to komplet.
|
||||||
|
"""
|
||||||
|
if link is None:
|
||||||
|
yield from response.iter_bytes()
|
||||||
|
return
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
|
||||||
|
stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(stamp)
|
||||||
|
path = response.request.url.path
|
||||||
|
buffer = bytearray()
|
||||||
|
seq = 0
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
buffer += chunk
|
||||||
|
for frame in unframe_incremental(buffer):
|
||||||
|
yield link.open(RESPONSE, path, stamp, seq, frame)
|
||||||
|
seq += 1
|
||||||
|
if buffer:
|
||||||
|
raise LinkError("strumień urwał się w połowie ramki")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
|
||||||
|
link: Link | None) -> Iterator[str]:
|
||||||
|
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
|
||||||
|
|
||||||
|
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
|
||||||
|
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
|
||||||
|
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
|
||||||
|
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
|
||||||
|
dopiero na znakach nowej linii.
|
||||||
|
|
||||||
|
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
|
||||||
|
działał bez zmian.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx as _httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
|
||||||
|
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
|
||||||
|
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
|
||||||
|
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
with client.stream("POST", url, json=payload, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
for text_line in response.iter_lines():
|
||||||
|
if text_line:
|
||||||
|
yield text_line
|
||||||
|
return
|
||||||
|
|
||||||
|
path = _httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
|
||||||
|
with client.stream("POST", url, content=body, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
buffer = bytearray()
|
||||||
|
for plain in open_response_stream(response, link):
|
||||||
|
buffer += plain
|
||||||
|
while True:
|
||||||
|
nl = buffer.find(b"\n")
|
||||||
|
if nl < 0:
|
||||||
|
break
|
||||||
|
text_line = bytes(buffer[:nl])
|
||||||
|
del buffer[:nl + 1]
|
||||||
|
if text_line:
|
||||||
|
yield text_line.decode("utf-8")
|
||||||
|
if buffer:
|
||||||
|
yield bytes(buffer).decode("utf-8")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Warstwa dostawców modeli językowych (LOG-31)."""
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Kontrakt dostawcy modelu językowego (LOG-31).
|
||||||
|
|
||||||
|
Analogicznie do `EphemerisEngine` (LOG-24): prezentacja i reszta logiki nie wiedzą,
|
||||||
|
kto pisze tekst — lokalny model na naszym sprzęcie czy dostawca w chmurze.
|
||||||
|
|
||||||
|
Kluczowa własność dla bezpieczeństwa baz (LOG-32): każdy dostawca deklaruje
|
||||||
|
`leaves_lan`. Prompt niesie ORYGINALNE opisy z baz, więc interfejs musi jawnie
|
||||||
|
mówić, czy ta treść opuszcza naszą sieć — UI ma na tej podstawie ostrzegać.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Completion:
|
||||||
|
"""Wynik generowania — tekst + metryki do rozliczenia i podglądu."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
model: str
|
||||||
|
provider: str
|
||||||
|
leaves_lan: bool
|
||||||
|
usage: dict = field(default_factory=dict) # prompt_tokens / completion_tokens
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(RuntimeError):
|
||||||
|
"""Błąd wołania modelu — z komunikatem nadającym się do pokazania użytkownikowi."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider(ABC):
|
||||||
|
name: str = "?"
|
||||||
|
#: czy treść promptu (a więc opisy z baz) opuszcza naszą sieć
|
||||||
|
leaves_lan: bool = True
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||||
|
"""Zwraca gotowy tekst. Rzuca LLMError przy niepowodzeniu."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def health(self) -> dict:
|
||||||
|
"""Czy dostawca jest osiągalny i skonfigurowany."""
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""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")}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Wybór dostawcy LLM (LOG-31) — jedyne miejsce znające konkretne implementacje.
|
||||||
|
|
||||||
|
Domyślny jest **model lokalny**: prompt niesie oryginalne opisy z baz, więc
|
||||||
|
domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
|
||||||
|
przez konfigurację albo pojedyncze żądanie.
|
||||||
|
|
||||||
|
Konfiguracja jest **per dostawca**, bo UI pozwala przełączać go przy każdym żądaniu.
|
||||||
|
Wspólne `LLM_*` nie wystarczy: ustawienie `LLM_BASE_URL` na lokalny model kierowałoby
|
||||||
|
tam także żądania do OpenAI, a `LLM_MODEL=llama3.1:8b` kazałoby Anthropic użyć modelu
|
||||||
|
llama. Dlatego każdy dostawca ma własny komplet zmiennych.
|
||||||
|
|
||||||
|
Zmienne środowiskowe:
|
||||||
|
LLM_PROVIDER local (domyślnie) | openai | anthropic — dostawca domyślny
|
||||||
|
LLM_TIMEOUT sekundy (domyślnie 120)
|
||||||
|
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
|
||||||
|
|
||||||
|
<DOSTAWCA>_MODEL / _BASE_URL / _API_KEY — konfiguracja konkretnego dostawcy:
|
||||||
|
LOCAL_MODEL, LOCAL_BASE_URL (klucz zwykle zbędny)
|
||||||
|
OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_API_KEY
|
||||||
|
ANTHROPIC_MODEL, ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY
|
||||||
|
|
||||||
|
Klucze WYŁĄCZNIE z sekretu — nigdy w repo, w UI ani w logach.
|
||||||
|
|
||||||
|
Zgodność wstecz: wspólne `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` nadal działają,
|
||||||
|
ale stosują się TYLKO do dostawcy domyślnego (LLM_PROVIDER) — czyli konfiguracja
|
||||||
|
instalacji jednodostawcowej zostaje nietknięta, a pozostali dostawcy jej nie dziedziczą.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.llm.base import LLMError, LLMProvider
|
||||||
|
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
|
||||||
|
|
||||||
|
LOCAL = "local"
|
||||||
|
OPENAI = "openai"
|
||||||
|
ANTHROPIC = "anthropic"
|
||||||
|
PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
|
||||||
|
|
||||||
|
_DEFAULT_MODEL = {
|
||||||
|
LOCAL: "llama3.1:8b",
|
||||||
|
OPENAI: "gpt-4o-mini",
|
||||||
|
# Opus 4.8 świadomie zamiast Sonnet 5: Sonnet uruchamia myślenie adaptacyjne,
|
||||||
|
# gdy pominąć parametr `thinking`, a jego tokeny liczą się do max_tokens —
|
||||||
|
# przy ciasnym limicie cała tura wychodziła jako samo myślenie z pustym
|
||||||
|
# tekstem. To była przyczyna pustych odpowiedzi na Anthropicu.
|
||||||
|
ANTHROPIC: "claude-opus-4-8",
|
||||||
|
}
|
||||||
|
_DEFAULT_URL = {
|
||||||
|
# Ollama i vLLM wystawiają zgodne API pod /v1
|
||||||
|
LOCAL: "http://localhost:11434/v1",
|
||||||
|
OPENAI: "https://api.openai.com/v1",
|
||||||
|
ANTHROPIC: "https://api.anthropic.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_provider_name() -> str:
|
||||||
|
return os.getenv("LLM_PROVIDER", LOCAL).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def max_tokens() -> int:
|
||||||
|
return int(os.getenv("LLM_MAX_TOKENS", "2000"))
|
||||||
|
|
||||||
|
|
||||||
|
def timeout() -> float:
|
||||||
|
return float(os.getenv("LLM_TIMEOUT", "120"))
|
||||||
|
|
||||||
|
|
||||||
|
def setting(provider: str, suffix: str, fallback: str = "") -> str:
|
||||||
|
"""Ustawienie dostawcy: <DOSTAWCA>_<SUFIKS> → LLM_<SUFIKS> → wbudowana domyślna.
|
||||||
|
|
||||||
|
Wspólne `LLM_*` stosuje się WYŁĄCZNIE do dostawcy domyślnego — inaczej adres
|
||||||
|
lokalnego modelu przejąłby żądania do chmury (i odwrotnie).
|
||||||
|
"""
|
||||||
|
specific = os.getenv(f"{provider.upper()}_{suffix}")
|
||||||
|
if specific:
|
||||||
|
return specific
|
||||||
|
if provider == default_provider_name():
|
||||||
|
generic = os.getenv(f"LLM_{suffix}")
|
||||||
|
if generic:
|
||||||
|
return generic
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_model(name: str | None = None, model: str | None = None) -> tuple[str, str]:
|
||||||
|
"""(dostawca, model) BEZ budowania dostawcy — czyli bez wymogu klucza API.
|
||||||
|
|
||||||
|
Rozmiar budżetu promptu zależy tylko od okna kontekstu modelu, więc nie może
|
||||||
|
zależeć od tego, czy klucz jest już skonfigurowany.
|
||||||
|
"""
|
||||||
|
provider = (name or default_provider_name()).lower()
|
||||||
|
if provider not in PROVIDERS:
|
||||||
|
provider = default_provider_name()
|
||||||
|
chosen = (model or "").strip() or setting(provider, "MODEL", _DEFAULT_MODEL[provider])
|
||||||
|
return provider, chosen
|
||||||
|
|
||||||
|
|
||||||
|
def build_provider(name: str | None = None, model: str | None = None) -> LLMProvider:
|
||||||
|
"""Dostawca modelu. `model` z żądania wygrywa nad konfiguracją — użytkownik
|
||||||
|
wybiera model w UI, a konfiguracja podaje tylko wartość domyślną."""
|
||||||
|
name = (name or default_provider_name()).lower()
|
||||||
|
if name not in PROVIDERS:
|
||||||
|
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
|
||||||
|
|
||||||
|
model = (model or "").strip() or setting(name, "MODEL", _DEFAULT_MODEL[name])
|
||||||
|
base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
|
||||||
|
api_key = setting(name, "API_KEY")
|
||||||
|
|
||||||
|
if name in (OPENAI, ANTHROPIC) and not api_key:
|
||||||
|
raise LLMError(
|
||||||
|
f"Brak klucza dla dostawcy {name} — ustaw {name.upper()}_API_KEY "
|
||||||
|
f"(z sekretu). Model lokalny klucza nie wymaga."
|
||||||
|
)
|
||||||
|
if name == ANTHROPIC:
|
||||||
|
return AnthropicProvider(base_url, model, api_key, timeout())
|
||||||
|
if name == OPENAI:
|
||||||
|
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
|
||||||
|
leaves_lan=True)
|
||||||
|
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
|
||||||
|
return ChatCompletionsProvider(LOCAL, base_url, model, api_key, timeout(),
|
||||||
|
leaves_lan=False)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""Implementacje dostawców LLM (LOG-31) — na samym httpx, bez SDK.
|
||||||
|
|
||||||
|
Świadomie bez bibliotek `openai` / `anthropic`: lokalny serwer modelu (Ollama,
|
||||||
|
vLLM, llama.cpp) i OpenAI mówią **tym samym** protokołem `/chat/completions`,
|
||||||
|
więc jedna implementacja obsługuje oba — różni je tylko adres i klucz. Anthropic
|
||||||
|
ma własny kształt `/v1/messages`, stąd druga klasa. Mniej zależności, mniej
|
||||||
|
powierzchni ataku, pełna kontrola nad tym, co wychodzi z sieci.
|
||||||
|
|
||||||
|
**Gwarancja niepustej odpowiedzi.** Horoskop ma powstać niezależnie od objętości
|
||||||
|
promptu, więc `generate()` nie jest pojedynczym strzałem, tylko pętlą:
|
||||||
|
1. wyślij turę z policzonym limitem wyjścia,
|
||||||
|
2. jeśli model urwał na limicie — dopisz turę „kontynuuj" i sklej tekst,
|
||||||
|
3. jeśli tura nie dała ani znaku tekstu — ponów z podpowiedzią,
|
||||||
|
4. dopiero brak tekstu po wszystkich próbach jest błędem (z diagnostyką).
|
||||||
|
Kontynuacja jest pewniejsza niż jedno wielkie żądanie: każda tura mieści się
|
||||||
|
w timeoucie HTTP, a długość odpowiedzi przestaje być ograniczona jedną turą.
|
||||||
|
|
||||||
|
**Anthropic i myślenie.** Modele Claude potrafią mieć włączone myślenie, którego
|
||||||
|
tokeny liczą się do `max_tokens`. Przy ciasnym limicie cała tura potrafi wyjść
|
||||||
|
jako same bloki `thinking` z pustym tekstem — dokładnie ten objaw, który
|
||||||
|
zgłoszono. Traktujemy taką turę jak ucięcie i kontynuujemy, zamiast zwracać pustkę.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.llm.base import Completion, LLMError, LLMProvider
|
||||||
|
|
||||||
|
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||||
|
MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
|
# ile razy wolno poprosić model o dokończenie urwanej odpowiedzi
|
||||||
|
MAX_CONTINUATIONS = 12
|
||||||
|
# ile tokenów zamawiać na jedną turę — mieści się w timeoucie, a pętla i tak
|
||||||
|
# dociągnie resztę; zbyt duża wartość ryzykuje zerwanie połączenia w trakcie
|
||||||
|
TURN_TOKENS_CAP = 16_000
|
||||||
|
|
||||||
|
_CONTINUE = (
|
||||||
|
"Kontynuuj dokładnie od miejsca, w którym przerwałeś — nie powtarzaj tego, "
|
||||||
|
"co już napisałeś, i nie zaczynaj od nowa. Jeśli skończyłeś całą odpowiedź, "
|
||||||
|
"napisz wyłącznie: KONIEC"
|
||||||
|
)
|
||||||
|
_NUDGE = (
|
||||||
|
"Nie otrzymałem żadnej treści. Napisz odpowiedź zgodnie z powyższym poleceniem, "
|
||||||
|
"zaczynając od razu od treści horoskopu."
|
||||||
|
)
|
||||||
|
_DONE_MARKER = "KONIEC"
|
||||||
|
|
||||||
|
|
||||||
|
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict:
|
||||||
|
"""POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx."""
|
||||||
|
last: Exception | None = None
|
||||||
|
for attempt in range(MAX_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
r = client.post(url, json=payload, headers=headers)
|
||||||
|
if r.status_code in RETRY_STATUSES and attempt < MAX_ATTEMPTS - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise LLMError(f"Model odpowiedział błędem {r.status_code}: {r.text[:300]}")
|
||||||
|
return r.json()
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
last = e
|
||||||
|
if attempt < MAX_ATTEMPTS - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise LLMError(
|
||||||
|
f"Model nie odpowiedział w czasie {timeout:.0f}s. Zwiększ LLM_TIMEOUT "
|
||||||
|
f"albo zmniejsz budżet promptu."
|
||||||
|
) from e
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
last = e
|
||||||
|
if attempt < MAX_ATTEMPTS - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise LLMError(f"Nie udało się połączyć z modelem: {e}") from e
|
||||||
|
raise LLMError(f"Nie udało się wywołać modelu: {last}")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_usage(total: dict, turn: dict) -> dict:
|
||||||
|
"""Sumuje zużycie tokenów przez wszystkie tury jednej odpowiedzi."""
|
||||||
|
for key, value in (turn or {}).items():
|
||||||
|
if isinstance(value, int):
|
||||||
|
total[key] = total.get(key, 0) + value
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _join(parts: list[str]) -> str:
|
||||||
|
return "".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _explain_empty(turns: int, usage: dict, stop: str | None) -> str:
|
||||||
|
detail = []
|
||||||
|
if stop:
|
||||||
|
detail.append(f"powód zakończenia: {stop}")
|
||||||
|
for key in ("completion_tokens", "output_tokens"):
|
||||||
|
if usage.get(key) is not None:
|
||||||
|
detail.append(f"tokeny odpowiedzi: {usage[key]}")
|
||||||
|
break
|
||||||
|
suffix = f" ({', '.join(detail)})" if detail else ""
|
||||||
|
return (
|
||||||
|
f"Model nie zwrócił żadnej treści po {turns} próbach{suffix}. "
|
||||||
|
f"Najczęstsza przyczyna: prompt wypełnił okno kontekstu i nie zostało miejsca "
|
||||||
|
f"na odpowiedź. Zmniejsz budżet promptu albo wybierz model z większym oknem."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Driver:
|
||||||
|
"""Wspólna pętla: tura → ewentualna kontynuacja → sklejony tekst.
|
||||||
|
|
||||||
|
Podklasy dostarczają tylko `_turn()` — reszta (kontynuacje, ponawianie pustej
|
||||||
|
tury, sumowanie zużycia) jest identyczna dla obu protokołów.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
model: str
|
||||||
|
leaves_lan: bool
|
||||||
|
|
||||||
|
def _turn(self, messages: list[dict], max_tokens: int):
|
||||||
|
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def generate(self, prompt: str, max_tokens: int, on_event=None) -> Completion:
|
||||||
|
"""`on_event(dict)` dostaje zdarzenia postępu — UI pokazuje z nich log.
|
||||||
|
Raportujemy KAŻDĄ turę, bo to ona trwa; bez tego pasek postępu byłby
|
||||||
|
ozdobnikiem, a nie informacją."""
|
||||||
|
def emit(kind: str, message: str, **extra):
|
||||||
|
if on_event:
|
||||||
|
on_event({"type": kind, "message": message, **extra})
|
||||||
|
|
||||||
|
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))
|
||||||
|
emit("turn_start",
|
||||||
|
f"Tura {turns}: wysyłam do modelu {self.model} (limit {budget} tokenów)…",
|
||||||
|
turn=turns)
|
||||||
|
started = time.monotonic()
|
||||||
|
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
|
||||||
|
took = time.monotonic() - started
|
||||||
|
_merge_usage(usage, turn_usage)
|
||||||
|
|
||||||
|
# Odejmujemy tokeny FAKTYCZNIE wyprodukowane, nie zamówiony limit tury.
|
||||||
|
# Inaczej pierwsza tura zjadałaby cały budżet i urwana odpowiedź nigdy
|
||||||
|
# nie doczekałaby się kontynuacji — wracałby do użytkownika fragment
|
||||||
|
# udający całość.
|
||||||
|
produced = (turn_usage or {}).get("completion_tokens")
|
||||||
|
if produced is None:
|
||||||
|
produced = (turn_usage or {}).get("output_tokens")
|
||||||
|
if produced is None:
|
||||||
|
produced = max(1, int(len(text) / 3.6))
|
||||||
|
remaining -= max(1, int(produced))
|
||||||
|
|
||||||
|
emit("turn_end",
|
||||||
|
f"Tura {turns}: odebrano {len(text.strip())} znaków w {took:.1f}s"
|
||||||
|
+ (" — odpowiedź urwana, poproszę o dokończenie" if truncated else ""),
|
||||||
|
turn=turns, chars=len(text.strip()), truncated=truncated)
|
||||||
|
|
||||||
|
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))
|
||||||
|
emit("generated", f"Gotowe: {len(final)} znaków w {turns} turach.",
|
||||||
|
chars=len(final), turns=turns)
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, base_url: str, model: str, api_key: str = "",
|
||||||
|
timeout: float = 120.0, leaves_lan: bool = True) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.leaves_lan = leaves_lan
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
h = {"Content-Type": "application/json"}
|
||||||
|
if self.api_key:
|
||||||
|
h["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
return h
|
||||||
|
|
||||||
|
def _turn(self, messages: list[dict], max_tokens: int):
|
||||||
|
data = _post_with_retry(
|
||||||
|
f"{self.base_url}/chat/completions", self._headers(),
|
||||||
|
{"model": self.model, "max_tokens": max_tokens, "messages": messages},
|
||||||
|
self.timeout,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
choice = data["choices"][0]
|
||||||
|
text = choice["message"].get("content") or ""
|
||||||
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
|
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||||
|
stop = choice.get("finish_reason")
|
||||||
|
return (text, stop == "length", data.get("usage") or {},
|
||||||
|
data.get("model", self.model), stop)
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=min(self.timeout, 10.0)) as client:
|
||||||
|
r = client.get(f"{self.base_url}/models", headers=self._headers())
|
||||||
|
info["status"] = "ok" if r.status_code < 400 else f"http {r.status_code}"
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
info["status"] = f"down: {e}"
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicProvider(_Driver, LLMProvider):
|
||||||
|
"""Protokół Anthropic `/v1/messages`."""
|
||||||
|
|
||||||
|
leaves_lan = True
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, model: str, api_key: str = "",
|
||||||
|
timeout: float = 120.0) -> None:
|
||||||
|
self.name = "anthropic"
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": self.api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _thinking(self) -> dict:
|
||||||
|
"""Konfiguracja myślenia. Domyślnie adaptacyjne — podnosi jakość tekstu.
|
||||||
|
|
||||||
|
UWAGA: tokeny myślenia liczą się do `max_tokens`, więc przy ciasnym limicie
|
||||||
|
cała tura potrafi wyjść jako samo myślenie z pustym tekstem. Pętla
|
||||||
|
kontynuacji to obsługuje, ale ANTHROPIC_THINKING=off wyłącza myślenie,
|
||||||
|
gdy zależy nam na przewidywalnym zużyciu tokenów.
|
||||||
|
"""
|
||||||
|
mode = os.getenv("ANTHROPIC_THINKING", "adaptive").lower()
|
||||||
|
if mode in ("off", "disabled", "0", "false"):
|
||||||
|
return {"thinking": {"type": "disabled"}}
|
||||||
|
return {
|
||||||
|
"thinking": {"type": "adaptive"},
|
||||||
|
"output_config": {"effort": os.getenv("ANTHROPIC_EFFORT", "high")},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _turn(self, messages: list[dict], max_tokens: int):
|
||||||
|
if not self.api_key:
|
||||||
|
raise LLMError("Brak ANTHROPIC_API_KEY — dostawca anthropic wymaga klucza.")
|
||||||
|
payload = {"model": self.model, "max_tokens": max_tokens, "messages": messages}
|
||||||
|
payload.update(self._thinking())
|
||||||
|
data = _post_with_retry(f"{self.base_url}/v1/messages", self._headers(),
|
||||||
|
payload, self.timeout)
|
||||||
|
try:
|
||||||
|
blocks = data["content"]
|
||||||
|
text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||||
|
|
||||||
|
stop = data.get("stop_reason")
|
||||||
|
# tura złożona z samego myślenia = budżet poszedł na rozumowanie; traktujemy
|
||||||
|
# jak ucięcie, żeby pętla poprosiła o treść zamiast zwrócić pustkę
|
||||||
|
thinking_only = not text.strip() and any(
|
||||||
|
b.get("type") in ("thinking", "redacted_thinking") for b in blocks
|
||||||
|
)
|
||||||
|
return (text, stop == "max_tokens" or thinking_only, data.get("usage") or {},
|
||||||
|
data.get("model", self.model), stop)
|
||||||
|
|
||||||
|
def count_tokens(self, prompt: str) -> int:
|
||||||
|
"""Dokładny licznik Anthropic — nie szacunek. Od tego zależy, czy po
|
||||||
|
zmieszczeniu promptu zostanie miejsce na odpowiedź."""
|
||||||
|
if not self.api_key:
|
||||||
|
return super().count_tokens(prompt)
|
||||||
|
try:
|
||||||
|
data = _post_with_retry(
|
||||||
|
f"{self.base_url}/v1/messages/count_tokens", self._headers(),
|
||||||
|
{"model": self.model, "messages": [{"role": "user", "content": prompt}]},
|
||||||
|
min(self.timeout, 30.0),
|
||||||
|
)
|
||||||
|
return int(data.get("input_tokens") or super().count_tokens(prompt))
|
||||||
|
except LLMError:
|
||||||
|
return super().count_tokens(prompt)
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
return {
|
||||||
|
"provider": self.name, "model": self.model, "leaves_lan": True,
|
||||||
|
"status": "ok (klucz ustawiony)" if self.api_key else "brak ANTHROPIC_API_KEY",
|
||||||
|
}
|
||||||
+287
-5
@@ -12,12 +12,17 @@ import httpx
|
|||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app import link_crypto, 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)
|
||||||
|
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
|
||||||
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
|
link_crypto.install(app, link_crypto.ENV_PRESENTATION_LOGIC, "logiczna")
|
||||||
|
|
||||||
# --- 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
|
||||||
@@ -39,6 +44,8 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/query", response_model=QueryResponse)
|
@app.post("/api/query", response_model=QueryResponse)
|
||||||
@@ -58,7 +65,10 @@ def chart_positions(req: PositionsRequest) -> dict:
|
|||||||
|
|
||||||
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)
|
||||||
chart = build_chart(engine, moment, req.house_system)
|
try:
|
||||||
|
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
if req.stations:
|
if req.stations:
|
||||||
from app.engine.stations import find_stations
|
from app.engine.stations import find_stations
|
||||||
|
|
||||||
@@ -66,6 +76,10 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -110,13 +124,248 @@ def chart_report(req: ReportRequest) -> dict:
|
|||||||
try:
|
try:
|
||||||
report = build_report(
|
report = build_report(
|
||||||
chart["positions"], DataClient(),
|
chart["positions"], DataClient(),
|
||||||
aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
|
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
||||||
|
per_object_limit=req.limit, group=req.group,
|
||||||
)
|
)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
||||||
return {"engine": engine.name, **report}
|
return {"engine": engine.name, **report}
|
||||||
|
|
||||||
|
|
||||||
|
class PromptRequest(BaseModel):
|
||||||
|
"""Wejście generatora promptu (LOG-29/30)."""
|
||||||
|
profile: str = "natal" # natal | period
|
||||||
|
when_utc: datetime
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
budget: str = "medium" # concise | medium | extensive | huge | max
|
||||||
|
limit: int = 5000
|
||||||
|
provider: str | None = None # do wyliczenia budżetu „max" wg okna modelu
|
||||||
|
model: str | None = None
|
||||||
|
# tylko dla profilu period:
|
||||||
|
from_date: str | None = None
|
||||||
|
to_date: str | None = None
|
||||||
|
techniques: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/prompt")
|
||||||
|
def chart_prompt(req: PromptRequest) -> dict:
|
||||||
|
"""Gotowy prompt do LLM z naszych wyliczeń (LOG-29) z budżetowaniem (LOG-30).
|
||||||
|
|
||||||
|
profile=natal → horoskop urodzeniowy (ekran Interpretacje)
|
||||||
|
profile=period → horoskop na wybrany okres (ekran Kalendarz)
|
||||||
|
|
||||||
|
Nie woła żadnego modelu — zwraca sam prompt i statystyki redukcji, żeby dało się
|
||||||
|
go obejrzeć i skopiować. Wysyłkę do modelu doda LOG-31.
|
||||||
|
"""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.prompt import CHARS_PER_TOKEN, MAX_BUDGET, build_natal_prompt, build_period_prompt
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
chart = build_chart(engine, moment)
|
||||||
|
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
data_error = None
|
||||||
|
|
||||||
|
# Budżet „maksymalny kontekst modelu": limit znaków liczymy z okna kontekstu
|
||||||
|
# WYBRANEGO modelu, zawsze po odjęciu miejsca zarezerwowanego na odpowiedź.
|
||||||
|
budget_chars = None
|
||||||
|
if req.budget == MAX_BUDGET:
|
||||||
|
from app.llm.factory import resolve_model
|
||||||
|
from app.llm.limits import prompt_token_budget
|
||||||
|
# celowo bez build_provider(): budżet zależy TYLKO od okna kontekstu modelu,
|
||||||
|
# więc nie może wymagać skonfigurowanego klucza API
|
||||||
|
provider_name, model_name = resolve_model(req.provider, req.model)
|
||||||
|
budget_chars = int(prompt_token_budget(provider_name, model_name) * CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
# Warstwa danych dokłada wyłącznie WSKAZANIA. Wyliczenia (horoskop, oś czasu) są
|
||||||
|
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
|
||||||
|
try:
|
||||||
|
if req.profile == "natal":
|
||||||
|
from app.significators import build_report
|
||||||
|
|
||||||
|
report: dict = {"objects": []}
|
||||||
|
try:
|
||||||
|
report = build_report(
|
||||||
|
chart["positions"], DataClient(),
|
||||||
|
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
||||||
|
per_object_limit=req.limit,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
data_error = f"Warstwa danych niedostępna: {e}"
|
||||||
|
out = build_natal_prompt(chart, report, req.budget, label, budget_chars)
|
||||||
|
|
||||||
|
elif req.profile == "period":
|
||||||
|
if not (req.from_date and req.to_date):
|
||||||
|
raise HTTPException(422, "profile=period wymaga from_date i to_date")
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.timeline import build_timeline
|
||||||
|
from app.significators import interpret_events
|
||||||
|
|
||||||
|
ramc, eps = engine.sidereal(moment)
|
||||||
|
points = {"Asc": H.compute_asc(ramc, eps, moment.lat), "MC": H.compute_mc(ramc, eps)}
|
||||||
|
for p in engine.positions(moment):
|
||||||
|
points[p.name] = p.longitude
|
||||||
|
events = build_timeline(engine, moment, points, req.from_date, req.to_date,
|
||||||
|
req.techniques)
|
||||||
|
try:
|
||||||
|
interpret_events(events, DataClient())
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
|
||||||
|
out = build_period_prompt(chart, events, req.from_date, req.to_date,
|
||||||
|
req.budget, label, budget_chars)
|
||||||
|
else:
|
||||||
|
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
|
||||||
|
except ValueError as e: # nieznany budżet
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
|
||||||
|
out["engine"] = engine.name
|
||||||
|
if data_error:
|
||||||
|
out["data_error"] = data_error
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class HoroscopeRequest(PromptRequest):
|
||||||
|
"""Jak PromptRequest (niesie już provider i model) + limit wyjścia (LOG-31)."""
|
||||||
|
max_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/horoscope")
|
||||||
|
def chart_horoscope(req: HoroscopeRequest) -> dict:
|
||||||
|
"""Napisany horoskop (LOG-31): prompt z LOG-29/30 → model → gotowy tekst.
|
||||||
|
|
||||||
|
Zwraca TAKŻE użyty prompt — również gdy wywołanie modelu padnie — żeby dało się
|
||||||
|
go obejrzeć i użyć ręcznie. `leaves_lan` mówi, czy treść baz opuściła naszą sieć
|
||||||
|
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
|
||||||
|
"""
|
||||||
|
from app.llm.base import LLMError
|
||||||
|
from app.llm.factory import build_provider
|
||||||
|
from app.llm.limits import plan
|
||||||
|
|
||||||
|
out = chart_prompt(req) # ten sam prompt co w podglądzie
|
||||||
|
try:
|
||||||
|
provider = build_provider(req.provider, req.model)
|
||||||
|
|
||||||
|
# Ile tokenów ma naprawdę ten prompt i ile zostaje na odpowiedź. Anthropic
|
||||||
|
# liczy dokładnie (własny endpoint), reszta szacuje — od tego zależy, czy
|
||||||
|
# w oknie kontekstu w ogóle zmieści się miejsce na horoskop.
|
||||||
|
prompt_tokens = provider.count_tokens(out["prompt"])
|
||||||
|
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
||||||
|
out["token_plan"] = budget
|
||||||
|
if budget["warnings"]:
|
||||||
|
out["warnings"] = budget["warnings"]
|
||||||
|
if not budget["fits"]:
|
||||||
|
out["llm_error"] = " ".join(budget["warnings"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
result = provider.generate(out["prompt"], budget["max_output"])
|
||||||
|
except LLMError as e:
|
||||||
|
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
|
||||||
|
out["llm_error"] = str(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
out.update(
|
||||||
|
horoscope=result.text,
|
||||||
|
provider=result.provider,
|
||||||
|
model=result.model,
|
||||||
|
leaves_lan=result.leaves_lan,
|
||||||
|
usage=result.usage,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/horoscope/stream")
|
||||||
|
def chart_horoscope_stream(req: HoroscopeRequest):
|
||||||
|
"""To samo co /chart/horoscope, ale strumieniuje POSTĘP w trakcie pracy.
|
||||||
|
|
||||||
|
Pisanie horoskopu trwa minutami — bez sygnału aplikacja wygląda na zawieszoną.
|
||||||
|
Strumień (NDJSON, jedna linia = jedno zdarzenie) niesie RZECZYWISTE etapy:
|
||||||
|
budowę promptu, limity modelu i każdą turę generowania. Ostatnie zdarzenie
|
||||||
|
(`result`) ma identyczny kształt co odpowiedź zwykłego endpointu.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.progress import stream
|
||||||
|
|
||||||
|
def work(emit) -> dict:
|
||||||
|
from app.llm.base import LLMError
|
||||||
|
from app.llm.factory import build_provider
|
||||||
|
from app.llm.limits import plan
|
||||||
|
|
||||||
|
emit({"type": "stage", "message": "Liczę horoskop i szukam wskazań w bazach…"})
|
||||||
|
out = chart_prompt(req)
|
||||||
|
st = out.get("stats", {})
|
||||||
|
emit({"type": "stage", "message":
|
||||||
|
f"Prompt gotowy: {st.get('chars', 0)} znaków, "
|
||||||
|
f"wskazań {st.get('included', 0)}"
|
||||||
|
+ (f", pominięto {st['omitted']}" if st.get("omitted") else "")})
|
||||||
|
if out.get("data_error"):
|
||||||
|
emit({"type": "warn", "message": out["data_error"]})
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = build_provider(req.provider, req.model)
|
||||||
|
emit({"type": "stage", "message":
|
||||||
|
f"Dostawca: {provider.name}, model: {provider.model}"
|
||||||
|
+ ("" if not provider.leaves_lan else " — dane opuszczają sieć")})
|
||||||
|
|
||||||
|
emit({"type": "stage", "message": "Liczę tokeny promptu…"})
|
||||||
|
prompt_tokens = provider.count_tokens(out["prompt"])
|
||||||
|
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
||||||
|
out["token_plan"] = budget
|
||||||
|
emit({"type": "stage", "message":
|
||||||
|
f"Prompt {prompt_tokens} tok. · okno modelu {budget['context_window']} · "
|
||||||
|
f"na odpowiedź {budget['max_output']}"})
|
||||||
|
for warning in budget["warnings"]:
|
||||||
|
emit({"type": "warn", "message": warning})
|
||||||
|
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"], on_event=emit)
|
||||||
|
except LLMError as e:
|
||||||
|
emit({"type": "warn", "message": f"Model zawiódł: {e}"})
|
||||||
|
out["llm_error"] = str(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
out.update(horoscope=result.text, provider=result.provider, model=result.model,
|
||||||
|
leaves_lan=result.leaves_lan, usage=result.usage)
|
||||||
|
return out
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream(work),
|
||||||
|
media_type="application/x-ndjson",
|
||||||
|
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/llm/models")
|
||||||
|
def llm_models() -> dict:
|
||||||
|
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
||||||
|
|
||||||
|
To nie jest lista zamknięta: pole modelu jest tekstowe, więc można wpisać
|
||||||
|
dowolny identyfikator, do którego konto ma dostęp.
|
||||||
|
"""
|
||||||
|
from app.llm.catalog import catalog
|
||||||
|
from app.llm.factory import _DEFAULT_MODEL
|
||||||
|
|
||||||
|
return {"providers": catalog(), "defaults": dict(_DEFAULT_MODEL)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/llm/health")
|
||||||
|
def llm_health(provider: str | None = None) -> dict:
|
||||||
|
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
|
||||||
|
from app.llm.base import LLMError
|
||||||
|
from app.llm.factory import build_provider
|
||||||
|
|
||||||
|
try:
|
||||||
|
return build_provider(provider).health()
|
||||||
|
except LLMError as e:
|
||||||
|
return {"status": f"blad konfiguracji: {e}"}
|
||||||
|
|
||||||
|
|
||||||
class ProfectionsRequest(BaseModel):
|
class ProfectionsRequest(BaseModel):
|
||||||
when_utc: datetime # moment urodzenia (UTC)
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
lat: float = 0.0
|
lat: float = 0.0
|
||||||
@@ -173,6 +422,27 @@ def chart_return(req: ReturnRequest) -> dict:
|
|||||||
"return_utc": hit.isoformat(), **chart}
|
"return_utc": hit.isoformat(), **chart}
|
||||||
|
|
||||||
|
|
||||||
|
class FirdariaRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/firdaria")
|
||||||
|
def chart_firdaria(req: FirdariaRequest) -> dict:
|
||||||
|
"""Firdaria (LOG-11): sekta + okresy główne i podokresy time-lordów."""
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.firdaria import firdaria
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
ramc, eps = engine.sidereal(natal)
|
||||||
|
asc, mc = H.compute_asc(ramc, eps, natal.lat), H.compute_mc(ramc, eps)
|
||||||
|
sun = engine.positions(natal, ["Sun"])[0].longitude
|
||||||
|
return {"engine": engine.name, **firdaria(req.when_utc, sun, asc, mc)}
|
||||||
|
|
||||||
|
|
||||||
class TimelineRequest(BaseModel):
|
class TimelineRequest(BaseModel):
|
||||||
when_utc: datetime # moment urodzenia (UTC)
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
lat: float = 0.0
|
lat: float = 0.0
|
||||||
@@ -180,11 +450,15 @@ class TimelineRequest(BaseModel):
|
|||||||
from_date: str # zakres: YYYY-MM-DD
|
from_date: str # zakres: YYYY-MM-DD
|
||||||
to_date: str
|
to_date: str
|
||||||
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
||||||
|
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/chart/timeline")
|
@app.post("/chart/timeline")
|
||||||
def chart_timeline(req: TimelineRequest) -> dict:
|
def chart_timeline(req: TimelineRequest) -> dict:
|
||||||
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end."""
|
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.
|
||||||
|
|
||||||
|
Z interpret=true dopina do zdarzeń interpretacje z warstwy danych (LOG-19, 1B->2B).
|
||||||
|
"""
|
||||||
from app.engine import houses as H
|
from app.engine import houses as H
|
||||||
from app.engine.models import ChartMoment
|
from app.engine.models import ChartMoment
|
||||||
from app.engine.timeline import build_timeline
|
from app.engine.timeline import build_timeline
|
||||||
@@ -196,8 +470,16 @@ def chart_timeline(req: TimelineRequest) -> dict:
|
|||||||
for p in engine.positions(natal):
|
for p in engine.positions(natal):
|
||||||
points[p.name] = p.longitude
|
points[p.name] = p.longitude
|
||||||
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
|
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
|
||||||
return {"engine": engine.name, "from": req.from_date, "to": req.to_date,
|
|
||||||
"count": len(events), "events": events}
|
out = {"engine": engine.name, "from": req.from_date, "to": req.to_date}
|
||||||
|
if req.interpret:
|
||||||
|
from app.significators import interpret_events
|
||||||
|
try:
|
||||||
|
interpret_events(events, DataClient())
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
out["data_error"] = f"Warstwa danych niedostępna: {e}"
|
||||||
|
out.update(count=len(events), events=events)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Strumień postępu długiej operacji (NDJSON).
|
||||||
|
|
||||||
|
Po co: pisanie horoskopu trwa — czasem minuty. Bez sygnału aplikacja wygląda na
|
||||||
|
zawieszoną. Zamiast udawanego paska postępu strumieniujemy **rzeczywiste**
|
||||||
|
zdarzenia z kolejnych etapów, żeby log pokazywał to, co faktycznie się dzieje.
|
||||||
|
|
||||||
|
Dlaczego NDJSON, a nie SSE: `EventSource` w przeglądarce obsługuje wyłącznie GET,
|
||||||
|
a to jest POST z ciałem. Strumień „jedna linia = jeden obiekt JSON" czyta się
|
||||||
|
zwykłym `fetch()` i jest trywialny do sparsowania.
|
||||||
|
|
||||||
|
Dlaczego wątek: właściwa praca (silnik, baza, model) jest synchroniczna. Puszczamy
|
||||||
|
ją w wątku roboczym, a generator odpompowuje kolejkę zdarzeń — dzięki temu
|
||||||
|
zdarzenia docierają w trakcie pracy, a nie dopiero na końcu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
_HEARTBEAT_SECONDS = 10.0
|
||||||
|
_DONE = object()
|
||||||
|
|
||||||
|
|
||||||
|
def line(kind: str, message: str, **extra: Any) -> str:
|
||||||
|
return json.dumps({"type": kind, "message": message, **extra}, ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def stream(work: Callable[[Callable[[dict], None]], dict]) -> Iterator[str]:
|
||||||
|
"""Uruchamia `work(emit)` w wątku i strumieniuje zdarzenia w czasie rzeczywistym.
|
||||||
|
|
||||||
|
`work` dostaje funkcję `emit(zdarzenie)` i zwraca końcowy wynik, który leci
|
||||||
|
jako ostatnie zdarzenie typu `result`. Wyjątek zamienia się w zdarzenie `error`
|
||||||
|
— połączenie nigdy nie urywa się bez wyjaśnienia.
|
||||||
|
"""
|
||||||
|
events: queue.Queue = queue.Queue()
|
||||||
|
|
||||||
|
def emit(event: dict) -> None:
|
||||||
|
events.put(event)
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
try:
|
||||||
|
result = work(emit)
|
||||||
|
events.put({"type": "result", "message": "Gotowe.", "result": result})
|
||||||
|
except Exception as e: # noqa: BLE001 — zgłaszamy KAŻDY błąd
|
||||||
|
events.put({
|
||||||
|
"type": "error",
|
||||||
|
"message": f"{type(e).__name__}: {e}",
|
||||||
|
"detail": traceback.format_exc(limit=3),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
events.put(_DONE)
|
||||||
|
|
||||||
|
worker = threading.Thread(target=run, daemon=True)
|
||||||
|
worker.start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = events.get(timeout=_HEARTBEAT_SECONDS)
|
||||||
|
except queue.Empty:
|
||||||
|
# cisza dłuższa niż heartbeat: dajemy znak życia, żeby pośredniki
|
||||||
|
# (proxy, load balancer) nie uznały połączenia za martwe
|
||||||
|
yield line("ping", "…")
|
||||||
|
continue
|
||||||
|
if event is _DONE:
|
||||||
|
break
|
||||||
|
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Generator promptów do LLM (LOG-29) + budżetowanie rozmiaru (LOG-30).
|
||||||
|
|
||||||
|
Składa z naszych wyliczeń gotowe, profesjonalne polecenie po polsku:
|
||||||
|
- profil **natal** — horoskop urodzeniowy (na bazie /chart/report),
|
||||||
|
- profil **period** — horoskop na wybrany okres (na bazie /chart/timeline).
|
||||||
|
|
||||||
|
Zasada naczelna promptu: model ma pisać WYŁĄCZNIE na podstawie dostarczonych danych
|
||||||
|
i przy każdej tezie wskazać konkretny sygnifikator, z którego ona wynika. To odróżnia
|
||||||
|
wynik od ogólnikowej wróżby i pozwala go zweryfikować.
|
||||||
|
|
||||||
|
Budżetowanie (LOG-30) — kolejność redukcji:
|
||||||
|
1. deduplikacja (ten sam sygnifikator ORAZ ten sam opis),
|
||||||
|
2. grupowanie identycznych opisów z licznikiem wystąpień,
|
||||||
|
3. sortowanie malejąco wg punktacji siły (LOG-21),
|
||||||
|
4. obcięcie ogona do budżetu — jednostką obcięcia jest CAŁE wskazanie,
|
||||||
|
5. skracanie nadmiernie długich opisów (z jawnym oznaczeniem).
|
||||||
|
Zawsze raportujemy, ile wskazań weszło i ile pominięto — użytkownik ma wiedzieć,
|
||||||
|
czego brakuje, i móc zwiększyć budżet.
|
||||||
|
|
||||||
|
Generowanie jest deterministyczne: ten sam horoskop + ten sam budżet = ten sam prompt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# budżety w znakach (całego promptu); dobrane pod wklejanie do ChatGPT/Claude
|
||||||
|
BUDGETS: dict[str, int] = {
|
||||||
|
"concise": 4_000,
|
||||||
|
"medium": 12_000,
|
||||||
|
"extensive": 30_000,
|
||||||
|
"huge": 120_000,
|
||||||
|
# „maksymalny kontekst modelu" — wyliczany dynamicznie z okna kontekstu
|
||||||
|
# wybranego modelu, ZAWSZE po odjęciu miejsca zarezerwowanego na odpowiedź.
|
||||||
|
# Wartość poniżej jest tylko zapasem, gdy limity modelu są nieznane.
|
||||||
|
"max": 400_000,
|
||||||
|
}
|
||||||
|
DEFAULT_BUDGET = "medium"
|
||||||
|
MAX_BUDGET = "max"
|
||||||
|
|
||||||
|
MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji)
|
||||||
|
CHARS_PER_TOKEN = 4.0 # zgrubny szacunek tokenów do podglądu w UI
|
||||||
|
|
||||||
|
DISCLAIMER = (
|
||||||
|
"Zakończ krótką notą: treść jest interpretacją astrologiczną i nie stanowi porady "
|
||||||
|
"medycznej, prawnej ani finansowej."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(text: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", (text or "").strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def est_tokens(text: str) -> int:
|
||||||
|
return int(len(text) / CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def _shorten(text: str, limit: int = MAX_EFFECT_CHARS) -> tuple[str, bool]:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text, False
|
||||||
|
return text[:limit].rstrip() + " […skrócono]", True
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- wskazania
|
||||||
|
|
||||||
|
def natal_indications(report: dict) -> list[dict]:
|
||||||
|
"""Płaska lista wskazań z raportu natalnego: obiekt + faseta + opis + waga."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for obj in report.get("objects") or []:
|
||||||
|
name = obj.get("object")
|
||||||
|
for facet in obj.get("facets") or []:
|
||||||
|
score = float(facet.get("score") or 0.0)
|
||||||
|
for s in facet.get("samples") or []:
|
||||||
|
out.append({
|
||||||
|
"context": f"{name} — {facet.get('label')}",
|
||||||
|
"sort_key": (name or "", str(facet.get("label") or "")),
|
||||||
|
"score": score,
|
||||||
|
"significator": s.get("expanded") or s.get("significator") or "",
|
||||||
|
"effect": s.get("effect") or "",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def period_indications(events: list[dict]) -> list[dict]:
|
||||||
|
"""Wskazania z osi czasu — waga rośnie z liczbą trafień w bazie."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for ev in events or []:
|
||||||
|
head = f"{ev.get('technique')} · {ev.get('significator')} · {ev.get('exact')}"
|
||||||
|
total = float(ev.get("interpretations_count") or 0)
|
||||||
|
for s in ev.get("interpretations") or []:
|
||||||
|
out.append({
|
||||||
|
"context": head,
|
||||||
|
"sort_key": (str(ev.get("exact") or ""), str(ev.get("technique") or "")),
|
||||||
|
"score": total,
|
||||||
|
"significator": s.get("expanded") or s.get("significator") or "",
|
||||||
|
"effect": s.get("effect") or "",
|
||||||
|
"date": ev.get("exact"),
|
||||||
|
"start": ev.get("start"),
|
||||||
|
"end": ev.get("end"),
|
||||||
|
"technique": ev.get("technique"),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def reduce_indications(items: list[dict], budget_chars: int) -> tuple[list[dict], dict]:
|
||||||
|
"""Dedup → grupowanie → sortowanie wg wagi → obcięcie do budżetu.
|
||||||
|
|
||||||
|
Zwraca (wybrane wskazania, statystyki). Jednostką obcięcia jest całe wskazanie.
|
||||||
|
"""
|
||||||
|
# 1+2: dedup i grupowanie identycznych (kontekst, opis) z licznikiem
|
||||||
|
grouped: dict[tuple[str, str], dict] = {}
|
||||||
|
order: list[tuple[str, str]] = []
|
||||||
|
for it in items:
|
||||||
|
key = (_norm(it["context"]), _norm(it["effect"]))
|
||||||
|
g = grouped.get(key)
|
||||||
|
if g is None:
|
||||||
|
g = {**it, "count": 0, "significators": []}
|
||||||
|
grouped[key] = g
|
||||||
|
order.append(key)
|
||||||
|
g["count"] += 1
|
||||||
|
if it["significator"] not in g["significators"]:
|
||||||
|
g["significators"].append(it["significator"])
|
||||||
|
merged = [grouped[k] for k in order]
|
||||||
|
deduped = len(items) - len(merged)
|
||||||
|
|
||||||
|
# 3: sortowanie malejąco wg wagi; remis rozstrzygany deterministycznie
|
||||||
|
merged.sort(key=lambda g: (-g["score"], -g["count"], g["sort_key"], g["significator"]))
|
||||||
|
|
||||||
|
# 5 (przed obcięciem, bo wpływa na rozmiar): skracanie długich opisów
|
||||||
|
shortened = 0
|
||||||
|
for g in merged:
|
||||||
|
g["effect"], was = _shorten(g["effect"])
|
||||||
|
shortened += 1 if was else 0
|
||||||
|
|
||||||
|
# 4: obcięcie ogona do budżetu — cała pozycja albo nic
|
||||||
|
chosen: list[dict] = []
|
||||||
|
used = 0
|
||||||
|
for g in merged:
|
||||||
|
cost = len(_render_indication(g)) + 1
|
||||||
|
if used + cost > budget_chars and chosen:
|
||||||
|
break
|
||||||
|
chosen.append(g)
|
||||||
|
used += cost
|
||||||
|
|
||||||
|
omitted = len(merged) - len(chosen)
|
||||||
|
stats = {
|
||||||
|
"source_rows": len(items),
|
||||||
|
"after_grouping": len(merged),
|
||||||
|
"deduplicated": deduped,
|
||||||
|
"included": len(chosen),
|
||||||
|
"omitted": omitted,
|
||||||
|
"shortened": shortened,
|
||||||
|
"min_score_included": round(chosen[-1]["score"], 3) if chosen else None,
|
||||||
|
"max_score_omitted": round(merged[len(chosen)]["score"], 3) if omitted else None,
|
||||||
|
}
|
||||||
|
return chosen, stats
|
||||||
|
|
||||||
|
|
||||||
|
def _render_indication(g: dict) -> str:
|
||||||
|
times = f" (×{g['count']})" if g.get("count", 1) > 1 else ""
|
||||||
|
sig = g.get("significator") or ""
|
||||||
|
return f"- {sig} → {g.get('effect')}{times}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_grouped(chosen: list[dict]) -> str:
|
||||||
|
"""Renderuje wskazania pogrupowane po kontekście, zachowując kolejność wagi."""
|
||||||
|
blocks: list[str] = []
|
||||||
|
seen: dict[str, list[dict]] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for g in chosen:
|
||||||
|
ctx = g["context"]
|
||||||
|
if ctx not in seen:
|
||||||
|
seen[ctx] = []
|
||||||
|
order.append(ctx)
|
||||||
|
seen[ctx].append(g)
|
||||||
|
for ctx in order:
|
||||||
|
rows = seen[ctx]
|
||||||
|
blocks.append(f"## {ctx} [waga {rows[0]['score']:.2f}]")
|
||||||
|
blocks.extend(_render_indication(g) for g in rows)
|
||||||
|
blocks.append("")
|
||||||
|
return "\n".join(blocks).rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- sekcje danych
|
||||||
|
|
||||||
|
def _chart_section(chart: dict, moment_label: str | None) -> str:
|
||||||
|
lines: list[str] = ["# DANE HOROSKOPU"]
|
||||||
|
meta = []
|
||||||
|
if moment_label:
|
||||||
|
meta.append(f"moment: {moment_label}")
|
||||||
|
if chart.get("zodiac"):
|
||||||
|
z = chart["zodiac"]
|
||||||
|
if chart.get("ayanamsha") is not None:
|
||||||
|
z += f" (ayanamsa {chart['ayanamsha']:.4f}°)"
|
||||||
|
meta.append(f"zodiak: {z}")
|
||||||
|
if chart.get("house_system"):
|
||||||
|
meta.append(f"system domów: {chart['house_system']}")
|
||||||
|
if chart.get("sect"):
|
||||||
|
meta.append(f"sekta: {'dzienna' if chart['sect'] == 'day' else 'nocna'}")
|
||||||
|
if meta:
|
||||||
|
lines.append(" · ".join(meta))
|
||||||
|
|
||||||
|
if chart.get("positions"):
|
||||||
|
lines.append("\n## Pozycje")
|
||||||
|
for p in chart["positions"]:
|
||||||
|
house = f"dom {p['house']}" if p.get("house") else "—"
|
||||||
|
lines.append(f"{p['name']:<12} {p.get('in_sign',''):<16} {house:<8} {p.get('direction','')}")
|
||||||
|
|
||||||
|
angles = chart.get("angles") or {}
|
||||||
|
if angles:
|
||||||
|
lines.append("\n## Osie")
|
||||||
|
for key in ("Asc", "MC", "Dsc", "IC"):
|
||||||
|
a = angles.get(key)
|
||||||
|
if a:
|
||||||
|
lines.append(f"{a['name']:<5} {a.get('in_sign','')}")
|
||||||
|
|
||||||
|
if chart.get("lots"):
|
||||||
|
lines.append("\n## Lots (punkty arabskie)")
|
||||||
|
for lot in chart["lots"]:
|
||||||
|
lines.append(
|
||||||
|
f"{lot['name']:<10} {lot.get('in_sign',''):<16} dom {lot.get('house','—')}"
|
||||||
|
f" ({lot.get('formula','')})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if chart.get("aspects"):
|
||||||
|
lines.append("\n## Aspekty (orb; A = aplikacyjny, S = separacyjny)")
|
||||||
|
for a in chart["aspects"]:
|
||||||
|
mark = a.get("as") or ""
|
||||||
|
lines.append(
|
||||||
|
f"{a['obj1']} {a['aspect']} {a['obj2']} orb {a.get('orb', 0):.2f}° {mark}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _indications_section(chosen: list[dict], stats: dict) -> str:
|
||||||
|
if not chosen:
|
||||||
|
return (
|
||||||
|
"# WSKAZANIA Z BAZ\n"
|
||||||
|
"(brak trafień w bazach dla tego horoskopu — oprzyj interpretację wyłącznie "
|
||||||
|
"na danych horoskopu powyżej)"
|
||||||
|
)
|
||||||
|
head = [
|
||||||
|
"# WSKAZANIA Z BAZ INTERPRETACYJNYCH",
|
||||||
|
"Wskazania dopasowane do tego horoskopu, uporządkowane od najsilniejszych.",
|
||||||
|
"Zapis: `- sygnifikator → opis (×ile razy wystąpiło w bazach)`.",
|
||||||
|
]
|
||||||
|
if stats.get("omitted"):
|
||||||
|
head.append(
|
||||||
|
f"UWAGA: pokazano {stats['included']} najsilniejszych wskazań, pominięto "
|
||||||
|
f"{stats['omitted']} słabszych (limit długości)."
|
||||||
|
)
|
||||||
|
return "\n".join(head) + "\n\n" + _render_grouped(chosen)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ prompty
|
||||||
|
|
||||||
|
_NATAL_TASK = """# ZADANIE
|
||||||
|
Jesteś doświadczonym astrologiem. Napisz profesjonalną interpretację HOROSKOPU
|
||||||
|
URODZENIOWEGO po polsku, opierając się wyłącznie na danych podanych niżej."""
|
||||||
|
|
||||||
|
_PERIOD_TASK = """# ZADANIE
|
||||||
|
Jesteś doświadczonym astrologiem. Napisz profesjonalną prognozę astrologiczną
|
||||||
|
NA WYBRANY OKRES po polsku, opierając się wyłącznie na danych podanych niżej."""
|
||||||
|
|
||||||
|
_NATAL_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
|
||||||
|
1. Struktura: (a) portret ogólny, (b) temperament i sekta, (c) obszary życia według domów,
|
||||||
|
(d) napięcia i wyzwania, (e) zasoby i mocne strony, (f) zwięzłe podsumowanie.
|
||||||
|
2. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie, np. „(Moon in 12th house)".
|
||||||
|
Teza bez wskazania jest niedopuszczalna.
|
||||||
|
3. Nie dodawaj twierdzeń, których nie da się wywieść z powyższych danych. Nie zmyślaj
|
||||||
|
pozycji, aspektów ani wskazań; nie korzystaj z wiedzy spoza tego promptu.
|
||||||
|
4. Gdy wskazania są sprzeczne, powiedz to wprost i wskaż obie strony, zamiast wybierać jedną.
|
||||||
|
5. Wagę wskazania traktuj jako siłę świadectwa — mocniejsze mają pierwszeństwo w syntezie.
|
||||||
|
6. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
|
||||||
|
|
||||||
|
_PERIOD_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
|
||||||
|
1. Uporządkuj prognozę CHRONOLOGICZNIE; przy każdym okresie podaj daty (start / dokładna / koniec).
|
||||||
|
2. Dla każdej daty napisz, która technika ją wyznacza (profekcja, solariusz, dyrekcja solar-arc,
|
||||||
|
Firdaria) i co z niej wynika.
|
||||||
|
3. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie. Teza bez wskazania jest
|
||||||
|
niedopuszczalna.
|
||||||
|
4. Nie zmyślaj dat ani zdarzeń spoza podanych. Nie korzystaj z wiedzy spoza tego promptu.
|
||||||
|
5. Rozróżniaj okresy o mocnym świadectwie (wysoka waga, kilka technik zbieżnych w czasie)
|
||||||
|
od słabych — i powiedz wprost, które są które.
|
||||||
|
6. Na końcu dodaj krótkie zestawienie: najważniejsze okresy w kolejności ważności.
|
||||||
|
7. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble(task: str, chart_sec: str, ind_sec: str, output: str) -> str:
|
||||||
|
return "\n\n".join([task, chart_sec, ind_sec, output, f"# ZASTRZEŻENIE\n{DISCLAIMER}"])
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_chars(budget: str, budget_chars: int | None = None) -> int:
|
||||||
|
"""Limit znaków promptu. `budget_chars` nadpisuje tabelę — używane dla opcji
|
||||||
|
„maksymalny kontekst modelu", gdzie limit zależy od wybranego modelu i musi
|
||||||
|
być policzony po odjęciu miejsca zarezerwowanego na odpowiedź."""
|
||||||
|
if budget not in BUDGETS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
|
||||||
|
)
|
||||||
|
if budget_chars and budget_chars > 0:
|
||||||
|
return budget_chars
|
||||||
|
return BUDGETS[budget]
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(prompt: str, budget: str, limit: int, stats: dict, profile: str) -> dict:
|
||||||
|
stats = {
|
||||||
|
**stats,
|
||||||
|
"profile": profile,
|
||||||
|
"budget": budget,
|
||||||
|
"limit_chars": limit,
|
||||||
|
"chars": len(prompt),
|
||||||
|
"est_tokens": est_tokens(prompt),
|
||||||
|
}
|
||||||
|
return {"profile": profile, "prompt": prompt, "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
|
||||||
|
moment_label: str | None = None,
|
||||||
|
budget_chars: int | None = None) -> dict:
|
||||||
|
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
|
||||||
|
limit = _budget_chars(budget, budget_chars)
|
||||||
|
chart_sec = _chart_section(chart, moment_label)
|
||||||
|
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
|
||||||
|
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
|
||||||
|
prompt = _assemble(_NATAL_TASK, chart_sec, _indications_section(chosen, stats), _NATAL_OUTPUT)
|
||||||
|
return _finish(prompt, budget, limit, stats, "natal")
|
||||||
|
|
||||||
|
|
||||||
|
def build_period_prompt(chart: dict, events: list[dict], from_date: str, to_date: str,
|
||||||
|
budget: str = DEFAULT_BUDGET, moment_label: str | None = None,
|
||||||
|
budget_chars: int | None = None) -> dict:
|
||||||
|
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
|
||||||
|
limit = _budget_chars(budget, budget_chars)
|
||||||
|
chart_sec = _chart_section(chart, moment_label)
|
||||||
|
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date} — {to_date}**."
|
||||||
|
|
||||||
|
ev_lines = ["# OŚ CZASU (techniki predykcyjne)",
|
||||||
|
"Zapis: technika · sygnifikator · start → dokładna → koniec."]
|
||||||
|
for ev in events or []:
|
||||||
|
ev_lines.append(
|
||||||
|
f"- {ev.get('technique')} · {ev.get('significator')} · "
|
||||||
|
f"{ev.get('start')} → {ev.get('exact')} → {ev.get('end')}"
|
||||||
|
)
|
||||||
|
events_sec = "\n".join(ev_lines)
|
||||||
|
|
||||||
|
fixed = len(task) + len(chart_sec) + len(events_sec) + len(_PERIOD_OUTPUT) + len(DISCLAIMER) + 200
|
||||||
|
chosen, stats = reduce_indications(period_indications(events), max(limit - fixed, 500))
|
||||||
|
body = events_sec + "\n\n" + _indications_section(chosen, stats)
|
||||||
|
prompt = _assemble(task, chart_sec, body, _PERIOD_OUTPUT)
|
||||||
|
stats["events"] = len(events or [])
|
||||||
|
return _finish(prompt, budget, limit, stats, "period")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""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)
|
||||||
@@ -22,6 +22,8 @@ PLANET_ABBR = {
|
|||||||
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
|
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
|
||||||
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith)
|
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith)
|
||||||
"North Node": "NN", "South Node": "SN", "Lilith": "Lilith",
|
"North Node": "NN", "South Node": "SN", "Lilith": "Lilith",
|
||||||
|
# Lots — w bazie Fortuna występuje jako [PF (Part of Fortune)
|
||||||
|
"Fortune": "PF", "Spirit": "Spirit",
|
||||||
}
|
}
|
||||||
SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
|
SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
|
||||||
|
|
||||||
@@ -112,17 +114,24 @@ def build_report(
|
|||||||
positions: list[dict],
|
positions: list[dict],
|
||||||
data: DataSource,
|
data: DataSource,
|
||||||
aspects: list[dict] | None = None,
|
aspects: list[dict] | None = None,
|
||||||
|
parallels: list[dict] | None = None,
|
||||||
per_object_limit: int = 5000,
|
per_object_limit: int = 5000,
|
||||||
group: bool = False,
|
group: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""positions: pozycje z build_chart (name, sign, direction, house).
|
"""positions: pozycje z build_chart (name, sign, direction, house).
|
||||||
|
|
||||||
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
|
Dla każdego obiektu fasety: „w znaku", „w domu", „w aspekcie" (dla każdego
|
||||||
aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty
|
aspektu głównego z listy `aspects`) oraz „paralela deklinacji" (dla paraleli
|
||||||
(ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety.
|
z listy `parallels` — LOG-07). Duplikaty (ten sam sygnifikator i opis) są
|
||||||
|
odsiewane wewnątrz każdej fasety.
|
||||||
"""
|
"""
|
||||||
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
|
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
|
||||||
|
|
||||||
|
# W bazie paralela deklinacji zapisana jest jako fraza „P. Dec." (nie token [).
|
||||||
|
# UWAGA: samo „Dec." w bazie to CZĘSTO dekanat („3rd Dec. of [Gem"), więc
|
||||||
|
# szukamy dokładnie „P. Dec.", inaczej sypnęłoby fałszywymi trafieniami.
|
||||||
|
PARALLEL_MARK = "P. Dec."
|
||||||
|
|
||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
provider = None
|
provider = None
|
||||||
for p in positions:
|
for p in positions:
|
||||||
@@ -178,6 +187,25 @@ def build_report(
|
|||||||
"count": len(asp_samples), "samples": asp_samples,
|
"count": len(asp_samples), "samples": asp_samples,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# paralele deklinacji (LOG-07) — baza zna je jako „P. Dec.". Tylko `parallel`:
|
||||||
|
# kontrparaleli i antyscji baza nie opisuje osobnym znacznikiem.
|
||||||
|
for par in (parallels or []):
|
||||||
|
if par.get("type") != "parallel" or name not in (par.get("obj1"), par.get("obj2")):
|
||||||
|
continue
|
||||||
|
other = par["obj2"] if par["obj1"] == name else par["obj1"]
|
||||||
|
if other not in PLANET_ABBR:
|
||||||
|
continue
|
||||||
|
other_tok = "[" + PLANET_ABBR[other]
|
||||||
|
par_samples = _facet_samples(rows, [PARALLEL_MARK, other_tok])
|
||||||
|
if not par_samples: # pokazujemy tylko z trafieniami
|
||||||
|
continue
|
||||||
|
facets.append({
|
||||||
|
"type": "parallel", "label": f"paralela deklinacji z {other}",
|
||||||
|
"token": f"{PARALLEL_MARK} + {other_tok}",
|
||||||
|
"orb": par.get("orb"), "allowed": par.get("allowed"),
|
||||||
|
"count": len(par_samples), "samples": par_samples,
|
||||||
|
})
|
||||||
|
|
||||||
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
|
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
|
||||||
for f in facets:
|
for f in facets:
|
||||||
f["score"] = score_facet(f)
|
f["score"] = score_facet(f)
|
||||||
@@ -195,3 +223,61 @@ def build_report(
|
|||||||
"facets": facets,
|
"facets": facets,
|
||||||
})
|
})
|
||||||
return {"provider": provider, "objects": items}
|
return {"provider": provider, "objects": items}
|
||||||
|
|
||||||
|
|
||||||
|
def _event_tokens(event: dict) -> list[str]:
|
||||||
|
"""Tokeny bazy dla zdarzenia osi czasu (spięcie 1B→2B). Pierwszy = planeta."""
|
||||||
|
from app.engine.aspects import DB_TOKEN
|
||||||
|
|
||||||
|
if event.get("technique") == "solar_arc":
|
||||||
|
p = PLANET_ABBR.get(event.get("directed"))
|
||||||
|
a = DB_TOKEN.get(event.get("aspect"))
|
||||||
|
q = PLANET_ABBR.get(event.get("target")) # None dla Asc/MC (nie ma tokenu)
|
||||||
|
toks = []
|
||||||
|
if p:
|
||||||
|
toks.append("[" + p)
|
||||||
|
if a:
|
||||||
|
toks.append(a)
|
||||||
|
if q:
|
||||||
|
toks.append("[" + q)
|
||||||
|
return toks if p else []
|
||||||
|
if event.get("technique") == "profection":
|
||||||
|
lord = PLANET_ABBR.get(event.get("lord"))
|
||||||
|
sign = SIGN_TO_ABBR.get(event.get("sign"))
|
||||||
|
toks = []
|
||||||
|
if lord:
|
||||||
|
toks.append("[" + lord)
|
||||||
|
if sign:
|
||||||
|
toks.append("[" + sign)
|
||||||
|
return toks if lord else []
|
||||||
|
if event.get("technique") == "firdaria":
|
||||||
|
major = PLANET_ABBR.get(event.get("fd_major"))
|
||||||
|
sub = PLANET_ABBR.get(event.get("fd_sub"))
|
||||||
|
toks = []
|
||||||
|
if major:
|
||||||
|
toks.append("[" + major)
|
||||||
|
if sub:
|
||||||
|
toks.append("[" + sub)
|
||||||
|
return toks if major else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def interpret_events(events: list[dict], data: DataSource, limit: int = 4,
|
||||||
|
per_object_limit: int = 5000) -> list[dict]:
|
||||||
|
"""Dopina interpretacje z bazy do zdarzeń osi czasu (predykcyjne 1B → 2B).
|
||||||
|
|
||||||
|
Wyszukuje po tokenie planety zdarzenia i zawęża do wszystkich tokenów
|
||||||
|
(aspekt/druga planeta lub znak), z odsiewaniem szumu i duplikatów.
|
||||||
|
"""
|
||||||
|
for ev in events:
|
||||||
|
tokens = _event_tokens(ev)
|
||||||
|
if not tokens:
|
||||||
|
continue
|
||||||
|
raw = data.search(
|
||||||
|
key="significator", value=tokens[0], exact=False, limit=per_object_limit,
|
||||||
|
fields=["significator", "actioneffect", "topicresult", "bodypart"],
|
||||||
|
)
|
||||||
|
samples = _facet_samples(raw.get("rows", []), tokens)
|
||||||
|
ev["interpretations"] = samples[:limit]
|
||||||
|
ev["interpretations_count"] = len(samples)
|
||||||
|
return events
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ httpx>=0.28
|
|||||||
pydantic>=2.10
|
pydantic>=2.10
|
||||||
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
|
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
|
||||||
skyfield>=1.49
|
skyfield>=1.49
|
||||||
|
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||||
|
cryptography>=44.0
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ służą jako wyrocznia (LOG-25).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -28,4 +29,8 @@ def own_engine():
|
|||||||
try:
|
try:
|
||||||
return SkyfieldEngine()
|
return SkyfieldEngine()
|
||||||
except Exception as e: # brak efemeryd / brak sieci
|
except Exception as e: # brak efemeryd / brak sieci
|
||||||
|
# Lokalnie pomijamy (dev bez pobranego jądra), ale w CI to musi być błąd —
|
||||||
|
# inaczej testy referencyjne (walidacja względem astro.com) cicho znikają.
|
||||||
|
if os.getenv("CI"):
|
||||||
|
pytest.fail(f"CI: silnik efemeryd niedostępny — testy referencyjne muszą działać: {e}")
|
||||||
pytest.skip(f"Nie można wczytać efemeryd: {e}")
|
pytest.skip(f"Nie można wczytać efemeryd: {e}")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
||||||
from app.engine.aspects import find_aspects, separation
|
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
|
||||||
|
|
||||||
|
|
||||||
def test_separation_wraparound():
|
def test_separation_wraparound():
|
||||||
@@ -55,6 +55,28 @@ 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]
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Firdaria (LOG-11) — sekta, kolejność, długości okresów, podokresy."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.engine.firdaria import DAY_ORDER, NIGHT_ORDER, YEARS, firdaria, is_day_birth
|
||||||
|
from app.significators import _event_tokens
|
||||||
|
|
||||||
|
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||||
|
# horoskop referencyjny: Asc 112.18, MC 352.59, Sun 40.14 -> Day birth (notes3)
|
||||||
|
ASC, MC, SUN = 112.18, 352.59, 40.14
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_is_day_birth():
|
||||||
|
assert is_day_birth(SUN, ASC, MC) is True # notes3: "Day birth (Diurnal)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_birth_uses_night_order():
|
||||||
|
# Słońce po stronie IC (przeciwna półkula) -> noc
|
||||||
|
assert is_day_birth((SUN + 180) % 360, ASC, MC) is False
|
||||||
|
fd = firdaria(BIRTH, (SUN + 180) % 360, ASC, MC)
|
||||||
|
assert fd["sect"] == "night" and fd["order"] == NIGHT_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_diurnal_sequence_and_totals():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
assert fd["sect"] == "day" and fd["order"] == DAY_ORDER
|
||||||
|
lords = [p["lord"] for p in fd["periods"]]
|
||||||
|
assert lords == DAY_ORDER + ["North Node", "South Node"]
|
||||||
|
assert sum(p["years"] for p in fd["periods"]) == 75 # 70 + 3 + 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_planet_majors_have_seven_subperiods_summing_to_period():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
for p in fd["periods"]:
|
||||||
|
if p["lord"] in YEARS:
|
||||||
|
assert len(p["sub"]) == 7
|
||||||
|
assert p["sub"][0]["lord"] == p["lord"] # sub zaczyna się od władcy okresu
|
||||||
|
assert p["sub"][0]["start"] == p["start"]
|
||||||
|
assert p["sub"][-1]["end"] == p["end"]
|
||||||
|
else:
|
||||||
|
assert "sub" not in p # węzły bez podokresów
|
||||||
|
|
||||||
|
|
||||||
|
def test_periods_are_contiguous():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
for a, b in zip(fd["periods"], fd["periods"][1:]):
|
||||||
|
assert a["end"] == b["start"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_42_is_saturn_major():
|
||||||
|
# kumulatywnie: Su10 Ve8 Me13 Mo9 -> 40; Saturn 40-51 -> wiek 42 w Saturnie
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
saturn = next(p for p in fd["periods"] if p["lord"] == "Saturn")
|
||||||
|
assert saturn["start"].startswith("2024") and saturn["end"].startswith("2035")
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_firdaria():
|
||||||
|
ev = {"technique": "firdaria", "fd_major": "Saturn", "fd_sub": "Jupiter"}
|
||||||
|
assert _event_tokens(ev) == ["[Sa", "[Ju"]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
|
||||||
|
|
||||||
|
Sedno: glify muszą być KOMPLETNE (każdy obiekt/znak/aspekt, który liczymy, ma
|
||||||
|
symbol), DWUKIERUNKOWE (symbol → tekst z powrotem) i TEKSTOWE, nigdy emoji
|
||||||
|
(DAN-18) — inaczej na kosmogramie wyjdą kolorowe kwadraty zamiast czarno-białych
|
||||||
|
symboli.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine import glyphs as G
|
||||||
|
from app.engine.aspects import MAJOR
|
||||||
|
from app.engine.formats import SIGNS
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS
|
||||||
|
|
||||||
|
VS15, VS16 = "︎", "️"
|
||||||
|
|
||||||
|
|
||||||
|
def _all_glyphs():
|
||||||
|
return (list(G.PLANET.values()) + list(G.POINT.values()) + list(G.LOT.values())
|
||||||
|
+ list(G.SIGN.values()) + list(G.ASPECT.values()) + [G.RETROGRADE])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- kompletność
|
||||||
|
|
||||||
|
def test_every_default_object_has_a_glyph():
|
||||||
|
"""Każdy obiekt z kanonicznego zestawu musi mieć symbol — inaczej na kole
|
||||||
|
zostanie dziura."""
|
||||||
|
missing = [name for name in DEFAULT_OBJECTS if G.glyph_for(name) is None]
|
||||||
|
assert not missing, f"obiekty bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_sign_has_a_glyph():
|
||||||
|
missing = [s for s in SIGNS if G.sign_glyph(s) is None]
|
||||||
|
assert not missing, f"znaki bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_major_aspect_has_a_glyph():
|
||||||
|
missing = [a for a in MAJOR if G.aspect_glyph(a) is None]
|
||||||
|
assert not missing, f"aspekty główne bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_and_retrograde_from_requirement():
|
||||||
|
"""Wymóg wprost wymienia ⊗ (Fortuna) i ℞ (retrogradacja)."""
|
||||||
|
assert G.glyph_for("Fortune") == "⊗"
|
||||||
|
assert G.RETROGRADE == "℞"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ DAN-18: tekst, nie emoji
|
||||||
|
|
||||||
|
def test_no_glyph_uses_emoji_variation_selector():
|
||||||
|
"""VS16 (U+FE0F) wymusiłby prezentację emoji — nie może go być NIGDZIE."""
|
||||||
|
offenders = [g for g in _all_glyphs() if VS16 in g]
|
||||||
|
assert not offenders, f"glify z wariantem emoji (FE0F): {offenders!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signs_force_text_presentation():
|
||||||
|
"""Znaki zodiaku mają domyślnie wariant emoji → MUSZĄ nieść VS15."""
|
||||||
|
for s in SIGNS:
|
||||||
|
assert VS15 in G.sign_glyph(s), f"{s} bez wymuszenia tekstu (VS15)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_venus_mars_force_text_but_sun_does_not():
|
||||||
|
"""♀/♂ mają wariant emoji (VS15 konieczny); ☉ nie ma (VS15 zbędny)."""
|
||||||
|
assert VS15 in G.glyph_for("Venus")
|
||||||
|
assert VS15 in G.glyph_for("Mars")
|
||||||
|
assert VS15 not in G.glyph_for("Sun") # bez zaśmiecania niepotrzebnym selektorem
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- dwukierunkowość
|
||||||
|
|
||||||
|
def test_object_round_trip():
|
||||||
|
for name in DEFAULT_OBJECTS:
|
||||||
|
assert G.name_for_glyph(G.glyph_for(name)) == name
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_round_trip():
|
||||||
|
for s in SIGNS:
|
||||||
|
assert G.sign_for_glyph(G.sign_glyph(s)) == s
|
||||||
|
|
||||||
|
|
||||||
|
def test_aspect_round_trip():
|
||||||
|
for a in MAJOR:
|
||||||
|
assert G.aspect_for_glyph(G.aspect_glyph(a)) == a
|
||||||
|
|
||||||
|
|
||||||
|
def test_reverse_tolerates_missing_variant_selector():
|
||||||
|
"""Symbol wklejony bez VS15 też musi się rozpoznać — bo z zewnątrz przyjdzie
|
||||||
|
dowolna forma (DAN-17: łańcuch znosi każdy Unicode)."""
|
||||||
|
assert G.sign_for_glyph("♓") == "Pisces" # ♓ bez VS15
|
||||||
|
assert G.sign_for_glyph("♓" + VS15) == "Pisces" # ♓ z VS15
|
||||||
|
assert G.name_for_glyph("♀") == "Venus" # ♀ bez VS15
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- glifikacja tekstu
|
||||||
|
|
||||||
|
def test_glyphify_matches_requirement_example():
|
||||||
|
"""Przykład z wymagań: [Sa [Pis 26°08' [conj [PF -> ♄ ♓ 26°08' ☌ ⊗."""
|
||||||
|
out = G.glyphify("[Sa [Pis 26°08' [conj [PF")
|
||||||
|
assert G.glyph_for("Saturn") in out
|
||||||
|
assert G.sign_glyph("Pisces") in out
|
||||||
|
assert G.aspect_glyph("conjunction") in out
|
||||||
|
assert G.glyph_for("Fortune") in out
|
||||||
|
assert "26°08'" in out # stopnie nietknięte
|
||||||
|
|
||||||
|
|
||||||
|
def test_glyphify_handles_retrograde_and_leaves_unknown():
|
||||||
|
out = G.glyphify("[Ma Rx w [Xyz")
|
||||||
|
assert G.glyph_for("Mars") in out
|
||||||
|
assert G.RETROGRADE in out
|
||||||
|
assert "[Xyz" in out # nieznany token bez zmian
|
||||||
|
|
||||||
|
|
||||||
|
def test_glyphify_empty_is_safe():
|
||||||
|
assert G.glyphify("") == ""
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""Szyfrowanie łącza między warstwami (PRE-16).
|
||||||
|
|
||||||
|
Sedno: przez to łącze płyną surowe wiersze oryginalnych baz interpretacyjnych.
|
||||||
|
Testy nie sprawdzają więc tylko, czy „coś się zaszyfrowało i odszyfrowało" —
|
||||||
|
sprawdzają, czy tajnego tekstu FAKTYCZNIE NIE MA w bajtach lecących po sieci
|
||||||
|
oraz czy każda znana droga na skróty (brak szyfrowania, obcy klucz, podmieniony
|
||||||
|
bajt, przeklejenie na inny endpoint, odtworzenie po czasie) kończy się odmową.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
|
from app.link_crypto import ENV_LOGIC_DATA, ENV_PRESENTATION_LOGIC, Link, LinkError
|
||||||
|
|
||||||
|
SECRET = "Saturn w VII domu — opis z oryginalnej bazy interpretacyjnej"
|
||||||
|
KEY_A = "11" * 32 # hex, 32 bajty
|
||||||
|
KEY_B = "22" * 32
|
||||||
|
|
||||||
|
|
||||||
|
class Payload(BaseModel):
|
||||||
|
question: str
|
||||||
|
|
||||||
|
|
||||||
|
def _app(link: Link | None) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
if link is not None:
|
||||||
|
app.add_middleware(link_crypto.LinkCryptoMiddleware, link=link, layer="testowa")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.post("/search")
|
||||||
|
def search(payload: Payload):
|
||||||
|
return {"echo": payload.question, "interpretation": SECRET}
|
||||||
|
|
||||||
|
@app.get("/catalog")
|
||||||
|
def catalog():
|
||||||
|
return {"models": ["a", "b"]}
|
||||||
|
|
||||||
|
@app.post("/stream")
|
||||||
|
def stream():
|
||||||
|
def lines():
|
||||||
|
for i in range(4):
|
||||||
|
yield json.dumps({"step": i, "note": SECRET}).encode() + b"\n"
|
||||||
|
|
||||||
|
return StreamingResponse(lines(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def link():
|
||||||
|
return Link(link_crypto.parse_key(KEY_A))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- klucze
|
||||||
|
|
||||||
|
def test_key_accepts_hex_and_base64():
|
||||||
|
import base64
|
||||||
|
|
||||||
|
raw = bytes(range(32))
|
||||||
|
assert link_crypto.parse_key(raw.hex()) == raw
|
||||||
|
assert link_crypto.parse_key(base64.b64encode(raw).decode()) == raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_of_wrong_length_is_rejected_loudly():
|
||||||
|
"""Krótki klucz to nie „słabsze szyfrowanie", tylko błąd konfiguracji."""
|
||||||
|
with pytest.raises(LinkError, match="32"):
|
||||||
|
link_crypto.parse_key("aabb")
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_that_is_neither_hex_nor_base64_is_rejected():
|
||||||
|
with pytest.raises(LinkError, match="hex"):
|
||||||
|
link_crypto.parse_key("!!! to nie jest klucz !!!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_from_env_is_lazy(monkeypatch):
|
||||||
|
monkeypatch.delenv(ENV_PRESENTATION_LOGIC, raising=False)
|
||||||
|
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) is None
|
||||||
|
monkeypatch.setenv(ENV_PRESENTATION_LOGIC, KEY_A)
|
||||||
|
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) == bytes.fromhex(KEY_A)
|
||||||
|
|
||||||
|
|
||||||
|
def test_directions_use_different_subkeys(link):
|
||||||
|
"""Żądanie i odpowiedź nie dzielą klucza — powtórzenie jednorazówki w jedną
|
||||||
|
stronę nie osłabia drugiej."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
sealed = link.seal(link_crypto.REQUEST, "/search", stamp, 0, b"tajne")
|
||||||
|
with pytest.raises(LinkError):
|
||||||
|
link.open(link_crypto.RESPONSE, "/search", stamp, 0, sealed)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- podstawowy obieg
|
||||||
|
|
||||||
|
def test_round_trip_delivers_plaintext_to_the_app(link):
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||||
|
payload={"question": "Saturn"}, link=link)
|
||||||
|
assert got["echo"] == "Saturn"
|
||||||
|
assert got["interpretation"] == SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_without_body_also_works(link):
|
||||||
|
"""GET nie ma ciała, ale i tak pieczętujemy pustą kopertę — to ona dowodzi,
|
||||||
|
że pytający ma klucz, i ona wymusza zaszyfrowanie odpowiedzi."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
got = link_crypto.call_json(client, "GET", "http://testserver/catalog", link=link)
|
||||||
|
assert got == {"models": ["a", "b"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_key_traffic_stays_plaintext(link):
|
||||||
|
"""Dev bez sekretów ma działać jak dotąd — inaczej nikt nie odpali projektu lokalnie."""
|
||||||
|
with TestClient(_app(None)) as client:
|
||||||
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||||
|
payload={"question": "Saturn"}, link=None)
|
||||||
|
assert got["interpretation"] == SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_stays_open_for_kubernetes_probes(link):
|
||||||
|
"""Sondy k8s klucza nie mają. Gdyby /health wymagał szyfrowania, literówka
|
||||||
|
w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
assert client.get("http://testserver/health").json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- czy na kablu naprawdę nic nie widać
|
||||||
|
|
||||||
|
def _raw_exchange(client, link, payload):
|
||||||
|
"""Wysyła zapieczętowane żądanie i zwraca SUROWE bajty obu stron."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
body = link_crypto.frame_out(
|
||||||
|
link.seal(link_crypto.REQUEST, "/search", stamp, 0, json.dumps(payload).encode()))
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
return body, response
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_bytes_do_not_contain_the_question(link):
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
body, _ = _raw_exchange(client, link, {"question": "Saturn w VII"})
|
||||||
|
assert b"Saturn" not in body
|
||||||
|
assert b"question" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_bytes_do_not_contain_the_interpretation(link):
|
||||||
|
"""To jest właściwy powód istnienia całego modułu."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
_, response = _raw_exchange(client, link, {"question": "Saturn"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
assert b"interpretation" not in response.content
|
||||||
|
assert response.headers[link_crypto.HEADER_ENC] == link_crypto.VERSION
|
||||||
|
assert response.headers["content-type"] == link_crypto.CONTENT_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- drogi na skróty i ataki
|
||||||
|
|
||||||
|
def test_plaintext_request_is_refused_when_key_is_set(link):
|
||||||
|
"""Fail-closed: regresja po stronie klienta nie może oznaczać cichego
|
||||||
|
powrotu do jawnego ruchu."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
response = client.post("http://testserver/search", json={"question": "Saturn"})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_foreign_key_cannot_read_the_link(link):
|
||||||
|
"""Klucze są osobne dla każdej pary warstw — przejęcie jednego nie otwiera drugiej."""
|
||||||
|
intruder = Link(link_crypto.parse_key(KEY_B))
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
body = link_crypto.frame_out(
|
||||||
|
intruder.seal(link_crypto.REQUEST, "/search", stamp, 0, b'{"question":"x"}'))
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_flipped_bit_is_rejected(link):
|
||||||
|
"""GCM uwierzytelnia, więc nie ma wariantu „odszyfrowało się, ale zmienione"."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
sealed = bytearray(link.seal(link_crypto.REQUEST, "/search", stamp, 0,
|
||||||
|
b'{"question":"x"}'))
|
||||||
|
sealed[-1] ^= 0x01
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search",
|
||||||
|
content=link_crypto.frame_out(bytes(sealed)),
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_cannot_be_replayed_against_another_endpoint(link):
|
||||||
|
"""Ścieżka wchodzi do materiału uwierzytelnianego, więc podsłuchanej koperty
|
||||||
|
nie da się przekleić tam, gdzie odpowiedź byłaby ciekawsza."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
sealed = link.seal(link_crypto.REQUEST, "/catalog", stamp, 0, b"")
|
||||||
|
with pytest.raises(LinkError):
|
||||||
|
link.open(link_crypto.REQUEST, "/search", stamp, 0, sealed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_frames_cannot_be_reordered(link):
|
||||||
|
"""Numer ramki jest uwierzytelniony — przestawienie kolejności w strumieniu
|
||||||
|
to błąd, a nie po cichu pomieszany horoskop."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
second = link.seal(link_crypto.RESPONSE, "/stream", stamp, 1, b"druga")
|
||||||
|
with pytest.raises(LinkError):
|
||||||
|
link.open(link_crypto.RESPONSE, "/stream", stamp, 0, second)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_frame_is_refused(link, monkeypatch):
|
||||||
|
"""Bez okna czasowego podsłuchane żądanie dałoby się odtworzyć kiedykolwiek."""
|
||||||
|
old = f"{time.time() - link_crypto.MAX_SKEW_SECONDS - 60:.3f}"
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
body = link_crypto.frame_out(
|
||||||
|
link.seal(link_crypto.REQUEST, "/search", old, 0, b'{"question":"x"}'))
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: old,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncated_stream_is_an_error_not_silent_loss(link):
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
full = link_crypto.frame_out(link.seal(link_crypto.RESPONSE, "/x", stamp, 0, b"abc"))
|
||||||
|
with pytest.raises(LinkError, match="urwana"):
|
||||||
|
link.open_all(link_crypto.RESPONSE, "/x", stamp, full[:-2])
|
||||||
|
|
||||||
|
|
||||||
|
def test_absurd_frame_length_does_not_allocate(link):
|
||||||
|
"""Zadeklarowana długość pochodzi z sieci — nie wolno jej wierzyć na słowo."""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
with pytest.raises(LinkError, match="rozmiar"):
|
||||||
|
list(link_crypto.frames_in(struct.pack(">I", 2 ** 31) + b"nic"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- odpowiedź strumieniowa
|
||||||
|
|
||||||
|
def test_streaming_response_survives_encryption(link):
|
||||||
|
"""Okno postępu dostaje kolejne linie na żywo — muszą dojść po kolei
|
||||||
|
i w komplecie, mimo że każda jedzie w osobnej kopercie."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
body = link_crypto.frame_out(link.seal(link_crypto.REQUEST, "/stream", stamp, 0, b""))
|
||||||
|
with client.stream("POST", "http://testserver/stream", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE}) as response:
|
||||||
|
chunks = list(link_crypto.open_response_stream(response, link))
|
||||||
|
|
||||||
|
steps = [json.loads(line) for line in b"".join(chunks).splitlines()]
|
||||||
|
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
||||||
|
assert all(s["note"] == SECRET for s in steps)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_lines_helper_round_trips_ndjson(link):
|
||||||
|
"""Wyższa warstwa (`stream_lines`) — dokładnie tej używa klient okna postępu.
|
||||||
|
|
||||||
|
Regresja, którą pilnuje ten test: strumień horoskopu powstał na gałęzi, która
|
||||||
|
nie widziała szyfrowania, więc szedł surowym httpx. Po scaleniu z PRE-16 przy
|
||||||
|
włączonym łączu żądanie było odrzucane (400) albo odpowiedź wracała jako
|
||||||
|
nieczytelne ramki. Tu sprawdzamy, że helper zwraca CZYSTE linie NDJSON i że
|
||||||
|
tajny opis nie przecieka po drodze."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
lines = list(link_crypto.stream_lines(
|
||||||
|
client, "http://testserver/stream", payload=None, link=link))
|
||||||
|
steps = [json.loads(line) for line in lines]
|
||||||
|
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
||||||
|
assert all(s["note"] == SECRET for s in steps)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_lines_plaintext_path_still_works(link):
|
||||||
|
"""Bez klucza (dev) strumień ma działać jak dotąd — surowy, bez szyfrowania."""
|
||||||
|
with TestClient(_app(None)) as client:
|
||||||
|
lines = list(link_crypto.stream_lines(
|
||||||
|
client, "http://testserver/stream", payload=None, link=None))
|
||||||
|
assert [json.loads(line)["step"] for line in lines] == [0, 1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_request_without_key_is_refused_when_required(link):
|
||||||
|
"""Fail-closed obejmuje też strumień: nieszyfrowane żądanie na łączu z kluczem
|
||||||
|
dostaje odmowę, a nie cichy jawny przelot tajnych danych."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
response = client.post("http://testserver/stream", json={})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_lines_client_is_fail_closed_when_required(monkeypatch, link):
|
||||||
|
"""Klient strumienia też nie wypuszcza jawnego żądania — jak `call`. Bez tego
|
||||||
|
ciało (dane urodzenia) poszłoby w eter, zanim serwer zdążyłby odmówić."""
|
||||||
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
class Tripwire:
|
||||||
|
def stream(self, *args, **kwargs):
|
||||||
|
sent.append(args)
|
||||||
|
raise AssertionError("strumień NIE powinien opuścić procesu")
|
||||||
|
|
||||||
|
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
||||||
|
list(link_crypto.stream_lines(Tripwire(), "http://logic/chart/horoscope/stream",
|
||||||
|
payload={"lat": 50.0}, link=None))
|
||||||
|
assert not sent, "żądanie strumienia wyszłoby jawnym tekstem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_incremental_unframing_handles_split_frames(link):
|
||||||
|
"""Ramka potrafi rozjechać się między dwa odczyty z gniazda — składamy ją
|
||||||
|
w buforze, zamiast zakładać, że każdy kawałek to komplet."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
stream = b"".join(link.seal_stream(link_crypto.RESPONSE, "/x", stamp,
|
||||||
|
[b"raz", b"dwa", b"trzy"]))
|
||||||
|
buffer = bytearray()
|
||||||
|
opened, seq = [], 0
|
||||||
|
for i in range(0, len(stream), 5): # ciachamy w poprzek ramek
|
||||||
|
buffer += stream[i:i + 5]
|
||||||
|
for frame in link_crypto.unframe_incremental(buffer):
|
||||||
|
opened.append(link.open(link_crypto.RESPONSE, "/x", stamp, seq, frame))
|
||||||
|
seq += 1
|
||||||
|
assert opened == [b"raz", b"dwa", b"trzy"]
|
||||||
|
assert not buffer, "bufor musi zostać pusty — inaczej gdzieś zgubiliśmy ramkę"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------- trzy kopie muszą być identyczne
|
||||||
|
|
||||||
|
def test_all_three_services_share_the_same_module():
|
||||||
|
"""Moduł jest skopiowany do trzech niezależnych usług (nie mają wspólnej
|
||||||
|
biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako
|
||||||
|
„nie da się odszyfrować" — więc pilnujemy tego testem."""
|
||||||
|
root = pathlib.Path(__file__).resolve().parents[3]
|
||||||
|
copies = {svc: (root / "services" / svc / "app" / "link_crypto.py")
|
||||||
|
for svc in ("presentation", "logic", "data")}
|
||||||
|
missing = [svc for svc, path in copies.items() if not path.is_file()]
|
||||||
|
assert not missing, f"brak modułu w warstwach: {missing}"
|
||||||
|
contents = {svc: path.read_bytes() for svc, path in copies.items()}
|
||||||
|
assert len(set(contents.values())) == 1, (
|
||||||
|
"kopie link_crypto.py rozjechały się między warstwami: "
|
||||||
|
+ ", ".join(f"{svc}={len(body)}B" for svc, body in contents.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_names_are_two_distinct_keys():
|
||||||
|
"""Wymóg wprost: osobny klucz dla pary prezentacja-logika i logika-dane."""
|
||||||
|
assert ENV_PRESENTATION_LOGIC != ENV_LOGIC_DATA
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- klient też musi być fail-closed
|
||||||
|
#
|
||||||
|
# To wyszło dopiero z podsłuchu prawdziwego gniazda, nie z testów: przy kliencie
|
||||||
|
# BEZ klucza serwer owszem odmawiał, ale pytanie leciało po drodze otwartym
|
||||||
|
# tekstem. Odpowiedź była chroniona — zapytanie już nie.
|
||||||
|
|
||||||
|
def test_client_without_key_sends_nothing_when_encryption_required(monkeypatch):
|
||||||
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
class Tripwire:
|
||||||
|
def request(self, *args, **kwargs):
|
||||||
|
sent.append(args)
|
||||||
|
raise AssertionError("żądanie NIE powinno opuścić procesu")
|
||||||
|
|
||||||
|
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
||||||
|
link_crypto.call(Tripwire(), "POST", "http://logic/search",
|
||||||
|
payload={"value": "[Sat"}, link=None)
|
||||||
|
assert not sent, "treść zapytania wyszłaby jawnym tekstem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plaintext_still_allowed_in_dev(monkeypatch, link):
|
||||||
|
"""Bez tej flagi lokalne uruchomienie bez sekretów ma dalej działać."""
|
||||||
|
monkeypatch.delenv(link_crypto.ENV_REQUIRED, raising=False)
|
||||||
|
with TestClient(_app(None)) as client:
|
||||||
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||||
|
payload={"question": "Saturn"}, link=None)
|
||||||
|
assert got["interpretation"] == SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_refuses_to_start_without_key_when_required(monkeypatch):
|
||||||
|
"""Pod w CrashLoop widać od razu; usługę, która wstała i nie szyfruje — nie."""
|
||||||
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||||
|
monkeypatch.delenv(ENV_LOGIC_DATA, raising=False)
|
||||||
|
with pytest.raises(LinkError, match=ENV_LOGIC_DATA):
|
||||||
|
link_crypto.install(FastAPI(), ENV_LOGIC_DATA, "danych")
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""Dostawcy LLM (LOG-31) — bez wołania jakiegokolwiek prawdziwego modelu.
|
||||||
|
|
||||||
|
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
|
||||||
|
deterministyczne i nic nie wychodzi na zewnątrz.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.llm import factory
|
||||||
|
from app.llm.base import Completion, LLMError
|
||||||
|
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_client(handler):
|
||||||
|
"""Podmienia httpx.Client na wersję z transportem testowym."""
|
||||||
|
class _C(httpx.Client):
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
kw["transport"] = httpx.MockTransport(handler)
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
return _C
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chat_ok(monkeypatch):
|
||||||
|
def handler(request):
|
||||||
|
assert request.url.path.endswith("/chat/completions")
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"model": "test-model",
|
||||||
|
"choices": [{"message": {"content": "Horoskop testowy."}}],
|
||||||
|
"usage": {"prompt_tokens": 100, "completion_tokens": 50},
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- protokół chat/completions
|
||||||
|
|
||||||
|
def test_local_provider_generates(chat_ok):
|
||||||
|
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
|
||||||
|
out = p.generate("prompt", 500)
|
||||||
|
assert isinstance(out, Completion)
|
||||||
|
assert out.text == "Horoskop testowy."
|
||||||
|
assert out.usage["completion_tokens"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_provider_does_not_leave_lan(chat_ok):
|
||||||
|
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
|
||||||
|
assert p.generate("prompt", 100).leaves_lan is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_provider_marks_leaving_lan(chat_ok):
|
||||||
|
p = ChatCompletionsProvider("openai", "https://api.openai.com/v1", "m", "klucz",
|
||||||
|
leaves_lan=True)
|
||||||
|
assert p.generate("prompt", 100).leaves_lan is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_key_sent_only_when_set(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen["auth"] = request.headers.get("authorization")
|
||||||
|
return httpx.Response(200, json={"choices": [{"message": {"content": "x"}}]})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||||
|
assert seen["auth"] is None
|
||||||
|
ChatCompletionsProvider("openai", "http://x/v1", "m", "tajny").generate("p", 10)
|
||||||
|
assert seen["auth"] == "Bearer tajny"
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_error_becomes_readable_message(monkeypatch):
|
||||||
|
monkeypatch.setattr(httpx, "Client",
|
||||||
|
_mock_client(lambda r: httpx.Response(400, text="zly model")))
|
||||||
|
with pytest.raises(LLMError, match="400"):
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_response_reported(monkeypatch):
|
||||||
|
monkeypatch.setattr(httpx, "Client",
|
||||||
|
_mock_client(lambda r: httpx.Response(200, json={"nonsens": 1})))
|
||||||
|
with pytest.raises(LLMError, match="kształt"):
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retries_then_succeeds(monkeypatch):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] < 3:
|
||||||
|
return httpx.Response(429, text="za duzo")
|
||||||
|
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
monkeypatch.setattr("app.llm.providers.time.sleep", lambda s: None) # bez czekania
|
||||||
|
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10).text == "ok"
|
||||||
|
assert calls["n"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Anthropic
|
||||||
|
|
||||||
|
def test_anthropic_generates(monkeypatch):
|
||||||
|
def handler(request):
|
||||||
|
assert request.url.path.endswith("/v1/messages")
|
||||||
|
assert request.headers.get("x-api-key") == "klucz"
|
||||||
|
assert request.headers.get("anthropic-version")
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"model": "claude-x",
|
||||||
|
"content": [{"type": "text", "text": "Prognoza."}],
|
||||||
|
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
out = AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
|
||||||
|
assert out.text == "Prognoza." and out.leaves_lan is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_requires_key():
|
||||||
|
with pytest.raises(LLMError, match="ANTHROPIC_API_KEY"):
|
||||||
|
AnthropicProvider("https://api.anthropic.com", "m", "").generate("p", 10)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- fabryka
|
||||||
|
|
||||||
|
def test_default_provider_is_local(monkeypatch):
|
||||||
|
monkeypatch.delenv("LLM_PROVIDER", raising=False)
|
||||||
|
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||||
|
monkeypatch.delenv("LLM_BASE_URL", raising=False)
|
||||||
|
p = factory.build_provider()
|
||||||
|
assert p.name == "local"
|
||||||
|
# domyślnie NIC nie opuszcza sieci — prompt niesie opisy z baz (LOG-32)
|
||||||
|
assert p.leaves_lan is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_requires_key(monkeypatch):
|
||||||
|
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
|
# komunikat wskazuje ZMIENNĄ DO USTAWIENIA dla tego dostawcy, nie ogólne LLM_API_KEY
|
||||||
|
with pytest.raises(LLMError, match="OPENAI_API_KEY"):
|
||||||
|
factory.build_provider("openai")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_provider_rejected():
|
||||||
|
with pytest.raises(LLMError, match="Nieznany dostawca"):
|
||||||
|
factory.build_provider("bzdura")
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_overrides_model_and_url(monkeypatch):
|
||||||
|
monkeypatch.setenv("LLM_MODEL", "moj-model")
|
||||||
|
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
|
||||||
|
p = factory.build_provider("local")
|
||||||
|
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------- 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
|
||||||
|
|
||||||
|
|
||||||
|
def test_turn_budget_counts_produced_not_requested(monkeypatch):
|
||||||
|
"""Regresja: odejmowanie ZAMOWIONEGO limitu tury zamiast wyprodukowanych
|
||||||
|
tokenow konczylo petle po jednej turze — urwany fragment wracal jako calosc."""
|
||||||
|
seq = [_chat("Fragment 1. ", "length"), _chat("Fragment 2. ", "length"),
|
||||||
|
_chat("Zakonczenie. KONIEC", "stop")]
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
# budzet 8000 < TURN_TOKENS_CAP: przy starej logice byla dokladnie jedna tura
|
||||||
|
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 8000)
|
||||||
|
assert out.usage["turns"] == 3, "urwana odpowiedz musi byc kontynuowana"
|
||||||
|
assert "Fragment 1." in out.text and "Zakonczenie." in out.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_reports_progress_events(monkeypatch):
|
||||||
|
"""Log w UI ma pokazywac RZECZYWISTE tury, nie udawany pasek postepu."""
|
||||||
|
seq = [_chat("Czesc. ", "length"), _chat("Reszta. KONIEC", "stop")]
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(
|
||||||
|
lambda r: httpx.Response(200, json=seq.pop(0) if seq else seq[-1])))
|
||||||
|
events = []
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate(
|
||||||
|
"p", 8000, on_event=events.append)
|
||||||
|
kinds = [e["type"] for e in events]
|
||||||
|
assert kinds.count("turn_start") == 2 and kinds.count("turn_end") == 2
|
||||||
|
assert kinds[-1] == "generated"
|
||||||
|
assert any("urwana" in e["message"] for e in events if e["type"] == "turn_end")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Lots / punkty arabskie (LOG-08).
|
||||||
|
|
||||||
|
Wyrocznie z notes3 (astro-seek, horoskop referencyjny 30.04.1984, urodzenie DZIENNE):
|
||||||
|
- Fortuna wprost w tabeli obiektów: Cancer 12°35'24" = 102.5900°
|
||||||
|
- Ducha (Spirit) potwierdza jego antyscja: Taurus 28°14' -> Spirit = 180 − 58.2333
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.formats import sign_index
|
||||||
|
from app.engine.lots import LOT_DEFS, compute_lots
|
||||||
|
|
||||||
|
# natalne długości horoskopu referencyjnego
|
||||||
|
NATAL = {"Asc": 112.1786, "Sun": 40.1392, "Moon": 30.5514, "Mercury": 27.3839,
|
||||||
|
"Venus": 27.6769, "Mars": 234.5411, "Jupiter": 282.9617, "Saturn": 223.3108}
|
||||||
|
|
||||||
|
FORTUNE_REF = 102.5900 # Cancer 12°35'24"
|
||||||
|
SPIRIT_REF = 180.0 - 58.2333 # z antyscji Taurus 28°14'
|
||||||
|
|
||||||
|
|
||||||
|
def _by_name(lots):
|
||||||
|
return {lot["name"]: lot["longitude"] for lot in lots}
|
||||||
|
|
||||||
|
|
||||||
|
def _arcmin(a, b):
|
||||||
|
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_matches_astroseek():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
assert _arcmin(lots["Fortune"], FORTUNE_REF) < 2.0
|
||||||
|
assert sign_index(lots["Fortune"]) == 3 # Cancer
|
||||||
|
|
||||||
|
|
||||||
|
def test_spirit_matches_antiscia_reference():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
assert _arcmin(lots["Spirit"], SPIRIT_REF) < 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_and_spirit_reverse_at_night():
|
||||||
|
day = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
night = _by_name(compute_lots(NATAL, is_day=False))
|
||||||
|
# nocą formuła się odwraca: Fortuna nocna = Duch dzienny (i odwrotnie)
|
||||||
|
assert _arcmin(night["Fortune"], day["Spirit"]) < 0.01
|
||||||
|
assert _arcmin(night["Spirit"], day["Fortune"]) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_seven_hermetic_lots_present():
|
||||||
|
lots = compute_lots(NATAL, is_day=True)
|
||||||
|
assert [lot["name"] for lot in lots] == [d[0] for d in LOT_DEFS]
|
||||||
|
assert len(lots) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_derived_lots_use_fortune_and_spirit():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
# Necessity = Asc + Fortune − Mercury
|
||||||
|
expected = (NATAL["Asc"] + lots["Fortune"] - NATAL["Mercury"]) % 360.0
|
||||||
|
assert _arcmin(lots["Necessity"], expected) < 0.01
|
||||||
|
# Eros = Asc + Venus − Spirit
|
||||||
|
expected_eros = (NATAL["Asc"] + NATAL["Venus"] - lots["Spirit"]) % 360.0
|
||||||
|
assert _arcmin(lots["Eros"], expected_eros) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_sign_method_lands_on_sign_start():
|
||||||
|
lots = compute_lots(NATAL, is_day=True, method="sign")
|
||||||
|
for lot in lots:
|
||||||
|
assert lot["longitude"] % 30.0 == 0.0 # 0° wyliczonego znaku
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_method_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
compute_lots(NATAL, is_day=True, method="bzdura")
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Aspekty pozazodiakalne — paralele deklinacji i antyscja (LOG-07).
|
||||||
|
|
||||||
|
Walidacja wobec faktów NIEZALEŻNYCH od naszego kodu:
|
||||||
|
* deklinacja Słońca 30.04.1984 ≈ +14.9° (almanach; Słońce w ~10° Byka, koniec
|
||||||
|
kwietnia, deklinacja rośnie ku przesileniu letniemu),
|
||||||
|
* antyscja to czysta arytmetyka odbicia względem osi przesileń — pary znaków
|
||||||
|
(Rak↔Bliźnięta, Baran↔Panna) i inwolucja dają się sprawdzić na piechotę,
|
||||||
|
* węzły są z definicji w kontrparaleli (SN = NN+180° na ekliptyce → deklinacja
|
||||||
|
przeciwna), więc muszą być odsiane jako kontakt bez informacji.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.out_of_zodiac import (
|
||||||
|
antiscion,
|
||||||
|
contra_antiscion,
|
||||||
|
declination,
|
||||||
|
find_antiscia,
|
||||||
|
find_declination_aspects,
|
||||||
|
is_out_of_bounds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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 chart(own_engine, krakow):
|
||||||
|
return build_chart(own_engine, krakow)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- deklinacja
|
||||||
|
|
||||||
|
def test_sun_declination_matches_almanac(chart):
|
||||||
|
"""Deklinacja Słońca 30.04.1984 ≈ +14.9° (wartość z almanachu)."""
|
||||||
|
sun = next(p for p in chart["positions"] if p["name"] == "Sun")
|
||||||
|
assert 14.6 < sun["declination"] < 15.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_uses_latitude_not_only_longitude():
|
||||||
|
"""Pełny wzór: przy tej samej długości różna szerokość → różna deklinacja.
|
||||||
|
Gdyby liczyć z samej długości (β=0), obie wyszłyby identyczne."""
|
||||||
|
eps = 23.44
|
||||||
|
on_ecliptic = declination(60.0, 0.0, eps) # β = 0
|
||||||
|
above = declination(60.0, 5.0, eps) # 5° na północ od ekliptyki
|
||||||
|
assert abs(above - on_ecliptic) > 3.0 # szerokość realnie zmienia deklinację
|
||||||
|
|
||||||
|
|
||||||
|
def test_obliquity_is_reasonable_for_1984(chart):
|
||||||
|
assert 23.43 < chart["obliquity"] < 23.45
|
||||||
|
|
||||||
|
|
||||||
|
def test_out_of_bounds_flag():
|
||||||
|
eps = 23.44
|
||||||
|
assert is_out_of_bounds(24.0, eps) is True # poza zakresem Słońca
|
||||||
|
assert is_out_of_bounds(20.0, eps) is False
|
||||||
|
assert is_out_of_bounds(-24.5, eps) is True # także na południe
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- antyscja (wzór)
|
||||||
|
|
||||||
|
def test_antiscion_is_reflection_across_solstitial_axis():
|
||||||
|
assert antiscion(0.0) == pytest.approx(180.0) # 0° Barana → 0° Wagi
|
||||||
|
assert antiscion(105.0) == pytest.approx(75.0) # 15° Raka → 15° Bliźniąt
|
||||||
|
assert antiscion(90.0) == pytest.approx(90.0) # 0° Raka leży NA osi
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscion_is_an_involution():
|
||||||
|
for lon in (12.3, 88.0, 200.5, 359.9):
|
||||||
|
assert antiscion(antiscion(lon)) == pytest.approx(lon)
|
||||||
|
|
||||||
|
|
||||||
|
def test_contra_antiscion_across_equinoctial_axis():
|
||||||
|
assert contra_antiscion(0.0) == pytest.approx(0.0) # 0° Barana leży NA osi
|
||||||
|
assert contra_antiscion(30.0) == pytest.approx(330.0) # 0° Byka → 0° Ryb (odbicie)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_pairs_are_classic():
|
||||||
|
"""Antyscja parami znaków: Baran↔Panna, Byk↔Lew, Bliźnięta↔Rak."""
|
||||||
|
# 5° Barana (5°) → 175° = 25° Panny
|
||||||
|
assert antiscion(5.0) == pytest.approx(175.0)
|
||||||
|
# 10° Byka (40°) → 140° = 20° Lwa
|
||||||
|
assert antiscion(40.0) == pytest.approx(140.0)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------- wykrywanie paraleli/antyscji
|
||||||
|
|
||||||
|
def test_parallel_and_contraparallel_detection():
|
||||||
|
bodies = [
|
||||||
|
{"name": "A", "declination": 12.0},
|
||||||
|
{"name": "B", "declination": 12.4}, # paralela z A (0.4°)
|
||||||
|
{"name": "C", "declination": -11.6}, # kontrparalela z A (|12-11.6|=0.4)
|
||||||
|
{"name": "D", "declination": 5.0}, # nic (daleko)
|
||||||
|
]
|
||||||
|
res = {(r["obj1"], r["obj2"]): r for r in find_declination_aspects(bodies, orb=1.0)}
|
||||||
|
assert res[("A", "B")]["type"] == "parallel"
|
||||||
|
assert res[("A", "C")]["type"] == "contraparallel"
|
||||||
|
assert ("A", "D") not in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_orb_is_configurable():
|
||||||
|
bodies = [{"name": "A", "declination": 10.0}, {"name": "B", "declination": 11.5}]
|
||||||
|
assert find_declination_aspects(bodies, orb=1.0) == [] # 1.5° > 1.0°
|
||||||
|
assert len(find_declination_aspects(bodies, orb=2.0)) == 1 # mieści się
|
||||||
|
|
||||||
|
|
||||||
|
def test_nodes_definitional_contraparallel_is_filtered(chart):
|
||||||
|
"""SN = NN+180° na ekliptyce → deklinacja przeciwna ZAWSZE. To kontakt bez
|
||||||
|
informacji, więc pary NN/SN nie może być w wynikach."""
|
||||||
|
decl = {p["name"]: p["declination"] for p in chart["positions"]}
|
||||||
|
# najpierw potwierdzamy, że deklinacje faktycznie są przeciwne
|
||||||
|
assert decl["North Node"] == pytest.approx(-decl["South Node"], abs=0.05)
|
||||||
|
# a mimo to para NN/SN nie pojawia się na liście paraleli
|
||||||
|
pairs = {frozenset((r["obj1"], r["obj2"])) for r in chart["parallels"]}
|
||||||
|
assert frozenset(("North Node", "South Node")) not in pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscia_detection_with_constructed_longitudes():
|
||||||
|
# A na 40°, B na 138° → antyscja A leży na 140°, więc B jest 2° od niej
|
||||||
|
bodies = [{"name": "A", "decimal": 40.0}, {"name": "B", "decimal": 138.0}]
|
||||||
|
assert find_antiscia(bodies, orb=1.0) == [] # 2° > 1°
|
||||||
|
hit = find_antiscia(bodies, orb=3.0)
|
||||||
|
assert len(hit) == 1 and hit[0]["type"] == "antiscion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_contra_antiscion_detection():
|
||||||
|
# A na 40°, kontrantyscja A = 320°; B na 320.5° → kontrantyscja w orbie
|
||||||
|
bodies = [{"name": "A", "decimal": 40.0}, {"name": "B", "decimal": 320.5}]
|
||||||
|
hit = find_antiscia(bodies, orb=1.0)
|
||||||
|
assert len(hit) == 1 and hit[0]["type"] == "contra_antiscion"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ spójność z build_chart
|
||||||
|
|
||||||
|
def test_chart_exposes_parallels_and_antiscia(chart):
|
||||||
|
assert "parallels" in chart and "antiscia" in chart
|
||||||
|
assert "obliquity" in chart
|
||||||
|
# znany, ciasny kontakt z tego horoskopu: Uran ~paralela~ Neptun (mid-80s)
|
||||||
|
un = next((r for r in chart["parallels"]
|
||||||
|
if {r["obj1"], r["obj2"]} == {"Uranus", "Neptune"}), None)
|
||||||
|
assert un is not None and un["type"] == "parallel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_is_zodiac_independent(own_engine, krakow):
|
||||||
|
"""Deklinacja jest fizyczna — nie zmienia się z wyborem zodiaku."""
|
||||||
|
tropical = build_chart(own_engine, krakow, zodiac="tropical")
|
||||||
|
sidereal = build_chart(own_engine, krakow, zodiac="sidereal_lahiri")
|
||||||
|
dt = {p["name"]: p["declination"] for p in tropical["positions"]}
|
||||||
|
ds = {p["name"]: p["declination"] for p in sidereal["positions"]}
|
||||||
|
for name in dt:
|
||||||
|
assert dt[name] == pytest.approx(ds[name], abs=1e-6)
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""Generator promptów (LOG-29) i budżetowanie (LOG-30).
|
||||||
|
|
||||||
|
Testy nie wołają żadnego modelu — sprawdzają skład promptu i niezmienniki redukcji.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.prompt import (
|
||||||
|
BUDGETS,
|
||||||
|
build_natal_prompt,
|
||||||
|
build_period_prompt,
|
||||||
|
natal_indications,
|
||||||
|
period_indications,
|
||||||
|
reduce_indications,
|
||||||
|
)
|
||||||
|
|
||||||
|
CHART = {
|
||||||
|
"zodiac": "tropical",
|
||||||
|
"house_system": "whole_sign",
|
||||||
|
"sect": "day",
|
||||||
|
"positions": [
|
||||||
|
{"name": "Sun", "sign": "Taurus", "in_sign": "Tau 10°12'37\"", "house": 11, "direction": "D"},
|
||||||
|
{"name": "Moon", "sign": "Aries", "in_sign": "Ari 7°48'40\"", "house": 10, "direction": "D"},
|
||||||
|
],
|
||||||
|
"angles": {"Asc": {"name": "Asc", "in_sign": "Can 16°00'54\""},
|
||||||
|
"MC": {"name": "MC", "in_sign": "Pis 25°50'50\""}},
|
||||||
|
"lots": [{"name": "Fortune", "in_sign": "Can 7°15'14\"", "house": 1,
|
||||||
|
"formula": "Asc + Moon − Sun"}],
|
||||||
|
"aspects": [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.34, "as": "A"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _report(n_samples=3, score=2.0):
|
||||||
|
return {"objects": [{
|
||||||
|
"object": "Sun", "sign": "Taurus", "house": 11,
|
||||||
|
"facets": [{
|
||||||
|
"type": "sign", "label": "w znaku Taurus", "score": score,
|
||||||
|
"samples": [{"significator": f"[Su in [Tau {i}", "expanded": f"Sun in Taurus {i}",
|
||||||
|
"effect": f"efekt numer {i}"} for i in range(n_samples)],
|
||||||
|
}],
|
||||||
|
}]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- budżetowanie
|
||||||
|
|
||||||
|
def test_budget_limits_prompt_size():
|
||||||
|
# opisy MUSZĄ być różne — identyczne zlałoby grupowanie w jedną pozycję
|
||||||
|
big = {"objects": [{
|
||||||
|
"object": "Sun", "sign": "Taurus", "house": 11,
|
||||||
|
"facets": [{"type": "sign", "label": "w znaku Taurus", "score": 1.0,
|
||||||
|
"samples": [{"significator": f"[Su {i}", "expanded": f"Sun {i}",
|
||||||
|
"effect": f"opis {i} " + "x" * 200} for i in range(500)]}],
|
||||||
|
}]}
|
||||||
|
out = build_natal_prompt(CHART, big, budget="concise")
|
||||||
|
assert out["stats"]["chars"] <= BUDGETS["concise"] * 1.1 # z marginesem na sekcje stałe
|
||||||
|
assert out["stats"]["omitted"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_bigger_budget_includes_more():
|
||||||
|
big = _report(n_samples=300, score=1.0)
|
||||||
|
small = build_natal_prompt(CHART, big, budget="concise")["stats"]
|
||||||
|
large = build_natal_prompt(CHART, big, budget="extensive")["stats"]
|
||||||
|
assert large["included"] > small["included"]
|
||||||
|
assert large["omitted"] < small["omitted"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouping_merges_identical_effects():
|
||||||
|
items = [
|
||||||
|
{"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0,
|
||||||
|
"significator": "Sun in Taurus", "effect": "ten sam opis"},
|
||||||
|
{"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0,
|
||||||
|
"significator": "Sun in Taurus (inny zapis)", "effect": "Ten Sam Opis "},
|
||||||
|
]
|
||||||
|
chosen, stats = reduce_indications(items, 10_000)
|
||||||
|
assert len(chosen) == 1 # zlane w jedno
|
||||||
|
assert chosen[0]["count"] == 2 # z licznikiem wystąpień
|
||||||
|
assert stats["after_grouping"] == 1
|
||||||
|
assert stats["deduplicated"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorted_by_score_desc():
|
||||||
|
items = [
|
||||||
|
{"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i),
|
||||||
|
"significator": f"s{i}", "effect": f"e{i}"} for i in range(5)
|
||||||
|
]
|
||||||
|
chosen, _ = reduce_indications(items, 10_000)
|
||||||
|
scores = [c["score"] for c in chosen]
|
||||||
|
assert scores == sorted(scores, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weakest_are_dropped_first():
|
||||||
|
items = [
|
||||||
|
{"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i),
|
||||||
|
"significator": f"s{i}", "effect": "opis " * 20} for i in range(20)
|
||||||
|
]
|
||||||
|
chosen, stats = reduce_indications(items, 400)
|
||||||
|
assert stats["omitted"] > 0
|
||||||
|
# to, co weszło, ma wagę nie niższą niż to, co odpadło
|
||||||
|
assert stats["min_score_included"] >= stats["max_score_omitted"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_effects_are_shortened():
|
||||||
|
items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0,
|
||||||
|
"significator": "s", "effect": "x" * 1000}]
|
||||||
|
chosen, stats = reduce_indications(items, 10_000)
|
||||||
|
assert stats["shortened"] == 1
|
||||||
|
assert "skrócono" in chosen[0]["effect"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_truncates_mid_indication():
|
||||||
|
items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0,
|
||||||
|
"significator": "s", "effect": "opis " * 50} for _ in range(10)]
|
||||||
|
chosen, _ = reduce_indications(items, 200)
|
||||||
|
assert chosen # zawsze co najmniej jedno całe
|
||||||
|
for c in chosen:
|
||||||
|
assert c["effect"].endswith(("opis", "[…skrócono]")) # nie urwane w pół słowa
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_budget_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
build_natal_prompt(CHART, _report(), budget="gigantyczny")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- skład promptu
|
||||||
|
|
||||||
|
def test_natal_prompt_contains_required_sections():
|
||||||
|
p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"]
|
||||||
|
for section in ["# ZADANIE", "# DANE HOROSKOPU", "# WSKAZANIA Z BAZ",
|
||||||
|
"# JAK MA WYGLĄDAĆ ODPOWIEDŹ", "# ZASTRZEŻENIE"]:
|
||||||
|
assert section in p
|
||||||
|
assert "HOROSKOPU\nURODZENIOWEGO" in p or "URODZENIOWEGO" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_natal_prompt_carries_chart_data():
|
||||||
|
p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"]
|
||||||
|
assert "Tau 10°12'37\"" in p and "dom 11" in p # pozycja + dom
|
||||||
|
assert "Can 16°00'54\"" in p # Asc
|
||||||
|
assert "Fortune" in p # Lots
|
||||||
|
assert "Sun conjunction Moon" in p or "conjunction" in p
|
||||||
|
assert "sekta: dzienna" in p
|
||||||
|
assert "1984-04-30 09:20 UTC" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_demands_citing_significators():
|
||||||
|
p = build_natal_prompt(CHART, _report())["prompt"]
|
||||||
|
assert "Teza bez wskazania jest niedopuszczalna" in p
|
||||||
|
assert "Nie zmyślaj" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_has_disclaimer():
|
||||||
|
p = build_natal_prompt(CHART, _report())["prompt"]
|
||||||
|
assert "nie stanowi porady" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_is_deterministic():
|
||||||
|
a = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"]
|
||||||
|
b = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"]
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_omission_is_reported_in_prompt():
|
||||||
|
out = build_natal_prompt(CHART, _report(n_samples=400, score=1.0), budget="concise")
|
||||||
|
assert out["stats"]["omitted"] > 0
|
||||||
|
assert "pominięto" in out["prompt"] # użytkownik/model wie, że coś odpadło
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_report_still_builds_prompt():
|
||||||
|
out = build_natal_prompt(CHART, {"objects": []})
|
||||||
|
assert out["stats"]["included"] == 0
|
||||||
|
assert "brak trafień" in out["prompt"]
|
||||||
|
assert "# DANE HOROSKOPU" in out["prompt"]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- okresowy
|
||||||
|
|
||||||
|
EVENTS = [
|
||||||
|
{"technique": "profection", "significator": "Lord of Year: Mars", "start": "2026-04-30",
|
||||||
|
"exact": "2026-04-30", "end": "2027-04-30", "interpretations_count": 2,
|
||||||
|
"interpretations": [{"significator": "[Ma", "expanded": "Mars", "effect": "opis marsowy"}]},
|
||||||
|
{"technique": "solar_arc", "significator": "Sun conj Saturn", "start": "2026-01-01",
|
||||||
|
"exact": "2026-06-15", "end": "2026-12-31", "interpretations_count": 1,
|
||||||
|
"interpretations": [{"significator": "[Su [conj [Sa", "expanded": "Sun conjunction Saturn",
|
||||||
|
"effect": "opis saturniczny"}]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_prompt_has_dates_and_timeline():
|
||||||
|
out = build_period_prompt(CHART, EVENTS, "2026-01-01", "2027-01-01")
|
||||||
|
p = out["prompt"]
|
||||||
|
assert "2026-01-01 — 2027-01-01" in p
|
||||||
|
assert "# OŚ CZASU" in p
|
||||||
|
assert "profection" in p and "solar_arc" in p
|
||||||
|
assert "2026-06-15" in p # data dokładna zdarzenia
|
||||||
|
assert "CHRONOLOGICZNIE" in p
|
||||||
|
assert out["stats"]["events"] == 2
|
||||||
|
assert out["profile"] == "period"
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_prompt_keeps_timeline_without_interpretations():
|
||||||
|
"""Padnięta warstwa danych zabiera wskazania, ale NIE oś czasu — ta jest czysto
|
||||||
|
obliczeniowa i bez niej prognoza okresowa jest bezużyteczna."""
|
||||||
|
bare = [{k: v for k, v in ev.items()
|
||||||
|
if k not in ("interpretations", "interpretations_count")} for ev in EVENTS]
|
||||||
|
out = build_period_prompt(CHART, bare, "2026-01-01", "2027-01-01")
|
||||||
|
assert out["stats"]["events"] == 2
|
||||||
|
assert "profection" in out["prompt"] and "2026-06-15" in out["prompt"]
|
||||||
|
assert out["stats"]["included"] == 0 # brak wskazań, ale oś czasu jest
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_indications_weight_by_hit_count():
|
||||||
|
items = period_indications(EVENTS)
|
||||||
|
assert [i["score"] for i in items] == [2.0, 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_natal_indications_flatten_facets():
|
||||||
|
items = natal_indications(_report(n_samples=4))
|
||||||
|
assert len(items) == 4
|
||||||
|
assert all(i["context"].startswith("Sun —") for i in items)
|
||||||
|
assert all(i["score"] == 2.0 for i in items)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""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
|
||||||
@@ -61,6 +61,42 @@ def test_aspect_facet():
|
|||||||
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
|
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_facet_hits_db_and_avoids_decanate_trap():
|
||||||
|
"""Paralela deklinacji (LOG-07): baza zna ją jako „P. Dec.". Pułapka: samo
|
||||||
|
„Dec." to często DEKANAT („3rd Dec. of [Gem"), który NIE może wpaść do paraleli."""
|
||||||
|
positions = [
|
||||||
|
{"name": "Mercury", "sign": "Taurus", "direction": "D", "house": 3},
|
||||||
|
{"name": "Saturn", "sign": "Scorpio", "direction": "Rx", "house": 9},
|
||||||
|
]
|
||||||
|
parallels = [{"obj1": "Mercury", "obj2": "Saturn", "type": "parallel",
|
||||||
|
"orb": 0.3, "allowed": 1.0}]
|
||||||
|
data = FakeData({"[Me": [
|
||||||
|
{"significator": "[Me at B. in P. Dec. [Sa and [Ma", "actioneffect": "dyscyplina umysłu"},
|
||||||
|
{"significator": "3rd Dec. of [Gem on Asc.", "actioneffect": "TO DEKANAT, nie paralela"},
|
||||||
|
{"significator": "[Me [conj [Sa", "actioneffect": "inny aspekt, nie paralela"},
|
||||||
|
]})
|
||||||
|
par = [f for f in build_report(positions, data, parallels=parallels)["objects"][0]["facets"]
|
||||||
|
if f["type"] == "parallel"]
|
||||||
|
assert par and par[0]["count"] == 1
|
||||||
|
assert "Saturn" in par[0]["label"]
|
||||||
|
assert all("Dec. of" not in s["significator"] for s in par[0]["samples"]), "dekanat przeciekł"
|
||||||
|
|
||||||
|
|
||||||
|
def test_contraparallel_gets_no_db_facet():
|
||||||
|
"""Baza nie ma osobnego znacznika dla kontrparaleli — nie zmyślamy fasety."""
|
||||||
|
positions = [
|
||||||
|
{"name": "Mercury", "sign": "Taurus", "direction": "D", "house": 3},
|
||||||
|
{"name": "Saturn", "sign": "Scorpio", "direction": "Rx", "house": 9},
|
||||||
|
]
|
||||||
|
parallels = [{"obj1": "Mercury", "obj2": "Saturn", "type": "contraparallel",
|
||||||
|
"orb": 0.3, "allowed": 1.0}]
|
||||||
|
data = FakeData({"[Me": [
|
||||||
|
{"significator": "[Me at B. in P. Dec. [Sa", "actioneffect": "paralela, nie kontr-"},
|
||||||
|
]})
|
||||||
|
types = [f["type"] for f in build_report(positions, data, parallels=parallels)["objects"][0]["facets"]]
|
||||||
|
assert "parallel" not in types
|
||||||
|
|
||||||
|
|
||||||
def test_grouping_by_effect():
|
def test_grouping_by_effect():
|
||||||
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
|
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
|
||||||
data = FakeData({"[Ma": [
|
data = FakeData({"[Ma": [
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Spięcie osi czasu z bazą interpretacji (LOG-14 → 1B→2B)."""
|
||||||
|
from app.significators import _event_tokens, interpret_events
|
||||||
|
|
||||||
|
|
||||||
|
class FakeData:
|
||||||
|
def __init__(self, rows_by_value):
|
||||||
|
self.rows_by_value = rows_by_value
|
||||||
|
|
||||||
|
def search(self, key, value, exact, limit, fields=None):
|
||||||
|
rows = self.rows_by_value.get(value, [])
|
||||||
|
return {"provider": "fake", "total": len(rows), "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_solar_arc():
|
||||||
|
ev = {"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"}
|
||||||
|
assert _event_tokens(ev) == ["[Ve", "[conj", "[NN"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_solar_arc_to_angle_has_no_target_token():
|
||||||
|
ev = {"technique": "solar_arc", "directed": "Sun", "aspect": "square", "target": "MC"}
|
||||||
|
assert _event_tokens(ev) == ["[Su", "[sq"] # MC nie ma tokenu planety
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_profection():
|
||||||
|
ev = {"technique": "profection", "lord": "Saturn", "sign": "Capricorn"}
|
||||||
|
assert _event_tokens(ev) == ["[Sa", "[Cap"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_return_has_no_tokens():
|
||||||
|
assert _event_tokens({"technique": "solar_return"}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_interpret_attaches_matches_with_all_tokens():
|
||||||
|
events = [
|
||||||
|
{"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"},
|
||||||
|
{"technique": "solar_return"},
|
||||||
|
]
|
||||||
|
data = FakeData({"[Ve": [
|
||||||
|
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # wszystkie tokeny
|
||||||
|
{"significator": "[Ve [conj [Mo", "actioneffect": "inny"}, # brak [NN
|
||||||
|
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # duplikat
|
||||||
|
]})
|
||||||
|
interpret_events(events, data)
|
||||||
|
assert events[0]["interpretations_count"] == 1 # duplikat odsiany, tylko z [NN
|
||||||
|
assert events[0]["interpretations"][0]["expanded"] == "Venus conjunction North Node"
|
||||||
|
assert "interpretations" not in events[1] # solar_return pominięty
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Systemy zodiaku (LOG-04): ayanamsy, syderyczny, draconic, RA.
|
||||||
|
|
||||||
|
Wartości referencyjne ayanams pochodzą ze **Swiss Ephemeris** (get_ayanamsa_ut)
|
||||||
|
użytego jako wyrocznia — silnika B nie importujemy tu (izolacja AGPL), więc stałe
|
||||||
|
są wpięte tak jak referencje astro-seek w innych testach. Nasz model odtwarza je
|
||||||
|
z dokładnością do ~0,02" w latach 1900–2100.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine import zodiac as Z
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.formats import sign_index
|
||||||
|
|
||||||
|
# wyrocznia: swisseph get_ayanamsa_ut (JD UT -> ayanamsa °)
|
||||||
|
ORACLE = {
|
||||||
|
"sidereal_lahiri": {
|
||||||
|
2451545.0: 23.857092, 2445820.88889: 23.638183,
|
||||||
|
2415021.0: 22.460550, 2433283.0: 23.158744,
|
||||||
|
2469808.0: 24.555632, 2488070.0: 25.254287,
|
||||||
|
},
|
||||||
|
"sidereal_fagan_bradley": {
|
||||||
|
2451545.0: 24.740300, 2445820.88889: 24.521391,
|
||||||
|
2415021.0: 23.343757, 2433283.0: 24.041952,
|
||||||
|
2469808.0: 25.438840, 2488070.0: 26.137495,
|
||||||
|
},
|
||||||
|
"sidereal_krishnamurti": {
|
||||||
|
2451545.0: 23.760240, 2415021.0: 22.363697,
|
||||||
|
2469808.0: 24.458780, 2488070.0: 25.157435,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _arcsec(a, b):
|
||||||
|
return abs(a - b) * 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", list(ORACLE))
|
||||||
|
def test_ayanamsha_matches_swisseph_oracle(name):
|
||||||
|
for jd, ref in ORACLE[name].items():
|
||||||
|
got = Z.ayanamsha(name, jd)
|
||||||
|
assert _arcsec(got, ref) < 0.1, f"{name} @ JD{jd}: {got} vs {ref} ({_arcsec(got, ref):.3f}\")"
|
||||||
|
|
||||||
|
|
||||||
|
def test_julian_day_j2000():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
jd = Z.julian_day(datetime(2000, 1, 1, 12, 0, tzinfo=timezone.utc))
|
||||||
|
assert abs(jd - 2451545.0) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_tropical_is_zero():
|
||||||
|
assert Z.offset("tropical", 2451545.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_draconic_is_node_longitude():
|
||||||
|
assert Z.offset("draconic", 2451545.0, node_lon=68.0) == 68.0
|
||||||
|
# normalizacja
|
||||||
|
assert Z.offset("draconic", 2451545.0, node_lon=428.0) == pytest.approx(68.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_draconic_requires_node():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Z.offset("draconic", 2451545.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_zodiac_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Z.offset("bzdura", 2451545.0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Z.ayanamsha("sidereal_nieistnieje", 2451545.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_subtracts_and_normalizes():
|
||||||
|
assert Z.apply(10.0, 20.0) == pytest.approx(350.0)
|
||||||
|
assert Z.apply(40.0, 24.74) == pytest.approx(15.26)
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_equatorial_reference_points():
|
||||||
|
# punkt równonocy: λ=0,β=0 -> RA=0, dec=0
|
||||||
|
ra, dec = Z.to_equatorial(0.0, 0.0, 23.4392911)
|
||||||
|
assert ra == pytest.approx(0.0, abs=1e-9) and dec == pytest.approx(0.0, abs=1e-9)
|
||||||
|
# przesilenie letnie: λ=90,β=0 -> RA=90, dec=+ε
|
||||||
|
ra, dec = Z.to_equatorial(90.0, 0.0, 23.4392911)
|
||||||
|
assert ra == pytest.approx(90.0, abs=1e-6)
|
||||||
|
assert dec == pytest.approx(23.4392911, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- integracja z pełnym horoskopem ----
|
||||||
|
|
||||||
|
def test_sidereal_chart_shifts_signs_back(own_engine, reference_moment):
|
||||||
|
trop = build_chart(own_engine, reference_moment, "whole_sign")
|
||||||
|
sid = build_chart(own_engine, reference_moment, "whole_sign", zodiac="sidereal_lahiri")
|
||||||
|
assert sid["zodiac"] == "sidereal_lahiri"
|
||||||
|
assert "ayanamsha" in sid and 23.0 < sid["ayanamsha"] < 24.5
|
||||||
|
tp = {p["name"]: p for p in trop["positions"]}
|
||||||
|
sp = {p["name"]: p for p in sid["positions"]}
|
||||||
|
# syderyczna długość = tropikalna − ayanamsa (mod 360) dla każdego obiektu
|
||||||
|
ay = sid["ayanamsha"]
|
||||||
|
for name in tp:
|
||||||
|
expect = (tp[name]["decimal"] - ay) % 360.0
|
||||||
|
assert abs(((sp[name]["decimal"] - expect + 180) % 360) - 180) < 1e-4
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidereal_preserves_house_numbers(own_engine, reference_moment):
|
||||||
|
# obrót zodiaku nie zmienia numerów domów (geometria niezmiennicza)
|
||||||
|
trop = build_chart(own_engine, reference_moment, "whole_sign")
|
||||||
|
sid = build_chart(own_engine, reference_moment, "whole_sign", zodiac="sidereal_lahiri")
|
||||||
|
tp = {p["name"]: p["house"] for p in trop["positions"]}
|
||||||
|
sp = {p["name"]: p["house"] for p in sid["positions"]}
|
||||||
|
assert tp == sp
|
||||||
|
|
||||||
|
|
||||||
|
def test_draconic_puts_node_at_zero_aries(own_engine, reference_moment):
|
||||||
|
drac = build_chart(own_engine, reference_moment, "whole_sign", zodiac="draconic")
|
||||||
|
assert drac["zodiac"] == "draconic"
|
||||||
|
nn = next(p for p in drac["positions"] if p["name"] == "North Node")
|
||||||
|
# węzeł wznoszący = 0° Barana w draconic
|
||||||
|
assert sign_index(nn["decimal"]) == 0
|
||||||
|
assert nn["decimal"] < 0.001 or nn["decimal"] > 359.999
|
||||||
|
|
||||||
|
|
||||||
|
def test_tropical_default_unchanged(own_engine, reference_moment):
|
||||||
|
# domyślnie tropikalny: brak ayanamshy, zodiac=tropical
|
||||||
|
chart = build_chart(own_engine, reference_moment, "whole_sign")
|
||||||
|
assert chart["zodiac"] == "tropical"
|
||||||
|
assert "ayanamsha" not in chart
|
||||||
@@ -5,23 +5,44 @@ 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
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
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 {}
|
||||||
|
|
||||||
|
|
||||||
|
def _link() -> link_crypto.Link | None:
|
||||||
|
"""Klucz łącza prezentacja↔logika. Czytany przy każdym wywołaniu, bo
|
||||||
|
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
|
||||||
|
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
|
||||||
|
return link_crypto.Link(key) if key else None
|
||||||
|
|
||||||
|
|
||||||
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("/")
|
||||||
|
|
||||||
|
def _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||||
|
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
|
||||||
|
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
|
||||||
|
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
||||||
|
payload=payload, headers=_auth_headers(),
|
||||||
|
link=_link())
|
||||||
|
|
||||||
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:
|
return self._post("/api/query", payload, settings.http_timeout)
|
||||||
r = client.post(f"{self.base_url}/api/query", json=payload)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def positions(
|
def positions(
|
||||||
self,
|
self,
|
||||||
@@ -31,6 +52,8 @@ class LogicClient:
|
|||||||
objects: list[str] | None = None,
|
objects: list[str] | None = None,
|
||||||
house_system: str = "whole_sign",
|
house_system: str = "whole_sign",
|
||||||
stations: bool = False,
|
stations: bool = False,
|
||||||
|
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 = {
|
||||||
@@ -40,19 +63,88 @@ class LogicClient:
|
|||||||
"objects": objects,
|
"objects": objects,
|
||||||
"house_system": house_system,
|
"house_system": house_system,
|
||||||
"stations": stations,
|
"stations": stations,
|
||||||
|
"zodiac": zodiac,
|
||||||
|
"tables": tables,
|
||||||
}
|
}
|
||||||
# stacje wymagają root-findów — dłuższy timeout
|
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
|
||||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
|
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
|
||||||
r = client.post(f"{self.base_url}/chart/positions", json=payload)
|
return self._post("/chart/positions", payload, timeout)
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def report(
|
def report(
|
||||||
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""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:
|
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
|
||||||
r = client.post(f"{self.base_url}/chart/report", json=payload)
|
|
||||||
r.raise_for_status()
|
def prompt(
|
||||||
return r.json()
|
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||||
|
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
|
||||||
|
provider: str | None = None, model: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
|
||||||
|
|
||||||
|
`provider` jest potrzebny dla budżetu „maksymalny kontekst modelu": limit
|
||||||
|
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"profile": profile, "when_utc": when_utc_iso,
|
||||||
|
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
|
||||||
|
"model": model,
|
||||||
|
}
|
||||||
|
if from_date and to_date:
|
||||||
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
|
return self._post("/chart/prompt", payload, max(settings.http_timeout, 60.0))
|
||||||
|
|
||||||
|
def horoscope(
|
||||||
|
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||||
|
budget: str = "medium", provider: str | None = None, model: str | None = None,
|
||||||
|
from_date: str | None = None, to_date: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
|
||||||
|
|
||||||
|
Generowanie tekstu trwa (zwłaszcza na modelu lokalnym), stąd długi timeout.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"profile": profile, "when_utc": when_utc_iso,
|
||||||
|
"lat": lat, "lon": lon, "budget": budget,
|
||||||
|
}
|
||||||
|
if provider:
|
||||||
|
payload["provider"] = provider
|
||||||
|
if model:
|
||||||
|
payload["model"] = model
|
||||||
|
if from_date and to_date:
|
||||||
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
|
return self._post("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
|
||||||
|
|
||||||
|
def horoscope_stream(self, payload: dict[str, Any]):
|
||||||
|
"""Strumień postępu pisania horoskopu (NDJSON) — przekazywany do przeglądarki.
|
||||||
|
|
||||||
|
Timeout jest długi, bo generowanie trwa; strumień i tak niesie heartbeat,
|
||||||
|
więc cisza na łączu nie zostanie wzięta za zerwanie.
|
||||||
|
|
||||||
|
Idzie przez szyfrowane łącze jak reszta ruchu w dół (PRE-16). Bez tego przy
|
||||||
|
włączonym `LINK_ENCRYPTION_REQUIRED` serwer odrzuciłby nieszyfrowane żądanie
|
||||||
|
i okno postępu przestałoby działać. `stream_lines` pieczętuje żądanie i
|
||||||
|
odszyfrowuje odpowiedź ramka po ramce, zachowując dostarczanie na żywo.
|
||||||
|
"""
|
||||||
|
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
|
||||||
|
yield from link_crypto.stream_lines(
|
||||||
|
client, f"{self.base_url}/chart/horoscope/stream",
|
||||||
|
payload=payload, headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
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:
|
||||||
|
return link_crypto.call_json(client, "GET", f"{self.base_url}/llm/models",
|
||||||
|
headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
def timeline(
|
||||||
|
self, when_utc_iso: str, lat: float, lon: float,
|
||||||
|
from_date: str, to_date: str, interpret: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
|
||||||
|
payload = {
|
||||||
|
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
||||||
|
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
||||||
|
}
|
||||||
|
return self._post("/chart/timeline", payload, max(settings.http_timeout, 60.0))
|
||||||
|
|||||||
@@ -15,3 +15,17 @@ class Settings:
|
|||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
# Domyślna (wklepana) lokalizacja formularzy — miejsce urodzenia właściciela:
|
||||||
|
# Szpital Barlickiego w Łodzi, 51°46'26.1"N 19°28'58.6"E (potwierdzone reverse-
|
||||||
|
# geokodowaniem OSM: Kopcińskiego 22/28). QoL — nie trzeba wpisywać jej za każdym
|
||||||
|
# razem. Przycisk „Tu i teraz" nadal ją nadpisuje geolokalizacją przeglądarki.
|
||||||
|
# Można nadpisać zmiennymi środowiskowymi (np. inny współpracownik).
|
||||||
|
DEFAULT_LAT: float = float(os.getenv("DEFAULT_LAT", "51.7739"))
|
||||||
|
DEFAULT_LON: float = float(os.getenv("DEFAULT_LON", "19.4829"))
|
||||||
|
DEFAULT_LOCATION_LABEL: str = os.getenv("DEFAULT_LOCATION_LABEL", "Szpital Barlickiego, Łódź")
|
||||||
|
|
||||||
|
|
||||||
|
def default_form() -> dict:
|
||||||
|
"""Wstępnie wypełnione pola lokalizacji na czystym formularzu (GET)."""
|
||||||
|
return {"lat": DEFAULT_LAT, "lon": DEFAULT_LON}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Proxy geokodera (OpenStreetMap / Nominatim) dla wyszukiwarki lokalizacji w UI.
|
||||||
|
|
||||||
|
QoL: pozwala wpisać nazwę / adres / POI i dostać współrzędne (oraz odwrotnie —
|
||||||
|
z punktu na mapie nazwę miejsca). Wołamy Nominatim **po stronie serwera**, nie
|
||||||
|
z przeglądarki, bo:
|
||||||
|
- polityka Nominatim wymaga sensownego User-Agent i max ~1 zapytanie/s — łatwiej
|
||||||
|
to wyegzekwować i cache'ować centralnie niż z wielu klientów,
|
||||||
|
- działa niezależnie od „secure context" przeglądarki (na http://<ip> też).
|
||||||
|
|
||||||
|
Atrybucja „© OpenStreetMap contributors" jest pokazywana w UI (mapa + wyniki).
|
||||||
|
Zależność sieciowa jest OPCJONALNA: brak internetu = wyszukiwarka zwraca błąd,
|
||||||
|
ale ręczne wpisanie lat/lon oraz mapa działają dalej.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
NOMINATIM = "https://nominatim.openstreetmap.org"
|
||||||
|
# Nominatim wymaga identyfikującego User-Agent (inaczej blokuje). Kontakt = właściciel.
|
||||||
|
USER_AGENT = "astrololo/1.0 (self-hosted astrology tool; +mtuszowski@gmail.com)"
|
||||||
|
_MIN_INTERVAL = 1.1 # s — polityka Nominatim: max ~1 zapytanie/s
|
||||||
|
_CACHE_TTL = 3600.0 # s — powtórki tego samego zapytania z pamięci przez godzinę
|
||||||
|
_TIMEOUT = 6.0
|
||||||
|
|
||||||
|
_lock = threading.Lock() # serializuje wołania (throttling globalny w procesie)
|
||||||
|
_last_call = 0.0
|
||||||
|
_cache: dict[str, tuple[float, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _throttle() -> None:
|
||||||
|
global _last_call
|
||||||
|
wait = _MIN_INTERVAL - (time.monotonic() - _last_call)
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
_last_call = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _cached(key: str) -> Any | None:
|
||||||
|
hit = _cache.get(key)
|
||||||
|
if hit and (time.monotonic() - hit[0]) < _CACHE_TTL:
|
||||||
|
return hit[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path: str, params: dict) -> Any:
|
||||||
|
"""Wołanie Nominatim z throttlingiem; zakłada trzymany `_lock`."""
|
||||||
|
_throttle()
|
||||||
|
r = httpx.get(
|
||||||
|
f"{NOMINATIM}{path}",
|
||||||
|
params=params,
|
||||||
|
headers={"User-Agent": USER_AGENT, "Accept-Language": "pl"},
|
||||||
|
timeout=_TIMEOUT,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def search(query: str, limit: int = 5) -> list[dict]:
|
||||||
|
"""Nazwa/adres/POI → lista kandydatów [{name, lat, lon}] (najlepsze pierwsze)."""
|
||||||
|
query = (query or "").strip()
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
key = f"s:{limit}:{query.lower()}"
|
||||||
|
with _lock:
|
||||||
|
cached = _cached(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = _get("/search", {"q": query, "format": "jsonv2",
|
||||||
|
"limit": limit, "addressdetails": 0})
|
||||||
|
out = [
|
||||||
|
{"name": item.get("display_name", ""),
|
||||||
|
"lat": round(float(item["lat"]), 6),
|
||||||
|
"lon": round(float(item["lon"]), 6)}
|
||||||
|
for item in data if "lat" in item and "lon" in item
|
||||||
|
]
|
||||||
|
_cache[key] = (time.monotonic(), out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(lat: float, lon: float) -> dict:
|
||||||
|
"""Punkt (lat, lon) → nazwa miejsca {name, lat, lon}."""
|
||||||
|
key = f"r:{round(lat, 5)}:{round(lon, 5)}"
|
||||||
|
with _lock:
|
||||||
|
cached = _cached(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = _get("/reverse", {"lat": lat, "lon": lon, "format": "jsonv2"})
|
||||||
|
out = {"name": data.get("display_name", ""),
|
||||||
|
"lat": round(lat, 6), "lon": round(lon, 6)}
|
||||||
|
_cache[key] = (time.monotonic(), out)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
||||||
|
|
||||||
|
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
||||||
|
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
||||||
|
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
||||||
|
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
||||||
|
zrzut z węzła), miał je w całości.
|
||||||
|
|
||||||
|
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
||||||
|
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
||||||
|
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
||||||
|
podatne na modyfikację".
|
||||||
|
|
||||||
|
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
||||||
|
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
||||||
|
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
||||||
|
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
||||||
|
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
||||||
|
|
||||||
|
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
||||||
|
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
||||||
|
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
||||||
|
|
||||||
|
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
||||||
|
|
||||||
|
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
||||||
|
|
||||||
|
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
||||||
|
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
||||||
|
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
||||||
|
|
||||||
|
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
||||||
|
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
||||||
|
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
||||||
|
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.link")
|
||||||
|
|
||||||
|
MAGIC = b"AL1"
|
||||||
|
VERSION = "v1"
|
||||||
|
NONCE_BYTES = 12
|
||||||
|
KEY_BYTES = 32 # AES-256
|
||||||
|
LENGTH_PREFIX = 4
|
||||||
|
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
||||||
|
MAX_SKEW_SECONDS = 300.0
|
||||||
|
|
||||||
|
HEADER_ENC = "X-Astrololo-Enc"
|
||||||
|
HEADER_TS = "X-Astrololo-Enc-Ts"
|
||||||
|
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
||||||
|
|
||||||
|
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||||
|
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||||
|
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||||
|
|
||||||
|
REQUEST, RESPONSE = b"req", b"res"
|
||||||
|
|
||||||
|
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
||||||
|
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
class LinkError(Exception):
|
||||||
|
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- klucze
|
||||||
|
|
||||||
|
def parse_key(raw: str) -> bytes:
|
||||||
|
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
raise LinkError("pusty klucz łącza")
|
||||||
|
try:
|
||||||
|
key = bytes.fromhex(text)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(text, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
||||||
|
if len(key) != KEY_BYTES:
|
||||||
|
raise LinkError(
|
||||||
|
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
||||||
|
f"`openssl rand -hex 32`"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def key_from_env(env_name: str) -> bytes | None:
|
||||||
|
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
||||||
|
raw = os.getenv(env_name, "")
|
||||||
|
return parse_key(raw) if raw.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_required() -> bool:
|
||||||
|
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
||||||
|
|
||||||
|
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
||||||
|
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
||||||
|
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
||||||
|
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
||||||
|
"""
|
||||||
|
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
||||||
|
return HKDF(
|
||||||
|
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
||||||
|
info=b"astrololo/link/" + direction,
|
||||||
|
).derive(link_key)
|
||||||
|
|
||||||
|
|
||||||
|
class Link:
|
||||||
|
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
||||||
|
|
||||||
|
def __init__(self, link_key: bytes) -> None:
|
||||||
|
self._by_direction = {
|
||||||
|
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
||||||
|
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- pojedyncza ramka
|
||||||
|
|
||||||
|
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
||||||
|
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
||||||
|
stamp.encode("ascii"), str(seq).encode("ascii")])
|
||||||
|
|
||||||
|
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
plaintext: bytes) -> bytes:
|
||||||
|
nonce = os.urandom(NONCE_BYTES)
|
||||||
|
sealed = self._by_direction[direction].encrypt(
|
||||||
|
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
||||||
|
return MAGIC + nonce + sealed
|
||||||
|
|
||||||
|
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
frame: bytes) -> bytes:
|
||||||
|
if not frame.startswith(MAGIC):
|
||||||
|
raise LinkError("ramka bez znacznika astrololo")
|
||||||
|
body = frame[len(MAGIC):]
|
||||||
|
if len(body) <= NONCE_BYTES:
|
||||||
|
raise LinkError("ramka za krótka")
|
||||||
|
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
|
||||||
|
try:
|
||||||
|
return self._by_direction[direction].decrypt(
|
||||||
|
nonce, sealed, self._aad(direction, path, stamp, seq))
|
||||||
|
except InvalidTag as exc:
|
||||||
|
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
|
||||||
|
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
|
||||||
|
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
|
||||||
|
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ strumień ramek
|
||||||
|
|
||||||
|
def seal_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
chunks: Iterable[bytes]) -> Iterator[bytes]:
|
||||||
|
for seq, chunk in enumerate(chunks):
|
||||||
|
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
|
||||||
|
|
||||||
|
def open_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
raw: bytes) -> Iterator[bytes]:
|
||||||
|
for seq, frame in enumerate(frames_in(raw)):
|
||||||
|
yield self.open(direction, path, stamp, seq, frame)
|
||||||
|
|
||||||
|
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
|
||||||
|
return b"".join(self.open_stream(direction, path, stamp, raw))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ramkowanie
|
||||||
|
|
||||||
|
def frame_out(payload: bytes) -> bytes:
|
||||||
|
return struct.pack(">I", len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def frames_in(raw: bytes) -> Iterator[bytes]:
|
||||||
|
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
|
||||||
|
offset = 0
|
||||||
|
while offset < len(raw):
|
||||||
|
if offset + LENGTH_PREFIX > len(raw):
|
||||||
|
raise LinkError("urwana ramka (brak nagłówka długości)")
|
||||||
|
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
offset += LENGTH_PREFIX
|
||||||
|
if offset + size > len(raw):
|
||||||
|
raise LinkError("urwana ramka (za mało danych)")
|
||||||
|
yield raw[offset:offset + size]
|
||||||
|
offset += size
|
||||||
|
|
||||||
|
|
||||||
|
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
|
||||||
|
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
|
||||||
|
|
||||||
|
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
|
||||||
|
rozjechać się między dwa odczyty.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
if len(buffer) < LENGTH_PREFIX:
|
||||||
|
return
|
||||||
|
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
if len(buffer) < LENGTH_PREFIX + size:
|
||||||
|
return
|
||||||
|
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
|
||||||
|
del buffer[:LENGTH_PREFIX + size]
|
||||||
|
yield frame
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- świeżość ruchu
|
||||||
|
|
||||||
|
def stamp_now() -> str:
|
||||||
|
return f"{time.time():.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def check_stamp(stamp: str) -> None:
|
||||||
|
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
|
||||||
|
odtworzyć w dowolnym momencie w przyszłości."""
|
||||||
|
try:
|
||||||
|
sent = float(stamp)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise LinkError("brak albo błędny znacznik czasu") from exc
|
||||||
|
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
|
||||||
|
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================== strona serwerowa
|
||||||
|
|
||||||
|
class LinkCryptoMiddleware:
|
||||||
|
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
|
||||||
|
|
||||||
|
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
|
||||||
|
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
|
||||||
|
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, link: Link | None, layer: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.link = link
|
||||||
|
self.layer = layer
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
path = scope["path"]
|
||||||
|
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
|
||||||
|
|
||||||
|
if headers.get(HEADER_ENC.lower()) != VERSION:
|
||||||
|
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
|
||||||
|
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
|
||||||
|
# chcemy po cichu wrócić do jawnego ruchu.
|
||||||
|
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
|
||||||
|
self.layer, path)
|
||||||
|
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
|
||||||
|
|
||||||
|
stamp = headers.get(HEADER_TS.lower(), "")
|
||||||
|
try:
|
||||||
|
check_stamp(stamp)
|
||||||
|
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
|
||||||
|
except LinkError as exc:
|
||||||
|
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
|
||||||
|
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
|
||||||
|
|
||||||
|
scope = dict(scope)
|
||||||
|
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
|
||||||
|
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
|
||||||
|
|
||||||
|
def _sealing_send(self, send, path: str):
|
||||||
|
state: dict = {"stamp": "", "seq": 0}
|
||||||
|
|
||||||
|
async def sealing(message):
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
state["stamp"] = stamp_now()
|
||||||
|
keep = [(k, v) for k, v in message.get("headers", [])
|
||||||
|
if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
message = dict(message)
|
||||||
|
message["headers"] = keep + [
|
||||||
|
(b"content-type", CONTENT_TYPE.encode()),
|
||||||
|
(HEADER_ENC.lower().encode(), VERSION.encode()),
|
||||||
|
(HEADER_TS.lower().encode(), state["stamp"].encode()),
|
||||||
|
]
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
if message["type"] == "http.response.body":
|
||||||
|
chunk = message.get("body", b"")
|
||||||
|
sealed = b""
|
||||||
|
if chunk:
|
||||||
|
sealed = frame_out(self.link.seal(
|
||||||
|
RESPONSE, path, state["stamp"], state["seq"], chunk))
|
||||||
|
state["seq"] += 1
|
||||||
|
return await send({"type": "http.response.body", "body": sealed,
|
||||||
|
"more_body": message.get("more_body", False)})
|
||||||
|
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
return sealing
|
||||||
|
|
||||||
|
|
||||||
|
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
|
||||||
|
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
|
||||||
|
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
|
||||||
|
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
kept.append((b"content-length", str(length).encode()))
|
||||||
|
if length:
|
||||||
|
kept.append((b"content-type", b"application/json"))
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_body(receive) -> bytes:
|
||||||
|
body = bytearray()
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.disconnect":
|
||||||
|
raise LinkError("rozłączenie w trakcie odbioru żądania")
|
||||||
|
body += message.get("body", b"")
|
||||||
|
if not message.get("more_body", False):
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _replay(body: bytes, original):
|
||||||
|
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
|
||||||
|
|
||||||
|
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
|
||||||
|
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
|
||||||
|
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
|
||||||
|
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
|
||||||
|
"""
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
if delivered:
|
||||||
|
return await original()
|
||||||
|
delivered = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return receive
|
||||||
|
|
||||||
|
|
||||||
|
async def _refuse(send, detail: str) -> None:
|
||||||
|
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
|
||||||
|
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
|
||||||
|
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
|
||||||
|
await send({"type": "http.response.start", "status": 400, "headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"content-length", str(len(payload)).encode()),
|
||||||
|
]})
|
||||||
|
await send({"type": "http.response.body", "body": payload})
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, env_name: str, layer: str):
|
||||||
|
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
|
||||||
|
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
|
||||||
|
link_key = key_from_env(env_name)
|
||||||
|
if link_key is None and encryption_required():
|
||||||
|
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
|
||||||
|
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
|
||||||
|
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
|
||||||
|
f"warstwa {layer} nie wystartuje bez klucza łącza"
|
||||||
|
)
|
||||||
|
if link_key is None:
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
|
||||||
|
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
|
||||||
|
"ruch wewnątrz sieci.", env_name, layer,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
link = Link(link_key)
|
||||||
|
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
|
||||||
|
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ strona kliencka
|
||||||
|
|
||||||
|
def call(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
|
||||||
|
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
|
||||||
|
|
||||||
|
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
|
||||||
|
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
|
||||||
|
się niejasnym błędem odszyfrowania.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
|
||||||
|
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
|
||||||
|
# sygnifikatory, o które pytamy bazę.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
|
||||||
|
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
response = client.request(method, url, json=payload, headers=request_headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
path = httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
|
||||||
|
"Content-Type": CONTENT_TYPE})
|
||||||
|
|
||||||
|
response = client.request(method, url, content=body, headers=request_headers)
|
||||||
|
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
log.error("łącze %s odmówiło: %s", path, response.text[:200])
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
|
||||||
|
reply_stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(reply_stamp)
|
||||||
|
return link.open_all(RESPONSE, path, reply_stamp, response.content)
|
||||||
|
|
||||||
|
|
||||||
|
def call_json(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None):
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
return _json.loads(call(client, method, url, payload=payload,
|
||||||
|
headers=headers, link=link))
|
||||||
|
|
||||||
|
|
||||||
|
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
|
||||||
|
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
|
||||||
|
|
||||||
|
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
|
||||||
|
w buforze zamiast zakładać, że każdy kawałek to komplet.
|
||||||
|
"""
|
||||||
|
if link is None:
|
||||||
|
yield from response.iter_bytes()
|
||||||
|
return
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
|
||||||
|
stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(stamp)
|
||||||
|
path = response.request.url.path
|
||||||
|
buffer = bytearray()
|
||||||
|
seq = 0
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
buffer += chunk
|
||||||
|
for frame in unframe_incremental(buffer):
|
||||||
|
yield link.open(RESPONSE, path, stamp, seq, frame)
|
||||||
|
seq += 1
|
||||||
|
if buffer:
|
||||||
|
raise LinkError("strumień urwał się w połowie ramki")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
|
||||||
|
link: Link | None) -> Iterator[str]:
|
||||||
|
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
|
||||||
|
|
||||||
|
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
|
||||||
|
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
|
||||||
|
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
|
||||||
|
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
|
||||||
|
dopiero na znakach nowej linii.
|
||||||
|
|
||||||
|
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
|
||||||
|
działał bez zmian.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx as _httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
|
||||||
|
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
|
||||||
|
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
|
||||||
|
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
with client.stream("POST", url, json=payload, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
for text_line in response.iter_lines():
|
||||||
|
if text_line:
|
||||||
|
yield text_line
|
||||||
|
return
|
||||||
|
|
||||||
|
path = _httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
|
||||||
|
with client.stream("POST", url, content=body, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
buffer = bytearray()
|
||||||
|
for plain in open_response_stream(response, link):
|
||||||
|
buffer += plain
|
||||||
|
while True:
|
||||||
|
nl = buffer.find(b"\n")
|
||||||
|
if nl < 0:
|
||||||
|
break
|
||||||
|
text_line = bytes(buffer[:nl])
|
||||||
|
del buffer[:nl + 1]
|
||||||
|
if text_line:
|
||||||
|
yield text_line.decode("utf-8")
|
||||||
|
if buffer:
|
||||||
|
yield bytes(buffer).decode("utf-8")
|
||||||
@@ -9,20 +9,24 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, Request
|
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
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.clients.logic_client import LogicClient
|
from app.clients.logic_client import LogicClient
|
||||||
|
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa prezentacji")
|
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]:
|
||||||
@@ -36,6 +40,15 @@ 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 (
|
||||||
@@ -48,7 +61,11 @@ def _logic_error(e: Exception) -> str:
|
|||||||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def chart_form(request: Request):
|
def chart_form(request: Request):
|
||||||
return templates.TemplateResponse(request, "chart.html", {"result": None, "form": {}})
|
return templates.TemplateResponse(
|
||||||
|
request, "chart.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
|
"llm_catalog": _llm_catalog()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/", response_class=HTMLResponse)
|
@app.post("/", response_class=HTMLResponse)
|
||||||
@@ -61,16 +78,19 @@ def chart_compute(
|
|||||||
lon: float = Form(0.0),
|
lon: float = Form(0.0),
|
||||||
house_system: str = Form("whole_sign"),
|
house_system: str = Form("whole_sign"),
|
||||||
stations: bool = Form(False),
|
stations: bool = Form(False),
|
||||||
|
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}
|
||||||
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,
|
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
|
||||||
)
|
)
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
@@ -105,7 +125,11 @@ def significators_search(
|
|||||||
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
|
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
|
||||||
@app.get("/interpret", response_class=HTMLResponse)
|
@app.get("/interpret", response_class=HTMLResponse)
|
||||||
def interpret_form(request: Request):
|
def interpret_form(request: Request):
|
||||||
return templates.TemplateResponse(request, "interpret.html", {"result": None, "form": {}})
|
return templates.TemplateResponse(
|
||||||
|
request, "interpret.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
|
"llm_catalog": _llm_catalog()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/interpret", response_class=HTMLResponse)
|
@app.post("/interpret", response_class=HTMLResponse)
|
||||||
@@ -117,14 +141,31 @@ def interpret_run(
|
|||||||
lat: float = Form(0.0),
|
lat: float = Form(0.0),
|
||||||
lon: float = Form(0.0),
|
lon: float = Form(0.0),
|
||||||
group: bool = Form(False),
|
group: bool = Form(False),
|
||||||
|
action: str = Form("report"),
|
||||||
|
prompt_budget: str = Form("medium"),
|
||||||
|
llm_provider: str = Form("local"),
|
||||||
|
llm_model: str = Form(""),
|
||||||
):
|
):
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"lat": lat, "lon": lon, "group": group}
|
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||||
|
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
|
||||||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
if action == "prompt":
|
||||||
|
ctx["prompt_result"] = logic.prompt(
|
||||||
|
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
||||||
|
provider=llm_provider, model=llm_model,
|
||||||
|
)
|
||||||
|
elif action == "horoscope":
|
||||||
|
ctx["prompt_result"] = logic.horoscope(
|
||||||
|
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -132,6 +173,137 @@ def interpret_run(
|
|||||||
return templates.TemplateResponse(request, "interpret.html", ctx)
|
return templates.TemplateResponse(request, "interpret.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
|
||||||
|
@app.get("/timeline", response_class=HTMLResponse)
|
||||||
|
def timeline_form(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "timeline.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
|
"llm_catalog": _llm_catalog()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/timeline", response_class=HTMLResponse)
|
||||||
|
def timeline_run(
|
||||||
|
request: Request,
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
from_date: str = Form(...),
|
||||||
|
to_date: str = Form(...),
|
||||||
|
action: str = Form("timeline"),
|
||||||
|
prompt_budget: str = Form("medium"),
|
||||||
|
llm_provider: str = Form("local"),
|
||||||
|
llm_model: str = Form(""),
|
||||||
|
):
|
||||||
|
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||||
|
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||||||
|
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||||
|
"llm_catalog": _llm_catalog()}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
if action == "prompt":
|
||||||
|
ctx["prompt_result"] = logic.prompt(
|
||||||
|
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
||||||
|
provider=llm_provider, model=llm_model,
|
||||||
|
)
|
||||||
|
elif action == "horoscope":
|
||||||
|
ctx["prompt_result"] = logic.horoscope(
|
||||||
|
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||||||
|
from_date=from_date, to_date=to_date,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ctx["result"] = logic.timeline(
|
||||||
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
from_date=from_date, to_date=to_date, interpret=True,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "timeline.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
|
||||||
|
@app.post("/horoscope/stream")
|
||||||
|
def horoscope_stream(
|
||||||
|
profile: str = Form("natal"),
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
prompt_budget: str = Form("medium"),
|
||||||
|
llm_provider: str = Form("local"),
|
||||||
|
llm_model: str = Form(""),
|
||||||
|
from_date: str = Form(""),
|
||||||
|
to_date: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
|
||||||
|
|
||||||
|
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby
|
||||||
|
przeładowanie strony — jedno źródło prawdy dla wyglądu wyniku.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
try:
|
||||||
|
iso_utc, _ = _build_utc(date, time, tz_offset)
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
||||||
|
|
||||||
|
payload: dict = {
|
||||||
|
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
|
||||||
|
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
|
||||||
|
}
|
||||||
|
if profile == "period" and from_date and to_date:
|
||||||
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
|
|
||||||
|
def relay():
|
||||||
|
try:
|
||||||
|
for raw in logic.horoscope_stream(payload):
|
||||||
|
try:
|
||||||
|
event = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if event.get("type") == "result":
|
||||||
|
html = templates.get_template("_prompt_result.html").render(
|
||||||
|
prompt_result=event.get("result") or {}
|
||||||
|
)
|
||||||
|
event["html"] = html
|
||||||
|
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
yield json.dumps({"type": "error", "message": _logic_error(e)},
|
||||||
|
ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
return StreamingResponse(relay(), media_type="application/x-ndjson",
|
||||||
|
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
||||||
|
@app.get("/geocode")
|
||||||
|
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
||||||
|
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
|
||||||
|
try:
|
||||||
|
return {"results": geocode.search(q)}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/reverse")
|
||||||
|
def geocode_reverse(lat: float, lon: float):
|
||||||
|
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
|
||||||
|
try:
|
||||||
|
return geocode.reverse(lat, lon)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {"status": "ok", "layer": "presentation"}
|
return {"status": "ok", "layer": "presentation"}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""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ń);
|
||||||
|
rozliczany per adres klienta, a za odwrotnym proxy — po TRUST_PROXY=true —
|
||||||
|
per adres z nagłówka, nie per adres proxy (patrz `client_ip`).
|
||||||
|
|
||||||
|
Ś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"))
|
||||||
|
|
||||||
|
|
||||||
|
def trust_proxy() -> bool:
|
||||||
|
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
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 client_ip(request: Request) -> str:
|
||||||
|
"""Adres, po którym rozliczamy limit żądań.
|
||||||
|
|
||||||
|
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
|
||||||
|
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
|
||||||
|
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
|
||||||
|
wyklika limit, odcięłaby pozostałe.
|
||||||
|
|
||||||
|
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
|
||||||
|
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
|
||||||
|
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
|
||||||
|
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
|
||||||
|
klient, więc nie znaczą nic.
|
||||||
|
"""
|
||||||
|
peer = request.client.host if request.client else "?"
|
||||||
|
if not trust_proxy():
|
||||||
|
return peer
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "")
|
||||||
|
if forwarded:
|
||||||
|
last = forwarded.rsplit(",", 1)[-1].strip()
|
||||||
|
if last:
|
||||||
|
return last
|
||||||
|
return request.headers.get("x-real-ip", "").strip() or peer
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if _rate_limited(client_ip(request)):
|
||||||
|
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)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Kopiowanie do schowka dla przycisków [data-copy="#selektor"].
|
||||||
|
// clipboard API wymaga secure context (https/localhost), a aplikacja bywa serwowana
|
||||||
|
// po http://<ip> — dlatego jest awaryjne przejście na zaznaczenie + execCommand.
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
const btn = e.target.closest('[data-copy]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const src = document.querySelector(btn.getAttribute('data-copy'));
|
||||||
|
if (!src) return;
|
||||||
|
|
||||||
|
const done = ok => {
|
||||||
|
const label = btn.textContent;
|
||||||
|
btn.textContent = ok ? 'Skopiowano ✓' : 'Nie udało się — zaznacz i skopiuj ręcznie';
|
||||||
|
setTimeout(() => { btn.textContent = label; }, 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = src.value !== undefined ? src.value : src.textContent;
|
||||||
|
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => done(true), () => done(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// awaryjnie: zaznacz zawartość i spróbuj starym poleceniem
|
||||||
|
try {
|
||||||
|
src.focus();
|
||||||
|
if (src.select) src.select();
|
||||||
|
done(document.execCommand('copy'));
|
||||||
|
} catch (err) {
|
||||||
|
done(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Wyszukiwarka lokalizacji + mapa (OpenStreetMap / Leaflet, bez klucza API).
|
||||||
|
// Wpina się do formularza z polami input[name=lat] / input[name=lon]:
|
||||||
|
// - wpisanie nazwy/adresu/POI -> /geocode -> lista trafień -> klik ustawia lat/lon,
|
||||||
|
// - klik na mapie lub przeciągnięcie pineski -> ustawia lat/lon + /reverse pokazuje nazwę,
|
||||||
|
// - „Tu i teraz" (now.js) emituje event 'astrololo:coords' -> mapa się synchronizuje.
|
||||||
|
// Bez internetu wyszukiwarka/kafelki nie działają, ale ręczne lat/lon dalej tak.
|
||||||
|
(function () {
|
||||||
|
function ready(fn) {
|
||||||
|
if (document.readyState !== 'loading') fn();
|
||||||
|
else document.addEventListener('DOMContentLoaded', fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
ready(function () {
|
||||||
|
var picker = document.getElementById('geoPicker');
|
||||||
|
if (!picker || typeof L === 'undefined') return; // brak Leafleta = po cichu pas
|
||||||
|
var latEl = document.querySelector('input[name=lat]');
|
||||||
|
var lonEl = document.querySelector('input[name=lon]');
|
||||||
|
if (!latEl || !lonEl) return;
|
||||||
|
|
||||||
|
var searchEl = document.getElementById('geoSearch');
|
||||||
|
var searchBtn = document.getElementById('geoSearchBtn');
|
||||||
|
var mapToggle = document.getElementById('geoMapToggle');
|
||||||
|
var resultsEl = document.getElementById('geoResults');
|
||||||
|
var mapEl = document.getElementById('map');
|
||||||
|
|
||||||
|
var num = function (el) { var v = parseFloat(el.value); return isNaN(v) ? 0 : v; };
|
||||||
|
var curLat = function () { return num(latEl); };
|
||||||
|
var curLon = function () { return num(lonEl); };
|
||||||
|
|
||||||
|
var pin = L.divIcon({ className: 'geo-pin', html: '📍', iconSize: [24, 24], iconAnchor: [12, 24] });
|
||||||
|
var map = L.map(mapEl).setView([curLat(), curLon()], 12);
|
||||||
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
var marker = L.marker([curLat(), curLon()], { draggable: true, icon: pin }).addTo(map);
|
||||||
|
|
||||||
|
// Kontener mógł być renderowany zanim ustaliła się jego szerokość — poprawka.
|
||||||
|
setTimeout(function () { map.invalidateSize(); }, 60);
|
||||||
|
|
||||||
|
function setCoords(lat, lon, fromMap) {
|
||||||
|
latEl.value = lat.toFixed(4);
|
||||||
|
lonEl.value = lon.toFixed(4);
|
||||||
|
marker.setLatLng([lat, lon]);
|
||||||
|
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
|
||||||
|
if (fromMap) reverseName(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverseName(lat, lon) {
|
||||||
|
fetch('/reverse?lat=' + lat + '&lon=' + lon)
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (d) { if (d && d.name) searchEl.value = d.name; })
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
marker.on('dragend', function () {
|
||||||
|
var p = marker.getLatLng();
|
||||||
|
setCoords(p.lat, p.lng, true);
|
||||||
|
});
|
||||||
|
map.on('click', function (e) { setCoords(e.latlng.lat, e.latlng.lng, true); });
|
||||||
|
|
||||||
|
// ---- wyszukiwanie po nazwie ----
|
||||||
|
function renderResults(items) {
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
if (!items.length) { resultsEl.hidden = true; return; }
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.textContent = it.name;
|
||||||
|
li.addEventListener('click', function () {
|
||||||
|
setCoords(it.lat, it.lon, false);
|
||||||
|
searchEl.value = it.name;
|
||||||
|
resultsEl.hidden = true;
|
||||||
|
});
|
||||||
|
resultsEl.appendChild(li);
|
||||||
|
});
|
||||||
|
resultsEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSearch() {
|
||||||
|
var q = searchEl.value.trim();
|
||||||
|
if (q.length < 3) { resultsEl.hidden = true; return; }
|
||||||
|
fetch('/geocode?q=' + encodeURIComponent(q))
|
||||||
|
.then(function (r) { return r.ok ? r.json() : { results: [] }; })
|
||||||
|
.then(function (d) { renderResults(d.results || []); })
|
||||||
|
.catch(function () { renderResults([]); });
|
||||||
|
}
|
||||||
|
|
||||||
|
var timer = null;
|
||||||
|
searchBtn.addEventListener('click', doSearch);
|
||||||
|
searchEl.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); doSearch(); } // nie wysyłaj formularza
|
||||||
|
});
|
||||||
|
searchEl.addEventListener('input', function () {
|
||||||
|
clearTimeout(timer); timer = setTimeout(doSearch, 500); // debounce (limit Nominatim)
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- zwijanie/rozwijanie mapy ----
|
||||||
|
mapToggle.addEventListener('click', function () {
|
||||||
|
var hidden = mapEl.hasAttribute('hidden');
|
||||||
|
if (hidden) {
|
||||||
|
mapEl.removeAttribute('hidden');
|
||||||
|
mapToggle.textContent = 'Ukryj mapę';
|
||||||
|
setTimeout(function () { map.invalidateSize(); map.setView([curLat(), curLon()]); }, 60);
|
||||||
|
} else {
|
||||||
|
mapEl.setAttribute('hidden', '');
|
||||||
|
mapToggle.textContent = 'Pokaż mapę';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- synchronizacja z „Tu i teraz" ----
|
||||||
|
document.addEventListener('astrololo:coords', function () {
|
||||||
|
marker.setLatLng([curLat(), curLon()]);
|
||||||
|
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -32,6 +32,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
function (pos) {
|
function (pos) {
|
||||||
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
||||||
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
||||||
|
document.dispatchEvent(new CustomEvent('astrololo:coords')); // zsynchronizuj mapę (geo.js)
|
||||||
say('Lokalizacja pobrana ✓');
|
say('Lokalizacja pobrana ✓');
|
||||||
},
|
},
|
||||||
function (err) {
|
function (err) {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Okno postępu przy pisaniu horoskopu.
|
||||||
|
//
|
||||||
|
// Problem: generowanie trwa minutami, a zwykły POST formularza nie daje żadnego
|
||||||
|
// sygnału — aplikacja wygląda na zawieszoną. Zamiast udawanego paska postępu
|
||||||
|
// czytamy strumień RZECZYWISTYCH zdarzeń z serwera (NDJSON) i wypisujemy je
|
||||||
|
// jako log: budowa promptu, limity modelu, każda tura generowania.
|
||||||
|
//
|
||||||
|
// Degradacja: jeśli przeglądarka nie umie strumieniować `fetch`, nie przechwytujemy
|
||||||
|
// wysyłki — formularz idzie klasycznie i wszystko działa jak wcześniej, tylko bez okna.
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const canStream = typeof fetch === 'function' && typeof ReadableStream === 'function' &&
|
||||||
|
typeof TextDecoder === 'function';
|
||||||
|
const form = document.querySelector('form[action="/interpret"], form[action="/timeline"]');
|
||||||
|
if (!canStream || !form) return;
|
||||||
|
|
||||||
|
const profile = form.getAttribute('action') === '/timeline' ? 'period' : 'natal';
|
||||||
|
const btn = form.querySelector('button[value="horoscope"]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
// --- okno ---------------------------------------------------------------
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'progress-overlay';
|
||||||
|
overlay.hidden = true;
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="progress-box" role="dialog" aria-modal="true" aria-label="Postęp generowania">' +
|
||||||
|
'<div class="progress-head">' +
|
||||||
|
'<span class="progress-spinner" aria-hidden="true"></span>' +
|
||||||
|
'<strong id="progressTitle">Piszę horoskop…</strong>' +
|
||||||
|
'<span class="progress-clock" id="progressClock">0:00</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<ol class="progress-log" id="progressLog"></ol>' +
|
||||||
|
'<p class="muted small">Nie zamykaj tej karty — generowanie trwa na serwerze.</p>' +
|
||||||
|
'<div class="actions"><button type="button" class="ghost" id="progressClose" hidden>Zamknij</button></div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const logEl = overlay.querySelector('#progressLog');
|
||||||
|
const clockEl = overlay.querySelector('#progressClock');
|
||||||
|
const titleEl = overlay.querySelector('#progressTitle');
|
||||||
|
const closeEl = overlay.querySelector('#progressClose');
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function addLine(text, kind) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
if (kind) li.className = 'log-' + kind;
|
||||||
|
const now = new Date();
|
||||||
|
li.textContent = String(now.getHours()).padStart(2, '0') + ':' +
|
||||||
|
String(now.getMinutes()).padStart(2, '0') + ':' +
|
||||||
|
String(now.getSeconds()).padStart(2, '0') + ' ' + text;
|
||||||
|
logEl.appendChild(li);
|
||||||
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startClock() {
|
||||||
|
const t0 = Date.now();
|
||||||
|
timer = setInterval(function () {
|
||||||
|
const s = Math.floor((Date.now() - t0) / 1000);
|
||||||
|
clockEl.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(title, allowClose) {
|
||||||
|
if (timer) { clearInterval(timer); timer = null; }
|
||||||
|
titleEl.textContent = title;
|
||||||
|
overlay.querySelector('.progress-spinner').style.visibility = 'hidden';
|
||||||
|
if (allowClose) closeEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEl.addEventListener('click', function () { overlay.hidden = true; });
|
||||||
|
|
||||||
|
// --- przechwycenie wysyłki ----------------------------------------------
|
||||||
|
btn.addEventListener('click', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!form.reportValidity()) return; // te same reguły co przy zwykłej wysyłce
|
||||||
|
|
||||||
|
const data = new FormData(form);
|
||||||
|
data.set('profile', profile);
|
||||||
|
|
||||||
|
logEl.innerHTML = '';
|
||||||
|
closeEl.hidden = true;
|
||||||
|
overlay.querySelector('.progress-spinner').style.visibility = '';
|
||||||
|
titleEl.textContent = 'Piszę horoskop…';
|
||||||
|
overlay.hidden = false;
|
||||||
|
startClock();
|
||||||
|
addLine('Wysyłam żądanie…');
|
||||||
|
|
||||||
|
fetch('/horoscope/stream', { method: 'POST', body: data })
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok || !response.body) throw new Error('HTTP ' + response.status);
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
function pump() {
|
||||||
|
return reader.read().then(function (chunk) {
|
||||||
|
if (chunk.done) return;
|
||||||
|
buffer += decoder.decode(chunk.value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop(); // ostatni może być niepełny
|
||||||
|
lines.forEach(function (raw) {
|
||||||
|
if (!raw.trim()) return;
|
||||||
|
let ev;
|
||||||
|
try { ev = JSON.parse(raw); } catch (e) { return; }
|
||||||
|
if (ev.type === 'ping') return; // sam heartbeat, nie logujemy
|
||||||
|
if (ev.type === 'result') {
|
||||||
|
addLine(ev.message || 'Gotowe.', 'ok');
|
||||||
|
if (ev.html) {
|
||||||
|
const host = document.getElementById('promptResult');
|
||||||
|
if (host) host.innerHTML = ev.html;
|
||||||
|
}
|
||||||
|
finish('Gotowe', true);
|
||||||
|
overlay.hidden = true;
|
||||||
|
const host = document.getElementById('promptResult');
|
||||||
|
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
|
||||||
|
: ev.type === 'warn' ? 'warn' : null);
|
||||||
|
if (ev.type === 'error') finish('Nie udało się', true);
|
||||||
|
});
|
||||||
|
return pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pump();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
addLine('Połączenie przerwane: ' + e.message, 'err');
|
||||||
|
addLine('Możesz spróbować ponownie — nic nie zostało utracone.');
|
||||||
|
finish('Nie udało się', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,6 +32,18 @@ h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
|||||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||||
|
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||||
|
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||||
|
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||||
|
2) font-variant-emoji:text — nowsza własność CSS,
|
||||||
|
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||||
|
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||||
|
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||||
|
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||||
|
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||||
|
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||||
|
"DejaVu Sans", sans-serif; }
|
||||||
|
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||||
.row { display: flex; gap: .5rem; }
|
.row { display: flex; gap: .5rem; }
|
||||||
.row input[type=text] { flex: 1; }
|
.row input[type=text] { flex: 1; }
|
||||||
@@ -64,3 +76,55 @@ tr:last-child td { border-bottom: none; }
|
|||||||
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||||
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||||
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
||||||
|
|
||||||
|
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
|
||||||
|
.geo { margin-top: .9rem; }
|
||||||
|
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||||
|
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
|
||||||
|
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
|
||||||
|
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
|
||||||
|
.geo-results li:last-child { border-bottom: none; }
|
||||||
|
.geo-results li:hover { background: var(--panel); color: #fff; }
|
||||||
|
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.geo-map[hidden] { display: none; }
|
||||||
|
.geo-attrib { margin: .4rem 0 0; }
|
||||||
|
.geo-attrib a { color: var(--accent); }
|
||||||
|
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||||
|
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||||
|
.leaflet-control-attribution { font-size: .68rem; }
|
||||||
|
|
||||||
|
/* Generator promptu do LLM (LOG-29/30) */
|
||||||
|
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
|
||||||
|
background: #12132a; color: var(--ink); border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
||||||
|
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* Okno postępu przy generowaniu horoskopu */
|
||||||
|
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
|
||||||
|
atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
|
||||||
|
kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
|
||||||
|
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
|
||||||
|
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1200; padding: 1rem; }
|
||||||
|
.progress-overlay[hidden] { display: none; }
|
||||||
|
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||||
|
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
|
||||||
|
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
|
||||||
|
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
|
||||||
|
color: var(--muted); font-size: .9rem; }
|
||||||
|
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
|
||||||
|
border: 2px solid var(--line); border-top-color: var(--accent);
|
||||||
|
animation: spin .8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
|
||||||
|
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
|
||||||
|
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
max-height: 15rem; overflow-y: auto;
|
||||||
|
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
|
||||||
|
.progress-log li { color: var(--ink); white-space: pre-wrap; }
|
||||||
|
.progress-log li.log-ok { color: #7fd18b; }
|
||||||
|
.progress-log li.log-warn { color: #e0c060; }
|
||||||
|
.progress-log li.log-err { color: #ef6b6b; }
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
/* required styles */
|
||||||
|
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-tile-container,
|
||||||
|
.leaflet-pane > svg,
|
||||||
|
.leaflet-pane > canvas,
|
||||||
|
.leaflet-zoom-box,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Prevents IE11 from highlighting tiles in blue */
|
||||||
|
.leaflet-tile::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||||
|
.leaflet-safari .leaflet-tile {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||||
|
.leaflet-safari .leaflet-tile-container {
|
||||||
|
width: 1600px;
|
||||||
|
height: 1600px;
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||||
|
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||||
|
.leaflet-container .leaflet-overlay-pane svg {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-marker-pane img,
|
||||||
|
.leaflet-container .leaflet-shadow-pane img,
|
||||||
|
.leaflet-container .leaflet-tile-pane img,
|
||||||
|
.leaflet-container img.leaflet-image-layer,
|
||||||
|
.leaflet-container .leaflet-tile {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container img.leaflet-tile {
|
||||||
|
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag {
|
||||||
|
-ms-touch-action: pinch-zoom;
|
||||||
|
/* Fallback for FF which doesn't support pinch-zoom */
|
||||||
|
touch-action: none;
|
||||||
|
touch-action: pinch-zoom;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tile {
|
||||||
|
filter: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile-loaded {
|
||||||
|
visibility: inherit;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 800;
|
||||||
|
}
|
||||||
|
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||||
|
.leaflet-overlay-pane svg {
|
||||||
|
-moz-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane { z-index: 400; }
|
||||||
|
|
||||||
|
.leaflet-tile-pane { z-index: 200; }
|
||||||
|
.leaflet-overlay-pane { z-index: 400; }
|
||||||
|
.leaflet-shadow-pane { z-index: 500; }
|
||||||
|
.leaflet-marker-pane { z-index: 600; }
|
||||||
|
.leaflet-tooltip-pane { z-index: 650; }
|
||||||
|
.leaflet-popup-pane { z-index: 700; }
|
||||||
|
|
||||||
|
.leaflet-map-pane canvas { z-index: 100; }
|
||||||
|
.leaflet-map-pane svg { z-index: 200; }
|
||||||
|
|
||||||
|
.leaflet-vml-shape {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.lvml {
|
||||||
|
behavior: url(#default#VML);
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* control positioning */
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 800;
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.leaflet-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.leaflet-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* zoom and fade animations */
|
||||||
|
|
||||||
|
.leaflet-fade-anim .leaflet-popup {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: opacity 0.2s linear;
|
||||||
|
-moz-transition: opacity 0.2s linear;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
}
|
||||||
|
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-animated {
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
-ms-transform-origin: 0 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
svg.leaflet-zoom-animated {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||||
|
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
}
|
||||||
|
.leaflet-zoom-anim .leaflet-tile,
|
||||||
|
.leaflet-pan-anim .leaflet-tile {
|
||||||
|
-webkit-transition: none;
|
||||||
|
-moz-transition: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* cursors */
|
||||||
|
|
||||||
|
.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaflet-grab {
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.leaflet-crosshair,
|
||||||
|
.leaflet-crosshair .leaflet-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.leaflet-popup-pane,
|
||||||
|
.leaflet-control {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.leaflet-dragging .leaflet-grab,
|
||||||
|
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||||
|
.leaflet-dragging .leaflet-marker-draggable {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* marker & overlays interactivity */
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-pane > svg path,
|
||||||
|
.leaflet-tile-container {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.leaflet-interactive,
|
||||||
|
.leaflet-image-layer.leaflet-interactive,
|
||||||
|
.leaflet-pane > svg path.leaflet-interactive,
|
||||||
|
svg.leaflet-image-layer.leaflet-interactive path {
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visual tweaks */
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #ddd;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
color: #0078A8;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
border: 2px dotted #38f;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general typography */
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general toolbar styles */
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.leaflet-bar a,
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:hover,
|
||||||
|
.leaflet-bar a:focus {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaflet-bar a.leaflet-disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar a {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zoom control */
|
||||||
|
|
||||||
|
.leaflet-control-zoom-in,
|
||||||
|
.leaflet-control-zoom-out {
|
||||||
|
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||||
|
text-indent: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* layers control */
|
||||||
|
|
||||||
|
.leaflet-control-layers {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers.png);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.leaflet-retina .leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers-2x.png);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers .leaflet-control-layers-list,
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-scrollbar {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-selector {
|
||||||
|
margin-top: 2px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 5px -10px 5px -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default icon URLs */
|
||||||
|
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||||
|
background-image: url(images/marker-icon.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* attribution and scale controls */
|
||||||
|
|
||||||
|
.leaflet-container .leaflet-control-attribution {
|
||||||
|
background: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a:hover,
|
||||||
|
.leaflet-control-attribution a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.leaflet-attribution-flag {
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
width: 1em;
|
||||||
|
height: 0.6669em;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control-scale {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control-scale {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
border: 2px solid #777;
|
||||||
|
border-top: none;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 2px 5px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
text-shadow: 1px 1px #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child) {
|
||||||
|
border-top: 2px solid #777;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||||
|
border-bottom: 2px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-attribution,
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* popup */
|
||||||
|
|
||||||
|
.leaflet-popup {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 13px 24px 13px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 17px 0;
|
||||||
|
margin: 1.3em 0;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -20px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
margin: -10px auto 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-moz-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||||
|
color: #757575;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||||
|
color: #585858;
|
||||||
|
}
|
||||||
|
.leaflet-popup-scrolled {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||||
|
-ms-zoom: 1;
|
||||||
|
}
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
width: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||||
|
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-control-zoom,
|
||||||
|
.leaflet-oldie .leaflet-control-layers,
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* div icon */
|
||||||
|
|
||||||
|
.leaflet-div-icon {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
/* Base styles for the element that has a tooltip */
|
||||||
|
.leaflet-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions */
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -12px;
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left {
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
right: 0;
|
||||||
|
margin-right: -12px;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
left: 0;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Prevent printers from removing background-images of controls. */
|
||||||
|
.leaflet-control {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
|||||||
|
{# Wyszukiwarka lokalizacji + interaktywna mapa (OpenStreetMap / Leaflet, bez klucza API).
|
||||||
|
Wklejana do formularzy z polami input[name=lat] / input[name=lon]. Samowystarczalna:
|
||||||
|
ładuje CSS/JS Leafleta (vendorowane lokalnie) oraz geo.js. Kafelki mapy lecą z OSM. #}
|
||||||
|
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css">
|
||||||
|
<div class="geo" id="geoPicker">
|
||||||
|
<div class="geo-search">
|
||||||
|
<input type="search" id="geoSearch" autocomplete="off"
|
||||||
|
placeholder="Szukaj miejsca (np. Szpital Barlickiego, Łódź)…">
|
||||||
|
<button type="button" id="geoSearchBtn" class="ghost">Szukaj</button>
|
||||||
|
<button type="button" id="geoMapToggle" class="ghost">Ukryj mapę</button>
|
||||||
|
</div>
|
||||||
|
<ul id="geoResults" class="geo-results" hidden></ul>
|
||||||
|
<div id="map" class="geo-map"></div>
|
||||||
|
<p class="muted small geo-attrib">
|
||||||
|
Wyszukiwarka i mapa: © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>
|
||||||
|
contributors (Nominatim). Kliknij mapę lub przeciągnij pineskę, by ustawić współrzędne.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<script defer src="/static/vendor/leaflet/leaflet.js"></script>
|
||||||
|
<script defer src="/static/geo.js"></script>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{# Katalog modeli wstrzykiwany przez handler — pole jest tekstowe, więc
|
||||||
|
można wpisać dowolny model, do którego konto ma dostęp. #}
|
||||||
|
<script type="application/json" id="llmCatalog">{{ llm_catalog | tojson }}</script>
|
||||||
|
{# Wspólny blok: sterowanie generowaniem + prompt (LOG-29/30) + horoskop (LOG-31).
|
||||||
|
Używany na ekranach Interpretacje i Kalendarz. #}
|
||||||
|
<div class="opts">
|
||||||
|
<label>Budżet promptu
|
||||||
|
<select name="prompt_budget">
|
||||||
|
{% set pb = form.prompt_budget or 'medium' %}
|
||||||
|
<option value="concise" {{ 'selected' if pb == 'concise' else '' }}>zwięzły (~4 tys. znaków)</option>
|
||||||
|
<option value="medium" {{ 'selected' if pb == 'medium' else '' }}>średni (~12 tys.)</option>
|
||||||
|
<option value="extensive" {{ 'selected' if pb == 'extensive' else '' }}>obszerny (~30 tys.)</option>
|
||||||
|
<option value="huge" {{ 'selected' if pb == 'huge' else '' }}>bardzo obszerny (~120 tys.)</option>
|
||||||
|
<option value="max" {{ 'selected' if pb == 'max' else '' }}>maksymalny kontekst modelu</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Dostawca
|
||||||
|
<select name="llm_provider" id="llmProvider">
|
||||||
|
{% set lp = form.llm_provider or 'local' %}
|
||||||
|
<option value="local" {{ 'selected' if lp == 'local' else '' }}>lokalny — nic nie opuszcza sieci</option>
|
||||||
|
<option value="anthropic" {{ 'selected' if lp == 'anthropic' else '' }}>Anthropic — dane wychodzą</option>
|
||||||
|
<option value="openai" {{ 'selected' if lp == 'openai' else '' }}>OpenAI — dane wychodzą</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Model
|
||||||
|
<input type="text" name="llm_model" id="llmModel" list="llmModelList"
|
||||||
|
value="{{ form.llm_model or '' }}" placeholder="domyślny dostawcy"
|
||||||
|
autocomplete="off">
|
||||||
|
<datalist id="llmModelList"></datalist>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="muted small" id="llmModelHint"></p>
|
||||||
|
<p class="muted small">
|
||||||
|
Prompt zawiera <strong>oryginalne opisy z baz</strong> oraz dane urodzeniowe. Model lokalny
|
||||||
|
przetwarza je u nas; wybór dostawcy w chmurze oznacza, że ta treść <strong>opuszcza naszą
|
||||||
|
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="promptResult">{% include "_prompt_result.html" %}</div>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{# Blok WYNIKU generowania (prompt/horoskop). Wydzielony, bo wstawia go także
|
||||||
|
okno postępu po zakończeniu strumienia — dzięki temu jest jedno źródło
|
||||||
|
prawdy dla wyglądu wyniku, niezależnie od drogi, którą przyszedł. #}
|
||||||
|
{% if prompt_result %}
|
||||||
|
{% set st = prompt_result.stats %}
|
||||||
|
|
||||||
|
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
|
||||||
|
{% if prompt_result.horoscope %}
|
||||||
|
<div class="meta">
|
||||||
|
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
|
||||||
|
model: {{ prompt_result.model }}
|
||||||
|
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
|
||||||
|
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
|
||||||
|
{% endif %}
|
||||||
|
{% if prompt_result.leaves_lan %}
|
||||||
|
· <strong class="retro">dane opuściły sieć</strong>
|
||||||
|
{% else %}
|
||||||
|
· dane nie opuściły sieci
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
|
||||||
|
<p class="muted small">
|
||||||
|
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
|
||||||
|
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.warnings %}
|
||||||
|
{% for w in prompt_result.warnings %}
|
||||||
|
<p class="muted small"><strong>Uwaga:</strong> {{ w }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.token_plan %}
|
||||||
|
{% set tp = prompt_result.token_plan %}
|
||||||
|
<p class="muted small">
|
||||||
|
Tokeny: prompt {{ tp.prompt_tokens }} · okno modelu {{ tp.context_window }} ·
|
||||||
|
zarezerwowane na odpowiedź {{ tp.max_output }}
|
||||||
|
{% if prompt_result.usage and prompt_result.usage.turns and prompt_result.usage.turns > 1 %}
|
||||||
|
· odpowiedź złożona z {{ prompt_result.usage.turns }} tur (model dokańczał urwany tekst)
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.llm_error %}
|
||||||
|
<div class="error">
|
||||||
|
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
|
||||||
|
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
|
||||||
|
<div class="meta">
|
||||||
|
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
|
||||||
|
budżet: {{ st.budget }} ·
|
||||||
|
wskazań: <strong>{{ st.included }}</strong>
|
||||||
|
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
|
||||||
|
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if st.omitted %}
|
||||||
|
<p class="muted small">
|
||||||
|
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
|
||||||
|
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prompt_result.data_error %}
|
||||||
|
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
|
||||||
|
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
|
||||||
|
{% endif %}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
|
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
|
||||||
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
|
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
|
||||||
|
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
|
||||||
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -32,11 +32,25 @@
|
|||||||
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
|
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>Zodiak
|
||||||
|
<select name="zodiac">
|
||||||
|
{% set zd = form.zodiac or 'tropical' %}
|
||||||
|
<option value="tropical" {{ 'selected' if zd == 'tropical' else '' }}>Tropikalny</option>
|
||||||
|
<option value="sidereal_lahiri" {{ 'selected' if zd == 'sidereal_lahiri' else '' }}>Syderyczny (Lahiri)</option>
|
||||||
|
<option value="sidereal_fagan_bradley" {{ 'selected' if zd == 'sidereal_fagan_bradley' else '' }}>Syderyczny (Fagan-Bradley)</option>
|
||||||
|
<option value="sidereal_krishnamurti" {{ 'selected' if zd == 'sidereal_krishnamurti' else '' }}>Syderyczny (Krishnamurti)</option>
|
||||||
|
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{% include "_location_picker.html" %}
|
||||||
<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 %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit">Policz horoskop</button>
|
<button type="submit">Policz horoskop</button>
|
||||||
@@ -53,6 +67,7 @@
|
|||||||
Silnik: <strong>{{ result.engine }}</strong> ·
|
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||||
obiektów: {{ result.positions | length }}
|
obiektów: {{ result.positions | length }}
|
||||||
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
|
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
|
||||||
|
{% if result.zodiac %}· zodiak: {{ result.zodiac }}{% if result.ayanamsha is defined %} (ayanamsa {{ '%.4f'|format(result.ayanamsha) }}°){% endif %}{% endif %}
|
||||||
{% if moment %}· moment: {{ moment }}{% endif %}
|
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,23 +86,39 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th title="Symbol astrologiczny (LOG-22)">Sym.</th>
|
||||||
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||||
|
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for p in result.positions %}
|
{% for p in result.positions %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
|
||||||
<td>{{ p.name }}</td>
|
<td>{{ p.name }}</td>
|
||||||
<td>{{ p.sign }}</td>
|
<td>{{ p.sign }}</td>
|
||||||
<td class="mono">{{ p.in_sign }}</td>
|
<td class="mono">{{ p.in_sign }}</td>
|
||||||
<td>{{ p.house if p.house is defined else '—' }}</td>
|
<td>{{ p.house if p.house is defined else '—' }}</td>
|
||||||
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
||||||
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
||||||
|
<td class="mono">{% if p.declination is defined %}{{ '%+.2f°' | format(p.declination) }}{% if p.out_of_bounds %} <span class="badge" title="Out of bounds — deklinacja poza zakresem Słońca">OOB</span>{% endif %}{% else %}—{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% if result.lots %}
|
||||||
|
<div class="meta">Lots hermetyczne ({{ result.lots | length }}) · sekta: <strong>{{ result.sect }}</strong></div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Lot</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Formuła</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for l in result.lots %}
|
||||||
|
<tr><td>{{ l.name }}</td><td>{{ l.sign }}</td><td class="mono">{{ l.in_sign }}</td><td>{{ l.house }}</td><td class="muted small">{{ l.formula }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
|
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
|
||||||
{% if with_stations %}
|
{% if with_stations %}
|
||||||
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
|
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
|
||||||
@@ -111,12 +142,129 @@
|
|||||||
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for a in result.aspects %}
|
{% for a in result.aspects %}
|
||||||
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
<tr><td>{{ a.obj1 }}</td><td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja #}
|
||||||
|
{% if result.parallels %}
|
||||||
|
<div class="meta" title="Ciała na tej samej (paralela) lub przeciwnej (kontrparalela) deklinacji — działają jak koniunkcja / opozycja poza ekliptyką">Paralele deklinacji ({{ result.parallels | length }})</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th><th>Dekl.</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in result.parallels %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.obj1 }}</td>
|
||||||
|
<td>{{ 'paralela' if r.type == 'parallel' else 'kontrparalela' }}</td>
|
||||||
|
<td>{{ r.obj2 }}</td>
|
||||||
|
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
|
||||||
|
<td class="mono">{{ '%+.2f'|format(r.dec1) }} / {{ '%+.2f'|format(r.dec2) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.antiscia %}
|
||||||
|
<div class="meta" title="Odbicie względem osi przesileń (antyscja) lub równonocy (kontrantyscja) — punkty „dzielące” tę samą długość dnia">Antyscja ({{ result.antiscia | length }})</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in result.antiscia %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.obj1 }}</td>
|
||||||
|
<td>{{ 'antyscja' if r.type == 'antiscion' else 'kontrantyscja' }}</td>
|
||||||
|
<td>{{ r.obj2 }}</td>
|
||||||
|
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.tables %}
|
||||||
|
{% set tb = result.tables %}
|
||||||
|
<div class="meta">Tabele dodatkowe (LOG-23)</div>
|
||||||
|
|
||||||
|
{% set bal = tb.tally.with_modern_10_plus_asc %}
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Bilans (10 planet + Asc)</th><th colspan="4">Rozkład</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Żywioły</td>
|
||||||
|
{% for e, n in bal.elements.items() %}
|
||||||
|
<td>{{ tb.tally.labels.elements[e] }}: <strong>{{ n }}</strong></td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr><td>Jakości</td>
|
||||||
|
{% for q, n in bal.qualities.items() %}
|
||||||
|
<td>{{ tb.tally.labels.qualities[q] }}: <strong>{{ n }}</strong></td>
|
||||||
|
{% endfor %}
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if tb.tally.missing_elements or tb.tally.missing_qualities %}
|
||||||
|
<p class="muted small">Brak:
|
||||||
|
{% for e in tb.tally.missing_elements %}<strong>{{ tb.tally.labels.elements[e] }}</strong>{{ ", " if not loop.last }}{% endfor %}
|
||||||
|
{% for q in tb.tally.missing_qualities %}<strong>{{ tb.tally.labels.qualities[q] }}</strong>{{ ", " if not loop.last }}{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.moon_phase %}
|
||||||
|
<p class="muted small">
|
||||||
|
<strong>Faza Księżyca:</strong> {{ tb.moon_phase.phase_pl }} ·
|
||||||
|
elongacja {{ '%.2f'|format(tb.moon_phase.angle) }}° ·
|
||||||
|
oświetlenie {{ '%.1f'|format(tb.moon_phase.illumination * 100) }}% ·
|
||||||
|
{{ 'przybywa' if tb.moon_phase.waxing else 'ubywa' }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.planetary_hours %}
|
||||||
|
{% set ph = tb.planetary_hours %}
|
||||||
|
<p class="muted small">
|
||||||
|
<strong>Godziny planetarne:</strong> władca dnia {{ ph.day_ruler }} ·
|
||||||
|
{{ ph.hour_number }}. godzina ({{ ph.period }}), władca {{ ph.hour_ruler }} ·
|
||||||
|
godzina trwa {{ ph.hour_length_minutes }} min
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.prenatal_syzygy %}
|
||||||
|
{% set s = tb.prenatal_syzygy %}
|
||||||
|
<p class="muted small">
|
||||||
|
<strong>Syzygia prenatalna:</strong> {{ s.type_pl }} {{ s.when_utc }}
|
||||||
|
({{ s.days_before_birth }} dni przed) w {{ s.in_sign }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tb.critical_degrees %}
|
||||||
|
<div class="meta">Stopnie krytyczne</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Obiekt</th><th>Pozycja</th><th>Uwaga</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in tb.critical_degrees %}
|
||||||
|
<tr><td>{{ c.name }}</td><td class="mono">{{ c.in_sign }}</td>
|
||||||
|
<td class="muted small">{{ c.flags | join('; ') }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<details class="loc">
|
||||||
|
<summary>Podziały: dwunastniki (D12) i nawamsa (D9)</summary>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Obiekt</th><th>D12</th><th>D9</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in tb.divisional %}
|
||||||
|
<tr><td>{{ d.name }}</td><td class="mono">{{ d.d12_in_sign }}</td>
|
||||||
|
<td class="mono">{{ d.d9_in_sign }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if result.cusps %}
|
{% 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>
|
||||||
|
|||||||
@@ -23,12 +23,17 @@
|
|||||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{% include "_location_picker.html" %}
|
||||||
<div class="opts">
|
<div class="opts">
|
||||||
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
|
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</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 %}
|
||||||
|
{% include "_prompt_block.html" %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit">Szukaj interpretacji</button>
|
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
|
||||||
|
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||||
|
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||||
<span id="geoNote" class="muted small"></span>
|
<span id="geoNote" class="muted small"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -84,4 +89,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script src="/static/now.js"></script>
|
<script src="/static/now.js"></script>
|
||||||
|
<script src="/static/copy.js"></script>
|
||||||
|
<script src="/static/models.js"></script>
|
||||||
|
<script src="/static/progress.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Kalendarz{% endblock %}
|
||||||
|
{% block nav_timeline %}active{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/timeline">
|
||||||
|
<div class="grid">
|
||||||
|
<label>Data urodzenia
|
||||||
|
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Godzina (lokalna)
|
||||||
|
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Strefa (offset h)
|
||||||
|
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
<label>Szerokość (lat)
|
||||||
|
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
<label>Długość (lon)
|
||||||
|
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||||
|
{% include "_location_picker.html" %}
|
||||||
|
<div class="grid">
|
||||||
|
<label>Zakres od
|
||||||
|
<input type="date" name="from_date" value="{{ form.from_date or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Zakres do
|
||||||
|
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% include "_prompt_block.html" %}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
|
<button type="submit" name="action" value="timeline">Pokaż kalendarz</button>
|
||||||
|
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
|
||||||
|
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
|
||||||
|
<span id="geoNote" class="muted small"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
{% if result.data_error %}<div class="error">{{ result.data_error }}</div>{% endif %}
|
||||||
|
<div class="meta">
|
||||||
|
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||||
|
zdarzeń: {{ result.count }} · zakres {{ result.from }} → {{ result.to }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for e in result.events %}
|
||||||
|
<div class="sig-item">
|
||||||
|
<div class="sig-head">
|
||||||
|
<span class="badge">{{ e.exact }}</span>
|
||||||
|
<span class="badge">{{ e.technique }}</span>
|
||||||
|
<strong>{{ e.significator }}</strong>
|
||||||
|
<span class="muted small">(okno {{ e.start }} → {{ e.end }})</span>
|
||||||
|
</div>
|
||||||
|
{% if e.interpretations %}
|
||||||
|
<table class="samples">
|
||||||
|
<tbody>
|
||||||
|
{% for s in e.interpretations %}
|
||||||
|
<tr><td class="sig nowrap" title="{{ s.significator }}">{{ s.expanded }}</td><td>{{ s.effect }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if e.interpretations_count > e.interpretations | length %}
|
||||||
|
<div class="muted small">…i {{ e.interpretations_count - (e.interpretations | length) }} więcej</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif e.technique != 'solar_return' %}
|
||||||
|
<div class="muted small">brak dopasowań w bazie</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script src="/static/now.js"></script>
|
||||||
|
<script src="/static/copy.js"></script>
|
||||||
|
<script src="/static/models.js"></script>
|
||||||
|
<script src="/static/progress.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
@@ -3,3 +3,5 @@ uvicorn[standard]>=0.34
|
|||||||
httpx>=0.28
|
httpx>=0.28
|
||||||
jinja2>=3.1
|
jinja2>=3.1
|
||||||
python-multipart>=0.0.20
|
python-multipart>=0.0.20
|
||||||
|
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||||
|
cryptography>=44.0
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Niezmiennik: KAŻDE wyjście HTTP w dół niesie token międzywarstwowy (LOG-32)
|
||||||
|
oraz klucz szyfrujący łącze (PRE-16).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Po dołożeniu szyfrowania ta sama klasa błędu ma gorszy objaw: wywołanie bez `link=`
|
||||||
|
nie wywala się widocznie, tylko po cichu wysyła treść JAWNYM tekstem. Dlatego dla
|
||||||
|
wywołań przez `link_crypto` wymagamy obu argumentów naraz.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
CLIENT = pathlib.Path(__file__).resolve().parents[1] / "app" / "clients" / "logic_client.py"
|
||||||
|
|
||||||
|
HTTP_VERBS = ("post", "get", "put", "patch", "delete")
|
||||||
|
# Wszystkie drogi w dół przez link_crypto — także strumień postępu (stream_lines),
|
||||||
|
# który dołożono po scaleniu okna postępu z szyfrowaniem. Każda musi nieść i token,
|
||||||
|
# i klucz łącza.
|
||||||
|
LINK_CALLS = ("call", "call_json", "stream_lines")
|
||||||
|
|
||||||
|
|
||||||
|
def _http_calls(path: pathlib.Path) -> list[tuple[str, int, bool, bool]]:
|
||||||
|
"""(opis, linia, czy_ma_headers, czy_wymaga_i_ma_link) dla każdego wyjścia w dół."""
|
||||||
|
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
|
||||||
|
target = node.func.value
|
||||||
|
has_headers = any(kw.arg == "headers" for kw in node.keywords)
|
||||||
|
has_link = any(kw.arg == "link" for kw in node.keywords)
|
||||||
|
|
||||||
|
if (node.func.attr in HTTP_VERBS
|
||||||
|
and isinstance(target, ast.Name) and target.id == "client"):
|
||||||
|
# Surowe wywołanie httpx — omija szyfrowanie, więc dopuszczalne tylko
|
||||||
|
# dla ścieżek wyjętych spod niego (patrz link_crypto.PUBLIC_PATHS).
|
||||||
|
out.append((f"client.{node.func.attr}()", node.lineno, has_headers, True))
|
||||||
|
elif (node.func.attr in LINK_CALLS
|
||||||
|
and isinstance(target, ast.Name) and target.id == "link_crypto"):
|
||||||
|
out.append((f"link_crypto.{node.func.attr}()", node.lineno, has_headers, has_link))
|
||||||
|
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} {what}" for what, 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_every_outbound_call_passes_link_key():
|
||||||
|
"""Brak `link=` nie boli od razu — po prostu treść leci jawnym tekstem."""
|
||||||
|
calls = _http_calls(CLIENT)
|
||||||
|
missing = [f"{CLIENT.name}:{line} {what}" for what, line, _, ok in calls if not ok]
|
||||||
|
assert not missing, (
|
||||||
|
"Wywołania w dół bez klucza łącza — poszłyby NIEZASZYFROWANE: " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_http_calls_only_on_paths_exempt_from_encryption():
|
||||||
|
"""Surowe `client.get/post` wolno wołać wyłącznie tam, gdzie szyfrowania nie ma
|
||||||
|
z założenia (`/health` dla sond k8s). Każde inne to obejście PRE-16."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
|
|
||||||
|
source = CLIENT.read_text(encoding="utf-8").splitlines()
|
||||||
|
offenders = []
|
||||||
|
for what, line, _, _ in _http_calls(CLIENT):
|
||||||
|
if not what.startswith("client."):
|
||||||
|
continue
|
||||||
|
url = re.search(r'f"\{self\.base_url\}([^"]*)"', source[line - 1])
|
||||||
|
if url is None or url.group(1) not in link_crypto.PUBLIC_PATHS:
|
||||||
|
offenders.append(f"{CLIENT.name}:{line} {what}")
|
||||||
|
assert not offenders, (
|
||||||
|
"Surowe wywołania HTTP poza ścieżkami wyjętymi spod szyfrowania: "
|
||||||
|
+ ", ".join(offenders)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""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))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------ adres klienta za odwrotnym proxy
|
||||||
|
#
|
||||||
|
# Po włączeniu TLS (PRE-16) aplikacja stoi za Ingressem, więc bezpośredni peer
|
||||||
|
# to zawsze POD proxy. Te testy pilnują obu stron kompromisu: żeby limit dalej
|
||||||
|
# rozróżniał ludzi, a jednocześnie żeby nagłówek nie stał się furtką do jego
|
||||||
|
# ominięcia.
|
||||||
|
|
||||||
|
class _Req:
|
||||||
|
"""Minimalny zamiennik Request — `client_ip` czyta tylko te dwa pola."""
|
||||||
|
|
||||||
|
def __init__(self, peer: str | None, **headers: str):
|
||||||
|
self.client = type("C", (), {"host": peer})() if peer else None
|
||||||
|
self.headers = {k.replace("_", "-"): v for k, v in headers.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_ignores_headers_without_trust_proxy(monkeypatch):
|
||||||
|
"""Bez TRUST_PROXY nagłówek jest bezwartościowy — każdy może go dopisać."""
|
||||||
|
monkeypatch.delenv("TRUST_PROXY", raising=False)
|
||||||
|
req = _Req("10.42.0.7", x_forwarded_for="1.2.3.4", x_real_ip="5.6.7.8")
|
||||||
|
assert security.client_ip(req) == "10.42.0.7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_takes_last_forwarded_entry(monkeypatch):
|
||||||
|
"""Ostatni wpis dopisał NASZ proxy; wcześniejsze mógł podstawić klient."""
|
||||||
|
monkeypatch.setenv("TRUST_PROXY", "true")
|
||||||
|
req = _Req("10.42.0.7", x_forwarded_for="1.1.1.1, 2.2.2.2, 192.168.1.50")
|
||||||
|
assert security.client_ip(req) == "192.168.1.50"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_falls_back_to_real_ip(monkeypatch):
|
||||||
|
monkeypatch.setenv("TRUST_PROXY", "true")
|
||||||
|
req = _Req("10.42.0.7", x_real_ip="192.168.1.50")
|
||||||
|
assert security.client_ip(req) == "192.168.1.50"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_falls_back_to_peer_when_headers_missing(monkeypatch):
|
||||||
|
monkeypatch.setenv("TRUST_PROXY", "true")
|
||||||
|
assert security.client_ip(_Req("10.42.0.7")) == "10.42.0.7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spoofed_forwarded_header_cannot_dodge_the_limit(monkeypatch):
|
||||||
|
"""Sedno sprawy: bez zaufania do proxy podszywanie się NIE resetuje wiadra.
|
||||||
|
|
||||||
|
Gdyby limiter brał pierwszy lepszy `X-Forwarded-For`, wystarczyłoby zmieniać
|
||||||
|
go co żądanie, żeby pompować bazę bez ograniczeń.
|
||||||
|
"""
|
||||||
|
monkeypatch.delenv("TRUST_PROXY", raising=False)
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
|
||||||
|
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||||
|
client = TestClient(_app())
|
||||||
|
codes = [client.get("/significators", headers={"X-Forwarded-For": f"9.9.9.{i}"}).status_code
|
||||||
|
for i in range(6)]
|
||||||
|
assert 429 in codes
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxied_clients_get_separate_buckets(monkeypatch):
|
||||||
|
"""Za proxy dwie różne osoby nie mogą się nawzajem odcinać."""
|
||||||
|
monkeypatch.setenv("TRUST_PROXY", "true")
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
|
||||||
|
monkeypatch.delenv("APP_PASSWORD", raising=False)
|
||||||
|
client = TestClient(_app())
|
||||||
|
first = [client.get("/significators", headers={"X-Forwarded-For": "192.168.1.50"}).status_code
|
||||||
|
for _ in range(5)]
|
||||||
|
second = client.get("/significators", headers={"X-Forwarded-For": "192.168.1.51"})
|
||||||
|
assert 429 in first, "limit musi zadziałać dla pierwszego adresu"
|
||||||
|
assert second.status_code == 200, "drugi adres ma własne wiadro"
|
||||||
Reference in New Issue
Block a user