Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a833965909 | |||
| baf4e0e38a | |||
| 8b6ecc727d | |||
| 21a00b0000 | |||
| 4d1daab35a | |||
| aec3f84331 | |||
| 7266f671a4 | |||
| 548d9301f3 | |||
| 1be57a47d8 | |||
| 40f5e459e0 | |||
| 86a0f16f9e | |||
| e3114f3e7c | |||
| bc80745a94 | |||
| 6e7cfcbbd7 | |||
| 70c83cfc0c | |||
| 4c1e7f8808 | |||
| d3d9b365fe | |||
| dd32f7e82f | |||
| 78af6d4755 | |||
| 7b435d4263 | |||
| a9f2a038fa | |||
| b36b3bee19 | |||
| a0d1135db1 | |||
| 81e09aa6df | |||
| 8ebce816cd | |||
| 52b7c20c2a | |||
| 9b1e4dbb20 | |||
| 4bdfb673cc | |||
| 9a5d61b5eb | |||
| bb64758fa0 | |||
| 91c4f918dc | |||
| f5dec15e4d | |||
| 495f3734c5 | |||
| adce568729 | |||
| 998c83b26e | |||
| 4b17f2dd67 | |||
| 623603b157 | |||
| 171deff2d1 | |||
| 15964dd0df | |||
| 9ddcdea5e2 | |||
| 56131b209a | |||
| f24616d342 | |||
| 6c9029c496 | |||
| 8cc329ab17 | |||
| 76fbdefeaa | |||
| f34016a4a5 | |||
| 5c82e8bd9f | |||
| 40c9bf7988 | |||
| 1d7f3e2136 | |||
| 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 | |||
| 2dbd3410e9 | |||
| ca458fd741 |
@@ -0,0 +1,33 @@
|
||||
# Kontekst builda dla obrazów budowanych z KORZENIA repo (dziś: pomocniczy obraz
|
||||
# wyroczni w CI — patrz .gitea/workflows/tests.yml). Obrazy usług mają własne
|
||||
# konteksty (services/<usługa>), więc ten plik ich nie dotyczy.
|
||||
#
|
||||
# UWAGA: docker NIE czyta .gitignore. Bez tego pliku do demona poleciałby m.in.
|
||||
# lokalny wirtualenv (~344 MB) i jądra efemeryd — a runner miał już incydent
|
||||
# „no space left on device".
|
||||
|
||||
# środowiska lokalne
|
||||
.env/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# cache Pythona i narzędzi
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# dane generowane/pobierane, odtwarzalne
|
||||
**/.ephemeris/
|
||||
**/.cache/
|
||||
|
||||
# historia i metadane repo
|
||||
.git/
|
||||
.gitea/
|
||||
.claude/
|
||||
|
||||
# rzeczy nieużywane w obrazach
|
||||
docs/
|
||||
*.md
|
||||
@@ -0,0 +1,34 @@
|
||||
name: build-render
|
||||
|
||||
# Osobny pipeline dla uslugi render (raport PDF, PRE-24) — celowo ODDZIELONY od
|
||||
# glownego build.yaml (data/logic/presentation). Obraz dzwiga TeX Live (setki MB),
|
||||
# wiec budowanie go przy KAZDYM pushu do mastera spowalnialoby kazdy deploy — a to
|
||||
# wlasnie ta izolacja mial usunac (patrz services/render/app/main.py, LOG-27).
|
||||
# Buduje sie tylko, gdy zmienia sie sama usluga.
|
||||
#
|
||||
# Obraz konsumuje astrololo/render.yaml w repo `deploy`.
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'services/render/**'
|
||||
- '.gitea/workflows/build-render.yaml'
|
||||
workflow_dispatch: {} # reczne 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 render (TeX Live — pierwszy build trwa dluzej)
|
||||
run: |
|
||||
TAG=${GITHUB_SHA::8}
|
||||
IMG=gitea.czernobog.pl/gitea/astrololo-render
|
||||
# Dockerfile sprawdza obecnosc xelatex + rsvg-convert przy budowie, wiec
|
||||
# build jest zarazem testem, ze obraz ma komplet narzedzi.
|
||||
docker build -t $IMG:$TAG -t $IMG:latest ./services/render
|
||||
docker push $IMG:$TAG
|
||||
docker push $IMG:latest
|
||||
echo "Zbudowano i wypchnieto: $IMG:$TAG (+ latest)"
|
||||
@@ -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,59 @@
|
||||
name: Wyrocznia domów — nocny przemiał
|
||||
|
||||
# DLACZEGO TU, A NIE JAKO JOB W KLASTRZE
|
||||
# Pierwotny plan zakładał CronJob w k3s, bo „duży przemiał jest kosztowny".
|
||||
# Pomiar tego nie potwierdził: 500 000 przypadków × 13 systemów = 70 mln porównań
|
||||
# w 64 sekundy, skalowanie liniowe. Osobny obraz w rejestrze (który już raz zapchał
|
||||
# dysk hosta), manifest, CronJob i kopia harnessu poza repo byłyby infrastrukturą
|
||||
# do problemu, którego nie ma — a każda kopia harnessu poza repo to ryzyko cichego
|
||||
# rozjazdu z kodem, który ma testować.
|
||||
#
|
||||
# Zestaw brzegowy blokuje KAŻDY build (patrz tests.yml). Tutaj chodzi o co innego:
|
||||
# duża losowa próbka z INNYM ZIARNEM co noc, żeby z czasem przeczesać dziedzinę
|
||||
# gęściej, niż zrobi to pojedynczy przebieg.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 2 * * *' # 02:17 — poza godzinami budowania
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
count:
|
||||
description: 'Liczba przypadków'
|
||||
default: '500000'
|
||||
seed:
|
||||
description: 'Ziarno (puste = z daty)'
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Przemiał losowy (13 systemów)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Obraz wyroczni (silnik B + nasza logika + harness)
|
||||
run: |
|
||||
docker build -t astrololo/engine-swisseph:sweep services/engine-swisseph
|
||||
# Pliki WBUDOWANE, nie montowane: job Gitea Actions sam działa w kontenerze,
|
||||
# więc `-v $PWD/...` rozwiązałoby się na HOŚCIE (docker cicho tworzy pusty
|
||||
# katalog i skrypt „znika"). Kontekst builda jest strumieniowany do demona.
|
||||
docker build -t astrololo/oracle:sweep -f - . <<'DOCKERFILE'
|
||||
FROM astrololo/engine-swisseph:sweep
|
||||
COPY services/logic /logic
|
||||
COPY tests/oracle /oracle
|
||||
ENV LOGIC_PATH=/logic
|
||||
DOCKERFILE
|
||||
|
||||
- name: Przemiał
|
||||
run: |
|
||||
COUNT="${{ inputs.count }}"; COUNT="${COUNT:-500000}"
|
||||
SEED="${{ inputs.seed }}"; SEED="${SEED:-$(date -u +%Y%m%d)}"
|
||||
echo "count=$COUNT seed=$SEED"
|
||||
# Kod wyjścia 1 przy przekroczeniu tolerancji ALBO niezgodności dziedziny,
|
||||
# więc job czerwieni się sam — bez parsowania tekstu raportu.
|
||||
docker run --rm astrololo/oracle:sweep \
|
||||
python /oracle/run.py --mode sweep --count "$COUNT" --seed "$SEED"
|
||||
|
||||
# Runner miał już incydent „no space left on device" — sprzątamy zawsze.
|
||||
- name: Usuń obrazy pomocnicze
|
||||
if: always()
|
||||
run: docker rmi -f astrololo/oracle:sweep astrololo/engine-swisseph:sweep 2>/dev/null || true
|
||||
@@ -0,0 +1,221 @@
|
||||
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
|
||||
|
||||
data-tests:
|
||||
name: Testy warstwy bazodanowej (ochrona 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/data/requirements-dev.txt
|
||||
- name: Instalacja zależności
|
||||
run: pip install -r services/data/requirements-dev.txt
|
||||
# Rekordy-pułapki (DAN-26): nietestowany kod ochronny jest gorszy niż jego
|
||||
# brak, bo daje złudzenie zabezpieczenia. Ta warstwa dotąd nie miała testów.
|
||||
- name: Testy (pytest)
|
||||
working-directory: services/data
|
||||
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)}")
|
||||
|
||||
# /houses — kontrakt parzystosci (LOG-28) po stronie DOMOW. Sprawdzamy
|
||||
# nie tylko, ze liczy, ale i ze ODMAWIA tam, gdzie system nie istnieje:
|
||||
# ciche podstawienie innego systemu byloby niewykrywalne dla wolajacego.
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.main import _HOUSE_CODES, HousesRequest, houses
|
||||
|
||||
WHEN = "1984-04-30T09:20:00Z"
|
||||
h = houses(HousesRequest(when_utc=WHEN, lat=50.0647, lon=19.9450, system="campanus"))
|
||||
assert h["engine"] == "swisseph" and h["system"] == "campanus"
|
||||
assert len(h["cusps"]) == 12, h["cusps"]
|
||||
assert [c["house"] for c in h["cusps"]] == list(range(1, 13))
|
||||
assert all(0.0 <= c["longitude"] < 360.0 for c in h["cusps"])
|
||||
assert {"Asc", "MC", "ARMC"} <= set(h["angles"]), h["angles"]
|
||||
|
||||
# kazdy ogloszony system musi dac 12 cuspow tam, gdzie ma definicje
|
||||
for name in _HOUSE_CODES:
|
||||
out = houses(HousesRequest(when_utc=WHEN, lat=50.0647, lon=19.9450, system=name))
|
||||
assert len(out["cusps"]) == 12, name
|
||||
print(f"/houses: {len(_HOUSE_CODES)} systemow OK")
|
||||
|
||||
for bad, why in ((dict(lat=69.65, lon=18.96, system="placidus"), "Tromso/placidus"),
|
||||
(dict(lat=50.0, lon=19.0, system="nie-ma-takiego"), "nieznany system")):
|
||||
try:
|
||||
houses(HousesRequest(when_utc=WHEN, **bad))
|
||||
raise AssertionError(f"{why}: mialo byc 422, a przeszlo")
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 422, (why, e.status_code)
|
||||
print("/houses: odmowy poza dziedzina OK")
|
||||
|
||||
print("SMOKE OK")
|
||||
PY
|
||||
|
||||
# Zgodność naszych domów z wyrocznią (Swiss Ephemeris). Odpalamy WEWNĄTRZ
|
||||
# obrazu silnika B — tylko tam jest pyswisseph — montując naszą warstwę
|
||||
# logiczną i framework. Bez klastra i bez HTTP: to czysta funkcja.
|
||||
# BLOKUJE build: błędny system domów jest CICHY (wykres wygląda dobrze,
|
||||
# planety siedzą w złych domach), więc lepiej zatrzymać go przed wypuszczeniem
|
||||
# niż wykryć po fakcie.
|
||||
- name: Domy — zgodność z wyrocznią (brzegi + wnętrze)
|
||||
run: |
|
||||
# Pliki WBUDOWUJEMY w obraz, a nie montujemy przez -v. Powód ten sam,
|
||||
# dla którego wyżej nie startujemy kontenera w tle: job Gitea Actions sam
|
||||
# działa w kontenerze, więc `-v $PWD/...` docker rozwiązuje na HOŚCIE,
|
||||
# gdzie tej ścieżki nie ma. Docker nie zgłasza wtedy błędu — po cichu
|
||||
# tworzy PUSTY katalog, przez co skrypt „znika". Kontekst builda jest
|
||||
# strumieniowany do demona, więc działa niezależnie od tego, gdzie on stoi.
|
||||
docker build -t astrololo/oracle:ci -f - . <<'DOCKERFILE'
|
||||
FROM astrololo/engine-swisseph:ci
|
||||
COPY services/logic /logic
|
||||
COPY tests/oracle /oracle
|
||||
ENV LOGIC_PATH=/logic
|
||||
DOCKERFILE
|
||||
docker run --rm astrololo/oracle:ci python /oracle/run.py --mode build
|
||||
|
||||
# Obraz pomocniczy nie jest już potrzebny — a runner miał już incydent
|
||||
# „no space left on device", więc sprzątamy po sobie od razu.
|
||||
- name: Usuń obraz pomocniczy wyroczni
|
||||
if: always()
|
||||
run: docker rmi -f astrololo/oracle:ci 2>/dev/null || true
|
||||
|
||||
# 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
|
||||
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
|
||||
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
|
||||
# updater test Mon 20 Jul 2026 19:52:08 CEST
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
services:
|
||||
data:
|
||||
image: gitea.czernobog.pl/gitea/astrololo-data:latest
|
||||
container_name: astrololo-data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATA_PROVIDER: ${DATA_PROVIDER:-excel}
|
||||
EXCEL_DIR: /app/data_files
|
||||
CACHE_DIR: /app/.cache
|
||||
INDEXED_KEYS: name,id,symbol
|
||||
volumes:
|
||||
- ./services/data/data_files:/app/data_files
|
||||
- data_cache:/app/.cache
|
||||
ports:
|
||||
- "8002:8002"
|
||||
|
||||
logic:
|
||||
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
|
||||
container_name: astrololo-logic
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATA_URL: http://data:8002
|
||||
EPHEMERIS_ENGINE: own # silnik własny (permisywny) — domyślny
|
||||
# adres silnika B (AGPL) używany tylko w trybie porównawczym (profil comparison)
|
||||
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||
depends_on:
|
||||
- data
|
||||
ports:
|
||||
- "8001:8001"
|
||||
|
||||
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||
# docker compose --profile comparison up
|
||||
# Nie wchodzi do domyślnego (zamkniętego) produktu — patrz services/engine-swisseph/LICENSE.
|
||||
engine-swisseph:
|
||||
image: gitea.czernobog.pl/gitea/astrololo-engine-swisseph:latest
|
||||
container_name: astrololo-engine-swisseph
|
||||
restart: unless-stopped
|
||||
profiles: ["comparison"]
|
||||
ports:
|
||||
- "8003:8003"
|
||||
|
||||
presentation:
|
||||
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
|
||||
container_name: astrololo-presentation
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LOGIC_URL: http://logic:8001
|
||||
depends_on:
|
||||
- logic
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
volumes:
|
||||
data_cache:
|
||||
Binary file not shown.
@@ -0,0 +1,58 @@
|
||||
# Rekordy-pułapki (canary) — instrukcja (DAN-26)
|
||||
|
||||
Zabezpieczenie **detekcyjne**: nie zapobiega wyciekowi baz, ale pozwala go
|
||||
**wykryć** i wskazać **z której kopii** wyciekł. Bazy są kupione i są rdzeniem
|
||||
produktu — jeśli krążą gdzie indziej, chcemy to udowodnić.
|
||||
|
||||
Mechanizm żyje w warstwie danych (`services/data/app/canary.py`) i działa na
|
||||
wyjściu z `/search`, więc pułapki **nie docierają** ani do użytkownika, ani do
|
||||
promptu LLM (wymóg LOG-30) — niezależnie od dostawcy (Excel/SQL).
|
||||
|
||||
## Jak to działa
|
||||
|
||||
1. **Marker** — unikalny ciąg, który nie występuje w realnych danych, wpleciony
|
||||
w kilka wiarygodnie wyglądających rekordów-pułapek w bazach (np. w polu
|
||||
znaczącym: `Ve Tau ASTROLOLO-CANARY-7f3a9`).
|
||||
2. **Odsiewanie** — warstwa danych wykrywa rekord z markerem i usuwa go z wyników,
|
||||
zanim opuszczą usługę. Interpretacje i prompty są czyste (log `info`).
|
||||
3. **Tripwire** — jeśli zapytanie **celuje wprost** w marker (ktoś enumeruje bazę,
|
||||
a nie liczy horoskopu), leci `warning` — sygnał podejrzanego zachowania.
|
||||
|
||||
## Konfiguracja (per wdrożenie)
|
||||
|
||||
Zmienne środowiskowe usługi `data`:
|
||||
|
||||
| Zmienna | Znaczenie |
|
||||
|---|---|
|
||||
| `CANARY_MARKERS` | markery oddzielone przecinkami (kilka na wariant) |
|
||||
| `CANARY_VARIANT` | etykieta wariantu tego wdrożenia (np. `prod-2026`, `partnerX`) |
|
||||
|
||||
Bez `CANARY_MARKERS` mechanizm jest **przezroczysty** (zero kosztu). Markery są
|
||||
sekretem — trzymaj je jak `astrololo-auth` (poza repo GitOps), przez `secretKeyRef`.
|
||||
|
||||
## Rejestr wariant → kopia (traitor tracing)
|
||||
|
||||
Sedno atrybucji: **każda dystrybuowana kopia baz dostaje inny zestaw pułapek**,
|
||||
a Ty trzymasz mapę, który wariant trafił dokąd. Gdy zobaczysz pułapkę w cudzej
|
||||
kopii → sprawdzasz marker w rejestrze → wiesz, skąd wyciekła.
|
||||
|
||||
Rejestr trzymaj **poza kodem i repo** (arkusz/menedżer sekretów po stronie ops),
|
||||
np.:
|
||||
|
||||
| Wariant | Markery | Wdrożenie / odbiorca | Data |
|
||||
|---|---|---|---|
|
||||
| `prod-2026` | `…7f3a9`, `…b12c` | produkcja czernobog | 2026-08 |
|
||||
| `partnerX` | `…9de4`, `…0a1b` | kopia dla partnera X | 2026-08 |
|
||||
|
||||
## Wstrzyknięcie pułapek do baz (krok właściciela)
|
||||
|
||||
To robi właściciel na **realnych** plikach (kod tego nie robi — nie ruszamy
|
||||
kupionych baz automatycznie): dodać kilka rekordów-pułapek z markerem danego
|
||||
wariantu, w stylu nieodróżnialnym od prawdziwych wpisów. Kilka, wtopionych —
|
||||
łatwiej, gdy ktoś zna mechanizm, wyciąć jeden oczywisty niż wszystkie.
|
||||
|
||||
## Granice
|
||||
|
||||
Canary dowodzi pochodzenia tylko, gdy wyciek **zawiera** treść pułapki (pełna
|
||||
kopia — tak; parafraza — niekoniecznie). Nie wykrywa retencji *prawdziwej* treści
|
||||
u dostawcy LLM — od tego jest bramka LOG-32 i wniosek o Zero Data Retention.
|
||||
@@ -0,0 +1,195 @@
|
||||
# DAN-25 — zamknięcie dostępu do baz na NFS (TrueNAS SCALE)
|
||||
|
||||
Bazy interpretacyjne leżą na `192.168.1.34:/mnt/Tank1/astrololo`. Dziś udział jest
|
||||
osiągalny z całej sieci, więc **kto ma dostęp do LAN, bierze kompletne bazy
|
||||
w oryginale — z pominięciem logowania, limitów, audytu i canary**. Żadne
|
||||
zabezpieczenie w kodzie tego nie zamyka: to najkrótsza droga do wycieku.
|
||||
|
||||
Cel: udział `astrololo` widoczny **tylko dla trzech węzłów k3s**, **tylko do
|
||||
odczytu**, z **root_squash**.
|
||||
|
||||
## ⚠️ Zasada nadrzędna: ruszamy WYŁĄCZNIE udział astrololo
|
||||
|
||||
Tank1 obsługuje cały homelab — Proxmox (`proxmox-NFS`), conjurera (`conjurer_swap`,
|
||||
z **zapisem**), media, LXC-e, stację roboczą. **Nie dotykamy globalnych ustawień
|
||||
usługi NFS ani innych udziałów** — inaczej wywalimy VM-y, bota i bibliotekę mediów.
|
||||
Każda komenda niżej celuje w jeden konkretny udział.
|
||||
|
||||
Druga zasada: **w TrueNAS SCALE nie edytuje się `/etc/exports` ręcznie.** Plik
|
||||
generuje middleware i nadpisze każdą ręczną zmianę. Wszystko robimy przez `midclt`
|
||||
(albo GUI: *Shares → Unix (NFS) Shares*).
|
||||
|
||||
## Ustalone dane
|
||||
|
||||
| Co | Wartość |
|
||||
|---|---|
|
||||
| NAS | `192.168.1.34` (TrueNAS SCALE / Community Edition) |
|
||||
| Udział do zamknięcia | `/mnt/Tank1/astrololo` |
|
||||
| Węzły k3s (jedyni uprawnieni) | `192.168.1.73` (server), `192.168.1.80` (agent2), `192.168.1.81` (agent1) |
|
||||
| Kto montuje astrololo | wyłącznie pod `data` w ns `astrololo`, **read-only** |
|
||||
|
||||
---
|
||||
|
||||
## Faza 0 — rozpoznanie (nic nie zmienia)
|
||||
|
||||
```bash
|
||||
ssh admin@192.168.1.34
|
||||
```
|
||||
|
||||
Wersja systemu (potwierdza, że komendy niżej pasują):
|
||||
|
||||
```bash
|
||||
midclt call system.version
|
||||
```
|
||||
|
||||
Lista udziałów NFS z ich obecnymi ustawieniami — **stąd bierzemy ID udziału astrololo**:
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.query | python3 -m json.tool
|
||||
```
|
||||
|
||||
> W wyniku poszukaj wpisu ze ścieżką `/mnt/Tank1/astrololo` i zapamiętaj jego `id`.
|
||||
> **Sprawdź też, jak nazywają się pola** (`path` vs `paths`, `hosts`, `networks`,
|
||||
> `ro`, `maproot_user`, `mapall_user`) — middleware zmieniało ich nazwy między
|
||||
> wersjami SCALE. Dalsze komendy używają nazw z Twojego wyniku.
|
||||
|
||||
Kto jest teraz podłączony (żeby nie odciąć czegoś w trakcie pracy):
|
||||
|
||||
```bash
|
||||
ss -tn state established '( sport = :2049 )'
|
||||
```
|
||||
|
||||
## Faza 1 — dowód dziury (zrób PRZED zmianą)
|
||||
|
||||
Na **stacji roboczej** (Mac mini, czyli host spoza klastra):
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/nfs-test && sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
|
||||
```
|
||||
|
||||
```bash
|
||||
ls -la /tmp/nfs-test | head
|
||||
```
|
||||
|
||||
Jeśli widzisz pliki baz — **to jest dokładnie problem, który zamykamy**. Odmontuj:
|
||||
|
||||
```bash
|
||||
sudo umount /tmp/nfs-test
|
||||
```
|
||||
|
||||
## Faza 2 — kopia obecnej konfiguracji (możliwość cofnięcia)
|
||||
|
||||
Na NAS-ie, podstaw `<ID>` z Fazy 0:
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.query '[["id","=",<ID>]]' > /root/astrololo-nfs-share.backup.json && cat /root/astrololo-nfs-share.backup.json
|
||||
```
|
||||
|
||||
## Faza 3 — zawężenie udziału
|
||||
|
||||
Jedna komenda ustawia wszystkie trzy zabezpieczenia naraz: listę hostów, tylko
|
||||
odczyt i root_squash. Podstaw `<ID>`:
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.update <ID> '{"hosts": ["192.168.1.73", "192.168.1.80", "192.168.1.81"], "ro": true, "maproot_user": null, "maproot_group": null, "mapall_user": null, "mapall_group": null}'
|
||||
```
|
||||
|
||||
Co robi każdy element:
|
||||
|
||||
| Ustawienie | Znaczenie |
|
||||
|---|---|
|
||||
| `hosts` | eksport **tylko** dla trzech węzłów k3s — reszta LAN przestaje widzieć udział |
|
||||
| `ro: true` | tylko odczyt; aplikacja i tak montuje read-only, więc niczego nie łamie |
|
||||
| `maproot_*`, `mapall_*` = `null` | **root_squash**: root z klienta nie jest rootem na udziale |
|
||||
|
||||
Zastosuj i sprawdź, że middleware przepisał eksporty:
|
||||
|
||||
```bash
|
||||
midclt call service.reload nfs && exportfs -v | grep -A1 astrololo
|
||||
```
|
||||
|
||||
> Jeśli Twoja wersja nie ma `service.reload`, użyj GUI (*Shares → NFS → zapisz*),
|
||||
> co wymusi to samo.
|
||||
|
||||
## Faza 4 — weryfikacja (wszystkie cztery testy)
|
||||
|
||||
**1. Spoza klastra ma NIE działać.** Na Macu:
|
||||
|
||||
```bash
|
||||
sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
|
||||
```
|
||||
|
||||
Oczekiwane: `access denied` / `Operation not permitted`. **Jeśli montuje się dalej —
|
||||
zmiana nie zadziałała, nie idź dalej.**
|
||||
|
||||
**2. Z węzła klastra ma działać.**
|
||||
|
||||
```bash
|
||||
ssh 192.168.1.73 'sudo mount -t nfs -o ro 192.168.1.34:/mnt/Tank1/astrololo /mnt/test && ls /mnt/test | head -3 && sudo umount /mnt/test'
|
||||
```
|
||||
|
||||
**3. Aplikacja żyje.** Pody muszą wstać i realnie czytać bazy:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout restart deploy/data && kubectl -n astrololo rollout status deploy/data
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo exec deploy/data -- ls /app/data_files | head -3
|
||||
```
|
||||
|
||||
**4. Reszta homelabu nietknięta** — conjurer (zapis!) i pozostałe udziały:
|
||||
|
||||
```bash
|
||||
kubectl -n conjurer get pods
|
||||
```
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.query | python3 -c "import sys,json;[print(s.get('path') or s.get('paths'), '| hosts:', s.get('hosts'), '| ro:', s.get('ro')) for s in json.load(sys.stdin)]"
|
||||
```
|
||||
|
||||
Oczekiwane: **tylko** astrololo ma zawężone `hosts` i `ro: true`; reszta bez zmian.
|
||||
|
||||
## Faza 5 — wycofanie (gdyby coś padło)
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.update <ID> '{"hosts": [], "ro": false}'
|
||||
```
|
||||
|
||||
```bash
|
||||
midclt call service.reload nfs
|
||||
```
|
||||
|
||||
To przywraca poprzedni stan (pełna kopia w `/root/astrololo-nfs-share.backup.json`).
|
||||
|
||||
---
|
||||
|
||||
## Pułapki, o których warto wiedzieć
|
||||
|
||||
**Hookscript Proxmoxa.** Na Proxmoxie działa `wait-truenas.sh`, który przed startem
|
||||
VM czeka w pętli na `showmount -e 192.168.1.34`. Zawężamy tylko udział astrololo,
|
||||
więc `showmount` nadal zwróci pozostałe eksporty i pętla przejdzie. Mimo to sprawdź
|
||||
po zmianie:
|
||||
|
||||
```bash
|
||||
ssh root@pve2 'showmount -e 192.168.1.34'
|
||||
```
|
||||
|
||||
**Aktualizacja baz przestanie działać przez NFS.** Po `ro: true` nikt nie wgra
|
||||
nowych plików baz przez ten udział — również Ty. Do wgrywania użyj GUI TrueNAS,
|
||||
SMB albo SSH bezpośrednio na NAS-ie. To celowe: udział ma być drogą tylko do
|
||||
czytania przez aplikację.
|
||||
|
||||
**`hosts` przyjmuje adresy IP, nie nazwy** — świadomie, żeby dostęp nie zależał od
|
||||
DNS-u (AdGuard na `.57`). Gdyby doszedł czwarty węzeł k3s, trzeba dopisać jego IP,
|
||||
inaczej pod `data` na nim nie wstanie.
|
||||
|
||||
**To nie jest uwierzytelnianie.** Lista IP zatrzymuje przypadkowy i oportunistyczny
|
||||
dostęp, ale adres da się podszyć w tej samej sieci. Docelowo (poza zakresem tego
|
||||
kroku): NFSv4 + Kerberos albo przeniesienie plików na wolumen nieosiągalny poza
|
||||
klastrem — tak mówi samo wymaganie DAN-25.
|
||||
|
||||
## Po wykonaniu
|
||||
|
||||
Zaktualizuj status DAN-25 w `docs/astrololo_wymagania.xlsx` na **Zrobione** (albo
|
||||
**W trakcie**, jeśli zostawiasz Kerberosa jako etap docelowy).
|
||||
@@ -0,0 +1,65 @@
|
||||
# Konta imienne i dziennik audytowy (PRE-17)
|
||||
|
||||
Zamiast jednego wspólnego hasła: **konta imienne**, bo przy bazach o realnej
|
||||
wartości handlowej trzeba wiedzieć **kto** sięgał do treści — i móc odciąć jedną
|
||||
osobę bez zmiany hasła całej reszcie.
|
||||
|
||||
## Zakładanie konta
|
||||
|
||||
Hasło podajesz interaktywnie (nie trafia do historii powłoki ani do listy procesów);
|
||||
na wyjściu dostajesz **hash**, nie hasło:
|
||||
|
||||
```bash
|
||||
cd services/presentation && python scripts/make_user.py alicja
|
||||
```
|
||||
|
||||
Wynik wklejasz do `APP_USERS` (wpisy po przecinku):
|
||||
|
||||
```
|
||||
APP_USERS='alicja:scrypt$…,bartek:scrypt$…'
|
||||
```
|
||||
|
||||
Hash liczy `scrypt` ze stdlib — **bez nowych zależności**. Sekret ustawiasz jak
|
||||
resztę (`kubectl create secret …`, `secretKeyRef`), nigdy w repo GitOps.
|
||||
|
||||
## Odebranie dostępu jednej osobie
|
||||
|
||||
Usuń jej wpis z `APP_USERS` i zrestartuj `presentation`. **Pozostali nie zmieniają
|
||||
haseł** — to była główna bolączka wspólnego hasła.
|
||||
|
||||
## Uwaga: wspólne hasło przestaje działać
|
||||
|
||||
Gdy `APP_USERS` jest ustawione, stare `APP_PASSWORD` **nie działa** (aplikacja
|
||||
zgłasza to ostrzeżeniem przy starcie). Celowo: działające obok kont wspólne hasło
|
||||
byłoby tylnym wejściem bez śladu w dzienniku, czyli dokładnie problemem, który to
|
||||
wymaganie zamyka. Po migracji usuń `APP_PASSWORD` z konfiguracji.
|
||||
|
||||
Zgodność wstecz: dopóki `APP_USERS` **nie** jest ustawione, `APP_USER`/`APP_PASSWORD`
|
||||
działa jak dotąd — aktualizacja nie wywraca istniejącego wdrożenia.
|
||||
|
||||
## Dziennik audytowy
|
||||
|
||||
Każde żądanie do chronionej ścieżki zostawia wpis na stdout (w k8s zbierany
|
||||
standardowo):
|
||||
|
||||
```
|
||||
2026-08-03 21:45:44 INFO AUDYT user=alicja ip=10.1.2.3 method=POST path=/interpret status=200 records=428 ms=1530
|
||||
```
|
||||
|
||||
| Pole | Znaczenie |
|
||||
|---|---|
|
||||
| `user` | kto (`-` przy nieudanym logowaniu — nie podpowiadamy, które konto istnieje) |
|
||||
| `path`, `method`, `status` | co robił i z jakim skutkiem |
|
||||
| `records` | **ile rekordów baz** oddaliśmy (`-` gdy żądanie nie dotyka baz) |
|
||||
| `ms` | czas obsługi |
|
||||
|
||||
`records` jest tu najważniejsze: pojedyncze zapytanie wygląda niewinnie, ale suma
|
||||
pokazuje **powolne wypompowywanie bazy** przez osobę uprawnioną — czego żadne
|
||||
uwierzytelnienie nie wykryje. Liczone m.in. dla wyszukiwarki sygnifikatorów,
|
||||
raportu interpretacji i **eksportu do Excela** (ten wynosi najwięcej naraz).
|
||||
|
||||
**W dzienniku nie ma treści** — ani rekordów, ani promptów, ani danych
|
||||
urodzeniowych. Logi byłyby kolejnym nośnikiem wycieku; do wykrycia nadużycia
|
||||
wystarczą metadane i liczby.
|
||||
|
||||
Poziom sterujesz przez `AUDIT_LEVEL` (domyślnie `INFO`).
|
||||
@@ -0,0 +1,82 @@
|
||||
# Konta i uprawnienia (PRE-27)
|
||||
|
||||
Rozszerzenie kont imiennych z [PRE-17](konta-i-audyt.md): konta zakłada się
|
||||
**z aplikacji**, a każde dostaje własny zestaw funkcji.
|
||||
|
||||
## Dwie zasady, z których wynika reszta
|
||||
|
||||
**1. Konto ograniczone widzi program KOMPLETNY — tylko mniejszy.**
|
||||
Nic nie może zdradzać, że istnieje coś więcej. Żadnych wyszarzonych zakładek,
|
||||
żadnego „brak uprawnień", żadnego 403 — bo **403 samo w sobie jest informacją**,
|
||||
że pod tym adresem coś jest. Ścieżka bez uprawnienia odpowiada **404**, tak samo
|
||||
jak adres, którego nie ma.
|
||||
|
||||
Z tej zasady wynikło też wyłączenie `/docs`, `/redoc` i `/openapi.json`.
|
||||
Automatyczna dokumentacja FastAPI wypisuje komplet tras — czyli spis wszystkich
|
||||
funkcji programu. Ochrona zakładek nic by nie dała, gdyby obok leżał ich katalog.
|
||||
(Znalezione testem, nie przeglądem kodu.)
|
||||
|
||||
**2. Konto administracyjne pochodzi WYŁĄCZNIE ze środowiska.**
|
||||
`APP_USER` / `APP_PASSWORD` (albo `APP_USERS`) — jak dotąd. To konto ma wszystkie
|
||||
uprawnienia i jako jedyne zarządza pozostałymi. **Nie leży w pliku kont**, więc
|
||||
nie da się go skasować ani ograniczyć z ekranu — nawet przez pomyłkę, nawet
|
||||
spreparowanym żądaniem. Konto założone w pliku o tym samym loginie **nie
|
||||
przesłoni** administracyjnego (kolejność sprawdzania jest odwrotna).
|
||||
|
||||
## Podział funkcji
|
||||
|
||||
**Ekrany** — zakładki widoczne w nawigacji:
|
||||
|
||||
| klucz | zakładka |
|
||||
|---|---|
|
||||
| `chart` | Horoskop |
|
||||
| `interpret` | Interpretacje |
|
||||
| `timeline` | Kalendarz |
|
||||
| `synastry` | Synastria |
|
||||
| `significators` | Sygnifikatory |
|
||||
| `compile` | Skompiluj |
|
||||
| `settings` | Ustawienia |
|
||||
|
||||
**Rozszerzenia** — poziomy złożoności wewnątrz ekranów:
|
||||
|
||||
| klucz | co daje |
|
||||
|---|---|
|
||||
| `houses_compare` | wybór systemu domów, zestawienie kilku obok siebie, obrót koła |
|
||||
| `extra_charts` | aspektarian, wykres deklinacji, oś antyscji |
|
||||
| `advanced_calc` | stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne |
|
||||
| `ai` | horoskopy pisane przez model językowy (**każde użycie kosztuje**) |
|
||||
| `export` | pobieranie PDF i Excela |
|
||||
|
||||
Konto bez `houses_compare` dostaje horoskop w Whole Sign i **nie widzi**, że
|
||||
systemów jest trzynaście. Konto bez `ai` nie zobaczy przycisku generowania ani
|
||||
nie wywoła go z pominięciem interfejsu.
|
||||
|
||||
## Gdzie leży granica
|
||||
|
||||
W handlerze, nie w szablonie. Ukrycie pola w formularzu chroni przed przypadkiem,
|
||||
ale nie przed kimś, kto zna nazwy pól — dlatego `_limit_options()` ścina opcje
|
||||
**po stronie serwera**, a rysunki dodatkowe bez uprawnienia w ogóle nie powstają
|
||||
(nie ma ich nawet w źródle strony).
|
||||
|
||||
Mapa `trasa → uprawnienie` jest **jedna**, w `app/features.py`. Rozproszenie jej
|
||||
po dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura
|
||||
jest niewidoczna do chwili, gdy ktoś ją znajdzie. Trasa bez wpisu w mapie wymaga
|
||||
uprawnień administracyjnych: **przeoczenie ma zamykać, nie otwierać**. Test
|
||||
przechodzi po wszystkich trasach aplikacji i wymaga, by każda była opisana.
|
||||
|
||||
## Gdzie leżą konta
|
||||
|
||||
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
|
||||
na NFS — **własny podkatalog prezentacji**, nie katalog z bazami: zamontowanie
|
||||
tutaj całego udziału obeszłoby bokiem zamknięcie dostępu z DAN-25.
|
||||
|
||||
Hasła wyłącznie jako hash scrypt, tym samym mechanizmem co `APP_USERS` — jedna
|
||||
implementacja, więc nie ma czego rozjechać. Zapis jest **atomowy** (plik
|
||||
tymczasowy + `os.replace` w tym samym katalogu): przerwanie zapisu nie obetnie
|
||||
pliku, czyli nie skasuje wszystkich kont naraz.
|
||||
|
||||
## Czego ten mechanizm NIE robi
|
||||
|
||||
Nie zastępuje ochrony baz na poziomie sieci ani NFS (DAN-25). Ktoś z dostępem do
|
||||
udziału albo do warstwy danych nadal je odczyta — uprawnienia w aplikacji
|
||||
ograniczają to, co widać **przez aplikację**, i tyle.
|
||||
@@ -0,0 +1,171 @@
|
||||
# LOG-33 — sekrety w spoczynku i procedura rotacji
|
||||
|
||||
Sekrety (`APP_PASSWORD`/`APP_USERS`, `INTERNAL_TOKEN`, klucze łącz AES, klucze API
|
||||
do dostawców LLM) trafiają do obiektów Secret w Kubernetesie, gdzie domyślnie są
|
||||
**tylko zakodowane base64** — jawne dla każdego, kto przeczyta magazyn stanu k3s
|
||||
albo ma prawo odczytu sekretów w namespace.
|
||||
|
||||
Ten dokument opisuje: **macierz rotacji** (co restartować przy zmianie czego),
|
||||
**procedury rotacji per sekret** i **kroki hartowania**, które wymagają dostępu do
|
||||
węzła.
|
||||
|
||||
---
|
||||
|
||||
## Macierz zależności — kto używa którego sekretu
|
||||
|
||||
Wyliczona z żywych deploymentów, nie z założeń:
|
||||
|
||||
| Sekret / klucz | Usługi, które go czytają | Restart obejmuje |
|
||||
|---|---|---|
|
||||
| `astrololo-auth` / **`INTERNAL_TOKEN`** | data, logic, presentation, **render** | **wszystkie cztery, równocześnie** |
|
||||
| `astrololo-auth` / `APP_PASSWORD`, `APP_USERS` | presentation | tylko presentation |
|
||||
| `astrololo-link` / `LINK_KEY_LOGIC_DATA` | logic, data | **para**: logic + data |
|
||||
| `astrololo-link` / `LINK_KEY_PRESENTATION_LOGIC` | presentation, logic | **para**: presentation + logic |
|
||||
| `astrololo-link` / `LINK_KEY_PRESENTATION_RENDER` | presentation, render | **para**: presentation + render |
|
||||
| `astrololo-llm` / `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | logic | tylko logic |
|
||||
|
||||
> **UWAGA — częsty błąd.** Wcześniejsza wersja tego wymagania mówiła o „wszystkich
|
||||
> **trzech** usługach" przy `INTERNAL_TOKEN`. To już nieprawda: `render` (PRE-24)
|
||||
> również go używa. Restart trzech zostawi render ze starym tokenem i **usługa po
|
||||
> cichu przestanie się dogadywać** — dokładnie ta awaria, przed którą wymaganie
|
||||
> ostrzega.
|
||||
|
||||
Klucze łącz są **parami** — rotacja jednego wymaga restartu tylko dwóch usług, nie
|
||||
całej czwórki. W trakcie wymiany para chwilowo się nie dogaduje (klucze muszą być
|
||||
zgodne po obu stronach łącza), dlatego restart obu naraz.
|
||||
|
||||
---
|
||||
|
||||
## Procedury rotacji
|
||||
|
||||
### A. Klucze LLM (najbezpieczniejsze do przećwiczenia)
|
||||
|
||||
Dotykają wyłącznie logiki, a awaria jest widoczna od razu i nieszkodliwa
|
||||
(niedostępna chmura, model lokalny działa dalej). **Zacznij ćwiczenie od tego.**
|
||||
|
||||
```bash
|
||||
read -rs -p "Nowy ANTHROPIC_API_KEY: " NEW; echo
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-llm \
|
||||
--from-literal=OPENAI_API_KEY="$(kubectl -n astrololo get secret astrololo-llm -o jsonpath='{.data.OPENAI_API_KEY}' | base64 -d)" \
|
||||
--from-literal=ANTHROPIC_API_KEY="$NEW" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
```bash
|
||||
unset NEW && kubectl -n astrololo rollout restart deploy/logic && kubectl -n astrololo rollout status deploy/logic
|
||||
```
|
||||
|
||||
### B. Klucz łącza (para usług)
|
||||
|
||||
Przykład dla `LINK_KEY_PRESENTATION_RENDER`. Pozostałe klucze zachowujemy bez zmian,
|
||||
odczytując je z istniejącego sekretu — inaczej skasowalibyśmy pozostałe łącza.
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-link \
|
||||
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
|
||||
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
|
||||
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout restart deploy/presentation deploy/render
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
|
||||
```
|
||||
|
||||
### C. `INTERNAL_TOKEN` (wszystkie cztery naraz)
|
||||
|
||||
Najbardziej wrażliwa rotacja: w trakcie usługi z różnymi tokenami **odrzucają się
|
||||
nawzajem**, więc restart musi objąć całą czwórkę.
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-auth \
|
||||
--from-literal=APP_PASSWORD="$(kubectl -n astrololo get secret astrololo-auth -o jsonpath='{.data.APP_PASSWORD}' | base64 -d)" \
|
||||
--from-literal=INTERNAL_TOKEN="$(openssl rand -hex 32)" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout restart deploy/data deploy/logic deploy/presentation deploy/render
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout status deploy/data && kubectl -n astrololo rollout status deploy/logic && kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
|
||||
```
|
||||
|
||||
> Jeśli używasz kont imiennych (PRE-17), zamiast `APP_PASSWORD` zachowaj `APP_USERS`
|
||||
> — patrz [`konta-i-audyt.md`](konta-i-audyt.md).
|
||||
|
||||
### Weryfikacja po KAŻDEJ rotacji
|
||||
|
||||
Sam `Running` nie wystarczy — pody wstaną nawet, gdy warstwy się nie dogadują.
|
||||
Trzeba sprawdzić **realny przelot przez wszystkie łącza**:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo get pods
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "aplikacja: %{http_code}\n" -u "<login>:<hasło>" https://astrololo.czernobog.pl/
|
||||
```
|
||||
|
||||
Policz horoskop w przeglądarce (dotyka presentation→logic→data) i wygeneruj PDF
|
||||
(dotyka presentation→render). Dopiero to potwierdza, że wszystkie cztery klucze
|
||||
i token są spójne.
|
||||
|
||||
---
|
||||
|
||||
## Hartowanie — kroki wymagające dostępu do węzła
|
||||
|
||||
### 1. Szyfrowanie sekretów w spoczynku (najważniejsze)
|
||||
|
||||
k3s z jednym serwerem trzyma stan w **SQLite**, nie w etcd, więc „szyfrowanie etcd"
|
||||
sprowadza się do wbudowanej funkcji k3s. Kroki są w sekcji „Co musisz zrobić sam"
|
||||
poniżej. Efekt: kopia pliku stanu albo snapshot VM przestaje być wyciekiem haseł
|
||||
i kluczy API.
|
||||
|
||||
**Granica:** klucz szyfrujący leży na tym samym serwerze. Chroni przed kradzieżą
|
||||
pliku/snapshotu — nie przed kimś, kto ma roota na węźle.
|
||||
|
||||
### 2. Ograniczenie tokenów kont serwisowych ✅
|
||||
|
||||
Zrobione: `automountServiceAccountToken: false` we wszystkich czterech usługach
|
||||
(deploy #13). Żadna nie rozmawia z API Kubernetesa — sekrety wstrzykuje kubelet,
|
||||
nie pod — więc token był zbędny, a stanowił gotowy punkt wyjścia do klastra.
|
||||
|
||||
### 3. RBAC
|
||||
|
||||
Stan sprawdzony: **zero RoleBindings** w `astrololo`, `cluster-admin` tylko dla
|
||||
`system:masters` i dwóch kont Helma w `kube-system`. Nie ma rozdanych nadmiarowych
|
||||
uprawnień do cofania.
|
||||
|
||||
Realna ekspozycja to **kubeconfig admina**. Sensowny krok: osobny, ograniczony
|
||||
kubeconfig do codziennej pracy, a admin tylko wtedy, gdy naprawdę potrzebny.
|
||||
|
||||
### 4. Sealed Secrets / SOPS — świadomie ODŁOŻONE
|
||||
|
||||
Dziś sekrety tworzone są ręcznie i **nie ma ich w repo GitOps** — czyli zasada
|
||||
„nie wpisywać sekretów do repozytorium" **jest już spełniona**. Kosztem jest
|
||||
odtwarzalność: po utracie klastra nikt nie wie, co tam było.
|
||||
|
||||
Sealed Secrets pozwoliłoby trzymać je w gicie w postaci zaszyfrowanej, ale to
|
||||
zmiana filozofii i **nowy pojedynczy punkt awarii**: utrata klucza kontrolera =
|
||||
utrata wszystkich sekretów. Rekomendacja: dopiero po punkcie 1, i tylko jeśli
|
||||
zależy Ci na odtwarzalności klastra z gita.
|
||||
|
||||
---
|
||||
|
||||
## Czego NIE robimy
|
||||
|
||||
- **Nie wpisujemy sekretów do logów.** Dziennik audytowy (PRE-17) niesie wyłącznie
|
||||
metadane i liczby.
|
||||
- **Nie wpisujemy sekretów do repo GitOps.** Manifesty odwołują się do sekretów
|
||||
przez `secretKeyRef` i celowo nie zawierają wartości.
|
||||
- **Nie zostawiamy haseł w historii powłoki** — stąd `read -rs` i odczyt istniejących
|
||||
wartości przez `kubectl … | base64 -d` zamiast wpisywania ich ponownie.
|
||||
@@ -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,139 @@
|
||||
# Wdrożenie usługi `render` — raport PDF (PRE-24)
|
||||
|
||||
Instrukcja krok po kroku dla nowego komponentu. Dotyczy dwóch repozytoriów:
|
||||
|
||||
| Repo | Co wnosi |
|
||||
|---|---|
|
||||
| `gitea/astrololo` | usługa `services/render`, klient w prezentacji, przycisk „Pobierz PDF", wariant kosmogramu do druku |
|
||||
| `gitea/deploy` | `astrololo/render.yaml`, adres i klucz łącza w `presentation.yaml` |
|
||||
|
||||
---
|
||||
|
||||
## Dlaczego osobna usługa
|
||||
|
||||
TeX Live waży setki megabajtów. W obrazie prezentacji spowalniałby każdy build
|
||||
i deploy, a przy każdej poprawce w CSS trzeba by go ciągnąć od nowa. Osobno:
|
||||
obraz produktu zostaje mały, TeX aktualizuje się niezależnie, a **awaria renderu
|
||||
nie kładzie aplikacji** — przestaje działać wyłącznie przycisk „Pobierz PDF".
|
||||
|
||||
To ta sama zasada, co przy izolacji silnika swisseph (LOG-27).
|
||||
|
||||
---
|
||||
|
||||
## Krok 1 — klucz łącza (PRZED wdrożeniem)
|
||||
|
||||
Usługa dostaje **cały raport**: dane urodzeniowe i opisy z baz. Łącze jest
|
||||
szyfrowane AES-256-GCM, własnym, **trzecim** kluczem — przejęcie go nie może
|
||||
otwierać łącza do logiki ani do danych.
|
||||
|
||||
Sekret `astrololo-link` już istnieje (PRE-16); dokładamy do niego trzeci klucz,
|
||||
**zachowując dwa dotychczasowe**:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-link \
|
||||
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link \
|
||||
-o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
|
||||
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link \
|
||||
-o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
|
||||
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
Sprawdzenie — mają być **trzy** klucze:
|
||||
```bash
|
||||
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
|
||||
```
|
||||
|
||||
> **Uwaga:** `LINK_ENCRYPTION_REQUIRED=true` działa fail-closed. Bez klucza pod
|
||||
> `render` **nie wstanie** — i tak ma być. Usługa, która wstała i po cichu nie
|
||||
> szyfruje, jest gorsza niż CrashLoop, bo awarii nie widać.
|
||||
|
||||
---
|
||||
|
||||
## Krok 2 — obraz usługi
|
||||
|
||||
Obraz budowany jest osobnym workflow (jak silnik swisseph — nie wchodzi do
|
||||
głównego pipeline'u produktu). Pierwszy build trwa dłużej, bo ciągnie TeX Live.
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo get deploy render -o jsonpath='{.spec.template.spec.containers[0].image}'; echo
|
||||
```
|
||||
|
||||
Pakiety w obrazie dobrane **wąsko** (nie `texlive-full`, który ma kilka GB):
|
||||
|
||||
| Pakiet | Po co |
|
||||
|---|---|
|
||||
| `texlive-xetex` | silnik **XeLaTeX** — konieczny, bo raport ma polskie znaki i glify astrologiczne; `pdflatex` ich nie złoży |
|
||||
| `texlive-latex-recommended` | `geometry`, `graphicx` |
|
||||
| `fonts-dejavu-core` | jeden font na polskie znaki **i** symbole (♄ ♓ ☉) |
|
||||
| `librsvg2-bin` | `rsvg-convert` — SVG kosmogramu → PDF (LaTeX nie wstawia SVG wprost) |
|
||||
|
||||
Dockerfile sprawdza obecność obu narzędzi **przy budowie**, więc zepsuty obraz
|
||||
nie dojedzie na produkcję niezauważony.
|
||||
|
||||
---
|
||||
|
||||
## Krok 3 — merge manifestów
|
||||
|
||||
```bash
|
||||
tea pr merge --login gitea --repo gitea/deploy <numer>
|
||||
kubectl -n astrololo rollout status deploy/render deploy/presentation
|
||||
```
|
||||
|
||||
Wjeżdża naraz: `Deployment` + `Service` renderu (ClusterIP — **bez** Ingressu
|
||||
i NodePortu, nie ma powodu sięgać do niej z zewnątrz) oraz `RENDER_URL`
|
||||
i trzeci klucz w prezentacji.
|
||||
|
||||
---
|
||||
|
||||
## Krok 4 — sprawdzenie
|
||||
|
||||
### Narzędzia są na miejscu
|
||||
`/health` raportuje obecność `xelatex` i `rsvg-convert`, żeby zepsuty obraz
|
||||
było widać od razu, a nie dopiero przy pierwszym raporcie:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo exec deploy/presentation -- \
|
||||
python -c "import httpx; print(httpx.get('http://render:8004/health').text)"
|
||||
```
|
||||
Oczekiwane: `{"status":"ok","layer":"render","tools":{"xelatex":true,"rsvg-convert":true}}`.
|
||||
Gdy `status` to `degraded` — obraz zbudował się bez któregoś narzędzia.
|
||||
|
||||
### Łącze faktycznie szyfruje
|
||||
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **z poprawnym tokenem,
|
||||
ale bez szyfrowania**:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo exec deploy/presentation -- python -c "
|
||||
import httpx, os
|
||||
r = httpx.post('http://render:8004/pdf', json={'person':'test'},
|
||||
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
|
||||
print(r.status_code, r.text[:80])
|
||||
"
|
||||
```
|
||||
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Sam token już
|
||||
nie wystarcza — o to chodziło.
|
||||
|
||||
### Raport od końca do końca
|
||||
W przeglądarce: **Skompiluj** → wypełnij dane → *Złóż podsumowanie* → *Pobierz PDF*.
|
||||
PDF ma zacząć się od imienia i nazwiska, potem wprowadzone dane, **rysunek
|
||||
kosmogramu**, a po nim interpretacja natalna i predykcje okresowe.
|
||||
|
||||
---
|
||||
|
||||
## Czego ta wersja nie załatwia
|
||||
|
||||
- **Kompilacja PDF nie została sprawdzona end-to-end** w środowisku, w którym
|
||||
powstawała — nie było tam ani TeX Live, ani runtime'u kontenerów. Sprawdzone
|
||||
jest wszystko dookoła: generowanie źródła `.tex` (w tym ucieczka znaków
|
||||
specjalnych), szyfrowanie łącza, kontrakt API i samowystarczalność SVG.
|
||||
**Pierwsze uruchomienie na klastrze trzeba obejrzeć.**
|
||||
- **Dzielenie wyrazów** jest angielskie — nie wciągamy `polyglossia`, żeby nie
|
||||
puchł obraz. Tekst składa się poprawnie, tylko przenoszenie bywa nieoptymalne.
|
||||
- **Brak kolejki**. Długi raport blokuje jedno połączenie na czas kompilacji
|
||||
(timeout klienta: 180 s). Przy większym ruchu warto dołożyć zadania w tle.
|
||||
- **Glify w tekście od modelu**. Kosmogram idzie jako obraz (rysuje go
|
||||
`rsvg-convert` z DejaVu), ale gdyby model wplótł symbole w prozę, złoży je
|
||||
XeLaTeX — też z DejaVu. Jeśli któregoś zabraknie, LaTeX zgłasza
|
||||
„Missing character" w logu i **nie drukuje znaku**; warto zerknąć w log
|
||||
pierwszego raportu.
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Przegląd baz na udziale i ich globalne włączanie/wyłączanie (DAN-15 / PRE-09).
|
||||
|
||||
Wymaganie przedefiniowane pod model serwerowy: pliki baz leżą na stałym NFS, więc
|
||||
nie wybiera się folderu — potrzeba za to WIDZIEĆ, jakie bazy są dostępne (nazwa +
|
||||
metaopis) i móc globalnie zdecydować, które biorą udział w interpretacji.
|
||||
|
||||
Stan przełączników trzymamy DEKLARATYWNIE w zmiennej `DISABLED_BASES`, a nie w
|
||||
pliku, bo warstwa danych nie ma gdzie trwale zapisywać: udział z bazami jest
|
||||
montowany read-only, a katalog cache to `emptyDir` (ginie przy restarcie poda).
|
||||
Zapis do pliku po cichu wracałby więc do stanu sprzed restartu — a ciche
|
||||
przywrócenie wyłączonej bazy jest gorsze niż konieczność edycji konfiguracji.
|
||||
|
||||
Dopasowanie jest tolerancyjne: wpis pasuje po nazwie pliku ALBO po ścieżce
|
||||
względnej — żeby dało się wyłączyć zarówno „stara_baza.xlsx", jak i
|
||||
„archiwum/stara_baza.xlsx".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def disabled_entries() -> list[str]:
|
||||
return [e.strip() for e in os.getenv("DISABLED_BASES", "").split(",") if e.strip()]
|
||||
|
||||
|
||||
def _relative(path: Path, root: Path) -> str:
|
||||
try:
|
||||
return str(path.relative_to(root))
|
||||
except ValueError:
|
||||
return path.name
|
||||
|
||||
|
||||
def is_enabled(path: str | Path, root: str | Path, entries: list[str] | None = None) -> bool:
|
||||
"""Czy baza bierze udział w wyszukiwaniu interpretacji."""
|
||||
entries = disabled_entries() if entries is None else entries
|
||||
if not entries:
|
||||
return True
|
||||
p = Path(path)
|
||||
rel = _relative(p, Path(root))
|
||||
return not any(e == p.name or e == rel for e in entries)
|
||||
|
||||
|
||||
def list_bases(root: str | Path, paths: list[str]) -> list[dict]:
|
||||
"""Bazy dostępne na udziale + metaopis. Celowo TANI opis (dane z systemu
|
||||
plików): przy setkach plików liczenie rekordów oznaczałoby wczytanie każdego."""
|
||||
root = Path(root)
|
||||
entries = disabled_entries()
|
||||
out: list[dict] = []
|
||||
for raw in paths:
|
||||
p = Path(raw)
|
||||
try:
|
||||
st = p.stat()
|
||||
size_mb, modified = round(st.st_size / (1024 * 1024), 2), st.st_mtime
|
||||
except OSError:
|
||||
size_mb, modified = None, None
|
||||
out.append({
|
||||
"name": p.name,
|
||||
"path": _relative(p, root),
|
||||
"size_mb": size_mb,
|
||||
"modified": (datetime.fromtimestamp(modified, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
if modified else None),
|
||||
"enabled": is_enabled(p, root, entries),
|
||||
})
|
||||
return out
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Rekordy-pułapki (canary) — wykrywanie wycieku baz (DAN-26).
|
||||
|
||||
Zabezpieczenie DETEKCYJNE, nie prewencyjne. Kilka unikalnych, wiarygodnie
|
||||
wyglądających rekordów wplecionych w bazy: nie zmieniają interpretacji (są
|
||||
ODSIEWANE z wyników, więc nie trafiają ani do użytkownika, ani do promptu LLM —
|
||||
wymóg LOG-30), ale jeśli kiedyś pojawią się w cudzej kopii, są dowodem pochodzenia.
|
||||
|
||||
Pułapkę rozpoznajemy po MARKERZE: unikalny ciąg, który nie występuje w realnych
|
||||
danych (wpleciony np. w pole znaczące). Markery i WARIANT tego wdrożenia biorą się
|
||||
z konfiguracji (ENV `CANARY_MARKERS`, `CANARY_VARIANT`) — każda kopia może dostać
|
||||
swój zestaw. Który wariant trafił do którego wdrożenia trzyma rejestr po stronie
|
||||
ops (osobno, poza kodem — patrz docs/canary-registry.md).
|
||||
|
||||
Dwa sygnały:
|
||||
* ODSIEWANIE — pułapka w wynikach znika, zanim opuści warstwę danych (log info);
|
||||
* TRIPWIRE — zapytanie CELUJE wprost w marker (ktoś enumeruje bazę, a nie liczy
|
||||
realny horoskop) → log warning, bo to podejrzane zachowanie.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
log = logging.getLogger("astrololo.data.canary")
|
||||
|
||||
|
||||
def _load_markers() -> list[str]:
|
||||
return [m.strip() for m in os.getenv("CANARY_MARKERS", "").split(",") if m.strip()]
|
||||
|
||||
|
||||
MARKERS = _load_markers()
|
||||
VARIANT = os.getenv("CANARY_VARIANT", "")
|
||||
|
||||
|
||||
def _row_is_canary(row: dict, markers: list[str]) -> bool:
|
||||
"""Czy KTÓRAKOLWIEK tekstowa wartość wiersza zawiera marker pułapki."""
|
||||
for v in row.values():
|
||||
if isinstance(v, str):
|
||||
for m in markers:
|
||||
if m in v:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def screen(
|
||||
rows: list[dict], query_value: str = "",
|
||||
markers: list[str] | None = None, variant: str | None = None,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""Zwraca (widoczne_wiersze, raport).
|
||||
|
||||
Odsiewa pułapki z wyników (nie opuszczą warstwy danych). Gdy zapytanie celuje
|
||||
wprost w marker — podnosi TRIPWIRE (możliwa enumeracja bazy). Bez skonfigurowanych
|
||||
markerów: przezroczyste (`active=False`), zero kosztu dla normalnego ruchu.
|
||||
"""
|
||||
markers = MARKERS if markers is None else markers
|
||||
variant = VARIANT if variant is None else variant
|
||||
if not markers:
|
||||
return rows, {"active": False, "removed": 0, "tripwire": False}
|
||||
|
||||
visible = [r for r in rows if not _row_is_canary(r, markers)]
|
||||
removed = len(rows) - len(visible)
|
||||
tripwire = any(m in (query_value or "") for m in markers)
|
||||
|
||||
if tripwire:
|
||||
log.warning(
|
||||
"CANARY TRIPWIRE: zapytanie celuje wprost w rekord-pułapkę (wariant %s) "
|
||||
"— możliwa enumeracja bazy", variant or "?")
|
||||
elif removed:
|
||||
log.info("Odsiano %d rekord(ów)-pułapek z wyników (wariant %s)", removed, variant or "?")
|
||||
return visible, {"active": True, "removed": removed, "tripwire": tripwire}
|
||||
@@ -0,0 +1,525 @@
|
||||
"""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"
|
||||
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
||||
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
||||
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
||||
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
||||
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")
|
||||
@@ -4,12 +4,14 @@ Jedyne zadanie: przyjąć znormalizowane zapytanie z warstwy logicznej, wyszuka
|
||||
dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
|
||||
ani prezentacji.
|
||||
"""
|
||||
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app import canary, link_crypto, security
|
||||
from app.config import settings
|
||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||
from app.providers.factory import build_provider
|
||||
@@ -24,11 +26,32 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
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)
|
||||
def search(query: SearchQuery) -> SearchResult:
|
||||
return provider.search(query)
|
||||
result = provider.search(query)
|
||||
# Rekordy-pułapki (DAN-26) odsiewamy TU, na wyjściu z warstwy danych — dzięki
|
||||
# temu nie dotrą ani wyżej, ani do promptu LLM (LOG-30), niezależnie od dostawcy.
|
||||
visible, report = canary.screen(result.rows, query.value)
|
||||
if report["active"]:
|
||||
result.rows = visible
|
||||
result.total = len(visible)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/bases")
|
||||
def bases() -> dict:
|
||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||
|
||||
Same METADANE — nazwy plików, rozmiar, data. Żadnej treści baz, więc podgląd
|
||||
listy nie jest kolejną drogą do ich wyniesienia."""
|
||||
items = provider.list_bases()
|
||||
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthInfo)
|
||||
|
||||
@@ -18,7 +18,10 @@ class SearchQuery(BaseModel):
|
||||
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
||||
value: str = Field(..., description="Szukana wartość.")
|
||||
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
||||
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(
|
||||
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
||||
)
|
||||
|
||||
@@ -25,3 +25,10 @@ class DataProvider(ABC):
|
||||
def warmup(self) -> None:
|
||||
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
|
||||
return None
|
||||
|
||||
def list_bases(self) -> list[dict]:
|
||||
"""Bazy widoczne dla dostawcy + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||
|
||||
Opcjonalne: dostawca SQL nie operuje na plikach, więc domyślnie pusto —
|
||||
UI pokaże wtedy, że nie ma czego przełączać, zamiast się wywrócić."""
|
||||
return []
|
||||
|
||||
@@ -91,10 +91,31 @@ class ExcelDataProvider(DataProvider):
|
||||
base = Path(self.s.excel_dir)
|
||||
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
|
||||
|
||||
def _enabled_files(self, paths: list[str]) -> list[str]:
|
||||
"""Odsiewa bazy WYŁĄCZONE globalnie (DAN-15) — nie biorą udziału
|
||||
w interpretacji, choć fizycznie leżą na udziale."""
|
||||
from app import bases
|
||||
|
||||
entries = bases.disabled_entries()
|
||||
if not entries:
|
||||
return paths
|
||||
return [p for p in paths if bases.is_enabled(p, self.s.excel_dir, entries)]
|
||||
|
||||
def list_bases(self) -> list[dict]:
|
||||
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
|
||||
from app import bases
|
||||
|
||||
return bases.list_bases(self.s.excel_dir, self._excel_files())
|
||||
|
||||
# ---- publiczne API ----
|
||||
def search(self, query: SearchQuery) -> SearchResult:
|
||||
t0 = time.perf_counter()
|
||||
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
|
||||
# Lista wyłączonych baz wchodzi do klucza cache: bez tego zmiana ustawień
|
||||
# oddawałaby wynik sprzed zmiany, czyli treść bazy uznanej za wyłączoną.
|
||||
from app import bases
|
||||
|
||||
disabled = ",".join(bases.disabled_entries())
|
||||
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}|{disabled}"
|
||||
|
||||
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
|
||||
if hit is not None:
|
||||
@@ -105,6 +126,9 @@ class ExcelDataProvider(DataProvider):
|
||||
if not candidates:
|
||||
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
|
||||
candidates = [(p, "0") for p in self._excel_files()]
|
||||
# bazy wyłączone globalnie (DAN-15) pomijamy niezależnie od źródła kandydatów
|
||||
allowed = set(self._enabled_files([p for p, _ in candidates]))
|
||||
candidates = [(p, s) for p, s in candidates if p in allowed]
|
||||
|
||||
rows: list[dict] = []
|
||||
for path, _sheet in candidates:
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
@@ -7,3 +7,5 @@ openpyxl>=3.1
|
||||
pyarrow>=18.0
|
||||
SQLAlchemy>=2.0
|
||||
pydantic>=2.10
|
||||
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||
cryptography>=44.0
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Przegląd baz na udziale i ich globalne wyłączanie (DAN-15 / PRE-09)."""
|
||||
import pytest
|
||||
|
||||
from app import bases
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
monkeypatch.delenv("DISABLED_BASES", raising=False)
|
||||
|
||||
|
||||
ROOT = "/dane"
|
||||
PATHS = [f"{ROOT}/main_base.xlsx", f"{ROOT}/archiwum/stara.xlsx"]
|
||||
|
||||
|
||||
def test_everything_enabled_by_default():
|
||||
for b in bases.list_bases(ROOT, PATHS):
|
||||
assert b["enabled"] is True
|
||||
|
||||
|
||||
def test_disable_by_file_name(monkeypatch):
|
||||
monkeypatch.setenv("DISABLED_BASES", "stara.xlsx")
|
||||
state = {b["name"]: b["enabled"] for b in bases.list_bases(ROOT, PATHS)}
|
||||
assert state == {"main_base.xlsx": True, "stara.xlsx": False}
|
||||
|
||||
|
||||
def test_disable_by_relative_path(monkeypatch):
|
||||
"""Wpis może wskazywać ścieżkę względną, nie tylko samą nazwę."""
|
||||
monkeypatch.setenv("DISABLED_BASES", "archiwum/stara.xlsx")
|
||||
assert bases.is_enabled(f"{ROOT}/archiwum/stara.xlsx", ROOT) is False
|
||||
assert bases.is_enabled(f"{ROOT}/main_base.xlsx", ROOT) is True
|
||||
|
||||
|
||||
def test_entries_are_trimmed_and_multiple(monkeypatch):
|
||||
monkeypatch.setenv("DISABLED_BASES", " stara.xlsx , main_base.xlsx ")
|
||||
assert [b["enabled"] for b in bases.list_bases(ROOT, PATHS)] == [False, False]
|
||||
|
||||
|
||||
def test_listing_carries_metadata_not_content(tmp_path):
|
||||
"""Metaopis: nazwa, ścieżka, rozmiar, data — ŻADNEJ treści bazy."""
|
||||
f = tmp_path / "baza.xlsx"
|
||||
f.write_bytes(b"x" * 2048)
|
||||
item = bases.list_bases(tmp_path, [str(f)])[0]
|
||||
assert set(item) == {"name", "path", "size_mb", "modified", "enabled"}
|
||||
assert item["name"] == "baza.xlsx" and item["size_mb"] is not None and item["modified"]
|
||||
|
||||
|
||||
def test_missing_file_does_not_crash_the_listing():
|
||||
item = bases.list_bases(ROOT, [f"{ROOT}/nie-ma.xlsx"])[0]
|
||||
assert item["size_mb"] is None and item["modified"] is None
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Rekordy-pułapki (canary) — DAN-26.
|
||||
|
||||
Testujemy sam MECHANIZM na syntetycznych pułapkach: prawdziwe markery i injekcja
|
||||
do baz przychodzą od właściciela produktu. Regresja byłaby CICHA i podwójnie zła:
|
||||
albo pułapka wycieka do interpretacji/LLM (zdradza się i psuje wynik), albo
|
||||
przestaje odsiewać i nie wiadomo o tym.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
from app import canary
|
||||
|
||||
MAIN = (pathlib.Path(__file__).resolve().parents[1] / "app" / "main.py").read_text(encoding="utf-8")
|
||||
|
||||
MARK = "ASTROLOLO-CANARY-7f3a9" # unikalny — nie wystąpi w realnych danych
|
||||
ROWS = [
|
||||
{"significator": "Ma Ari", "effect": "odważny, impulsywny"},
|
||||
{"significator": "Ve Tau " + MARK, "effect": "pułapka — nie dotknie interpretacji"},
|
||||
{"significator": "Su Leo", "effect": "dumny, twórczy"},
|
||||
]
|
||||
|
||||
|
||||
def test_no_markers_is_transparent():
|
||||
"""Bez skonfigurowanych markerów — zero ingerencji, zero kosztu."""
|
||||
out, rep = canary.screen(ROWS, "cokolwiek", markers=[])
|
||||
assert out == ROWS and rep["active"] is False
|
||||
|
||||
|
||||
def test_canary_row_is_fenced_from_results():
|
||||
"""Pułapka znika z wyników — nie opuści warstwy danych (a więc i promptu LLM)."""
|
||||
out, rep = canary.screen(ROWS, "Ve Tau", markers=[MARK])
|
||||
assert rep["active"] and rep["removed"] == 1
|
||||
assert all(MARK not in str(r) for r in out) # nigdzie nie ma markera
|
||||
assert len(out) == 2 and {"significator": "Su Leo", "effect": "dumny, twórczy"} in out
|
||||
|
||||
|
||||
def test_real_results_pass_through_untouched():
|
||||
out, _ = canary.screen(ROWS, "Ari", markers=[MARK])
|
||||
assert {"significator": "Ma Ari", "effect": "odważny, impulsywny"} in out
|
||||
|
||||
|
||||
def test_tripwire_when_query_targets_a_marker():
|
||||
"""Zapytanie CELUJĄCE w marker = ktoś enumeruje bazę, nie liczy horoskopu."""
|
||||
_, rep = canary.screen(ROWS, "Ve Tau " + MARK, markers=[MARK])
|
||||
assert rep["tripwire"] is True
|
||||
|
||||
|
||||
def test_normal_query_does_not_trip():
|
||||
_, rep = canary.screen(ROWS, "Ma Ari", markers=[MARK])
|
||||
assert rep["tripwire"] is False
|
||||
|
||||
|
||||
def test_marker_matched_in_any_string_field():
|
||||
rows = [{"significator": "X", "effect": "opis " + MARK, "extra": 5}]
|
||||
out, rep = canary.screen(rows, "X", markers=[MARK])
|
||||
assert out == [] and rep["removed"] == 1 # marker w polu 'effect' też łapiemy
|
||||
|
||||
|
||||
def test_endpoint_fences_before_returning():
|
||||
"""/search odsiewa pułapki na wyjściu z warstwy danych (niezależnie od dostawcy)."""
|
||||
assert "canary.screen(result.rows, query.value)" in MAIN
|
||||
assert "result.rows = visible" in MAIN
|
||||
@@ -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
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /wheels /wheels
|
||||
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 . .
|
||||
|
||||
# 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
|
||||
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.
|
||||
|
||||
## 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)
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.txt # wymaga kompilatora C (patrz wyżej)
|
||||
uvicorn app.main:app --port 8003
|
||||
```
|
||||
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
import swisseph as swe
|
||||
@@ -69,6 +69,67 @@ def positions(req: PositionsRequest) -> dict:
|
||||
return {"engine": "swisseph", "positions": rows}
|
||||
|
||||
|
||||
# Kody systemów domów w Swiss Ephemeris. Nazwy po LEWEJ są nasze — te same,
|
||||
# których używa houses.SYSTEMS w warstwie logicznej — żeby wołający nie musiał
|
||||
# znać liter swissepha. Lista celowo pokrywa się 1:1 z naszą: rozjazd oznaczałby,
|
||||
# że kontrakt parzystości (LOG-28) przestał obejmować część systemów.
|
||||
_HOUSE_CODES = {
|
||||
"whole_sign": b"W", "whole_sign_aries": b"N",
|
||||
"equal": b"E", "equal_mc": b"D",
|
||||
"porphyry": b"O", "vehlow": b"V", "morinus": b"M",
|
||||
"regiomontanus": b"R", "campanus": b"C", "alcabitus": b"B",
|
||||
"topocentric": b"T", "placidus": b"P", "koch": b"K",
|
||||
}
|
||||
|
||||
# Kolejność, w jakiej swe_houses zwraca punkty w tablicy ascmc.
|
||||
_ASCMC = ("Asc", "MC", "ARMC", "Vertex", "equatorial_asc",
|
||||
"co_asc_koch", "co_asc_munkasey", "polar_asc")
|
||||
|
||||
|
||||
class HousesRequest(BaseModel):
|
||||
when_utc: datetime
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
system: str = "whole_sign"
|
||||
|
||||
|
||||
@app.post("/houses")
|
||||
def houses(req: HousesRequest) -> dict:
|
||||
"""Cuspy domów i osie policzone przez silnik B — do porównania z własnym.
|
||||
|
||||
Domyka kontrakt parzystości (LOG-28) po stronie domów: dotąd obejmował
|
||||
wyłącznie pozycje obiektów, więc błąd w podziale na domy przechodził przez
|
||||
porównanie silników niezauważony. Błąd w domach jest CICHY — wykres wygląda
|
||||
poprawnie, tylko planety siedzą gdzie indziej — więc akurat tu warto mieć
|
||||
drugie zdanie.
|
||||
|
||||
Placidus i Koch nie istnieją powyżej koła podbiegunowego i swisseph zgłasza
|
||||
tam wyjątek. Oddajemy to jako 422 z czytelnym powodem, a NIE podstawiamy po
|
||||
cichu innego systemu: cicha podmiana jest nie do wykrycia po stronie
|
||||
wołającego, a to on ma zdecydować, co z tym zrobić.
|
||||
"""
|
||||
code = _HOUSE_CODES.get(req.system)
|
||||
if code is None:
|
||||
raise HTTPException(422, f"nieznany system domów: {req.system!r} "
|
||||
f"(znane: {', '.join(sorted(_HOUSE_CODES))})")
|
||||
d = req.when_utc
|
||||
ut_hours = d.hour + d.minute / 60.0 + d.second / 3600.0
|
||||
jd = swe.julday(d.year, d.month, d.day, ut_hours)
|
||||
try:
|
||||
cusps, ascmc = swe.houses(jd, req.lat, req.lon, code)
|
||||
except Exception as e: # poza dziedziną systemu
|
||||
raise HTTPException(
|
||||
422, f"system {req.system!r} nie ma definicji dla φ={req.lat}: {e}") from e
|
||||
|
||||
return {
|
||||
"engine": "swisseph",
|
||||
"system": req.system,
|
||||
"cusps": [{"house": i + 1, "longitude": c % 360.0} for i, c in enumerate(cusps)],
|
||||
"angles": {name: ascmc[i] % 360.0 for i, name in enumerate(_ASCMC)
|
||||
if i < len(ascmc) and name in ("Asc", "MC", "ARMC", "Vertex")},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
|
||||
|
||||
@@ -6,10 +6,12 @@ i nie w bazie.
|
||||
|
||||
## API
|
||||
- `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/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/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)
|
||||
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ ABBREVIATIONS: dict[str, str] = {
|
||||
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||
# osie
|
||||
"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
|
||||
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
|
||||
"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
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app import link_crypto
|
||||
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:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||
@@ -26,12 +41,20 @@ class DataClient:
|
||||
) -> dict[str, Any]:
|
||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||
r = client.post(f"{self.base_url}/search", json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
return link_crypto.call_json(client, "POST", f"{self.base_url}/search",
|
||||
payload=payload, headers=_auth_headers(),
|
||||
link=_link())
|
||||
|
||||
def bases(self) -> dict[str, Any]:
|
||||
"""Lista baz na udziale + metaopis (DAN-15) — same metadane, bez treści."""
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
||||
headers=_auth_headers(), link=_link())
|
||||
|
||||
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:
|
||||
r = client.get(f"{self.base_url}/health")
|
||||
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -15,6 +15,16 @@ MAJOR = {
|
||||
"trine": 120.0,
|
||||
"opposition": 180.0,
|
||||
}
|
||||
# Aspekty POBOCZNE (opcjonalne, PRE-06). Tylko te trzy — bo mają już glify i barwy
|
||||
# w warstwie prezentacji (chartwheel) oraz w engine/glyphs.py, więc dokładają się
|
||||
# bez ruszania czegokolwiek poza silnikiem. Bazy interpretacyjne zwykle ich nie
|
||||
# opisują (brak tokenu w DB_TOKEN), więc trafiają na kosmogram i do tabeli, ale
|
||||
# NIE generują faset sygnifikatorów — most po cichu je pomija (significators.py).
|
||||
MINOR = {
|
||||
"semisextile": 30.0,
|
||||
"semisquare": 45.0,
|
||||
"quincunx": 150.0,
|
||||
}
|
||||
DB_TOKEN = {
|
||||
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
|
||||
"trine": "[tri", "opposition": "[opp",
|
||||
@@ -27,6 +37,18 @@ LUMINARIES = {"Sun", "Moon"}
|
||||
DEFAULT_ORB = 8.0
|
||||
LUMINARY_BONUS = 2.0
|
||||
|
||||
# Pary sztywno powiązane definicyjnie — kąt między nimi wynika z samej definicji
|
||||
# punktu, nie z układu nieba (SN = NN + 180°). Aspekt taki zawsze wychodzi
|
||||
# dokładny (orb 0,00°) i nie niesie żadnej informacji astrologicznej, więc
|
||||
# wycinamy go z wyników: zaśmieca listę w UI i zjada budżet promptu do LLM.
|
||||
RIGID_PAIRS = frozenset({
|
||||
frozenset({"North Node", "South Node"}),
|
||||
})
|
||||
|
||||
|
||||
def _is_rigid(name_a: str, name_b: str) -> bool:
|
||||
return frozenset({name_a, name_b}) in RIGID_PAIRS
|
||||
|
||||
|
||||
def separation(a: float, b: float) -> float:
|
||||
"""Najmniejsza separacja kątowa [0,180]."""
|
||||
@@ -49,25 +71,67 @@ def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: f
|
||||
return dev_next < dev_now
|
||||
|
||||
|
||||
def _first_aspect(sep: float, allowed: float, checks: dict) -> tuple[str, float] | None:
|
||||
"""Pierwszy aspekt z `checks`, w którego orbie mieści się separacja `sep`."""
|
||||
for asp, angle in checks.items():
|
||||
dev = abs(sep - angle)
|
||||
if dev <= allowed:
|
||||
return asp, round(dev, 2)
|
||||
return None
|
||||
|
||||
|
||||
def find_cross_aspects(
|
||||
a_positions: list[dict], b_positions: list[dict],
|
||||
orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS, minor: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Aspekty MIĘDZY dwoma horoskopami (synastria, PRE-04): każdy obiekt z A vs
|
||||
każdy obiekt z B (`obj1` = osoba A, `obj2` = osoba B). Statyczne — dwa natale,
|
||||
brak wspólnego czasu, więc bez applying/separating. RIGID_PAIRS nie dotyczy
|
||||
(NN osoby A vs SN osoby B to realny aspekt, nie artefakt definicji)."""
|
||||
checks = {**MAJOR, **MINOR} if minor else MAJOR
|
||||
out: list[dict] = []
|
||||
for a in a_positions:
|
||||
la = a.get("decimal")
|
||||
if la is None:
|
||||
continue
|
||||
for b in b_positions:
|
||||
lb = b.get("decimal")
|
||||
if lb is None:
|
||||
continue
|
||||
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
|
||||
m = _first_aspect(separation(float(la), float(lb)), allowed, checks)
|
||||
if m:
|
||||
out.append({"obj1": a["name"], "obj2": b["name"],
|
||||
"aspect": m[0], "orb": m[1], "allowed": round(allowed, 2)})
|
||||
return out
|
||||
|
||||
|
||||
def find_aspects(
|
||||
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS,
|
||||
minor: bool = False,
|
||||
) -> list[dict]:
|
||||
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
|
||||
|
||||
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).
|
||||
Zwraca listę aspektów; gdy znane są prędkości, każdy aspekt ma applying (bool)
|
||||
i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny). Orb i bonus dla świateł są
|
||||
KONFIGUROWALNE (PRE-06); `minor=True` dokłada aspekty poboczne (30/45/150°).
|
||||
|
||||
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
|
||||
"""
|
||||
checks = {**MAJOR, **MINOR} if minor else MAJOR
|
||||
out: list[dict] = []
|
||||
n = len(positions)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = positions[i], positions[j]
|
||||
if _is_rigid(a["name"], b["name"]):
|
||||
continue
|
||||
la, lb = a.get("decimal"), b.get("decimal")
|
||||
if la is None or lb is None:
|
||||
continue
|
||||
sep = separation(float(la), float(lb))
|
||||
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
|
||||
for asp, angle in MAJOR.items():
|
||||
for asp, angle in checks.items():
|
||||
dev = abs(sep - angle)
|
||||
if dev <= allowed:
|
||||
applying = _is_applying(
|
||||
|
||||
@@ -7,26 +7,96 @@ pozycje.
|
||||
from __future__ import annotations
|
||||
|
||||
from app.engine import houses as H
|
||||
from app.engine import zodiac as Z
|
||||
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
|
||||
|
||||
|
||||
def _fmt(name: str, lon: float) -> dict:
|
||||
def _fmt(name: str, lon: float, off: float = 0.0) -> dict:
|
||||
lon = norm360(lon - off)
|
||||
return {
|
||||
"name": name,
|
||||
"sign": SIGNS[sign_index(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,
|
||||
house_systems: list[str] | None = None,
|
||||
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0,
|
||||
aspect_minor: bool = False) -> dict:
|
||||
from app.engine import glyphs as GL
|
||||
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)
|
||||
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
||||
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
||||
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
|
||||
result["aspects"] = find_aspects( # aspekty (LOG-06), konfigurowalne (PRE-06)
|
||||
result["positions"], orb=aspect_orb,
|
||||
luminary_bonus=aspect_luminary_bonus, minor=aspect_minor)
|
||||
|
||||
# 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.
|
||||
# ε PRAWDZIWE — to samo, którym liczymy domy, bo pozycje ze Skyfielda są
|
||||
# POZORNE (uwzględniają nutację). Silnik bez sidereal() nutacji nie poda;
|
||||
# wtedy zostaje średnie, z jawnym oznaczeniem w wyniku.
|
||||
if hasattr(engine, "sidereal"):
|
||||
eps = engine.sidereal(moment)[1]
|
||||
result["obliquity_kind"] = "true"
|
||||
else:
|
||||
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
||||
result["obliquity_kind"] = "mean"
|
||||
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"):
|
||||
return result
|
||||
@@ -35,19 +105,75 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||
mc = H.compute_mc(ramc, eps)
|
||||
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||
cusp_list = H.cusps(asc, mc, system)
|
||||
# cusps_detailed, nie cusps_for: Placidus i Koch nie istnieją powyżej koła
|
||||
# podbiegunowego, a astrolog z Tromsø ma dostać wynik ZE ŚLADEM, czym go
|
||||
# policzyliśmy. Ten ślad musi dojść aż do raportu i PDF-a.
|
||||
primary = H.cusps_detailed(ramc, eps, moment.lat, system)
|
||||
cusp_list = primary.cusps # tropikalne — geometria domów jest niezmiennicza
|
||||
|
||||
result["house_system"] = system
|
||||
def _cusps_out(cl: list[float]) -> list[dict]:
|
||||
"""Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif."""
|
||||
return [
|
||||
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||
"in_sign": in_sign(norm360(c - off)),
|
||||
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
|
||||
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
|
||||
for i, c in enumerate(cl)
|
||||
]
|
||||
|
||||
result["house_system"] = primary.system # FAKTYCZNIE użyty
|
||||
result["house_system_requested"] = primary.requested
|
||||
# Lista ostrzeżeń dla całego horoskopu — prezentacja, raport i PDF czytają
|
||||
# jedno miejsce, więc żaden z nich nie może „zapomnieć" o fallbacku.
|
||||
result["house_warnings"] = [primary.notice] if primary.notice else []
|
||||
result["angles"] = {
|
||||
"Asc": _fmt("Asc", asc),
|
||||
"MC": _fmt("MC", mc),
|
||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0)),
|
||||
"IC": _fmt("IC", norm360(mc + 180.0)),
|
||||
"Asc": _fmt("Asc", asc, off),
|
||||
"MC": _fmt("MC", mc, off),
|
||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
||||
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
||||
}
|
||||
result["cusps"] = [
|
||||
{"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)}
|
||||
for i, c in enumerate(cusp_list)
|
||||
]
|
||||
for a in result["angles"].values(): # glif znaku osi (LOG-22)
|
||||
a["sign_glyph"] = GL.sign_glyph(a["sign"])
|
||||
result["cusps"] = _cusps_out(cusp_list) # PRYMARNY system — pod kosmogram i wstecz
|
||||
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
|
||||
|
||||
# Wiele systemów domów NARAZ (PRE-05/LOG-05) — do porównania obok siebie.
|
||||
# Osie (Asc/MC) są wspólne; różni się tylko PODZIAŁ na domy. Prymarny zostaje
|
||||
# w `cusps`/`house_system` (kosmogram i wstecz), a `house_systems` niesie pełen
|
||||
# zestaw; `positions[].houses[system]` mówi, w którym domu obiekt siedzi wg
|
||||
# danego systemu. Dokładamy tylko gdy poproszono o więcej niż jeden.
|
||||
requested = [system] + [s for s in (house_systems or []) if s in H.SYSTEMS]
|
||||
ordered = list(dict.fromkeys(requested)) # prymarny pierwszy, bez duplikatów
|
||||
if len(ordered) > 1:
|
||||
result["house_systems"] = []
|
||||
for s in ordered:
|
||||
cs = primary if s == system else H.cusps_detailed(ramc, eps, moment.lat, s)
|
||||
block = {"system": s, "used_system": cs.system, "cusps": _cusps_out(cs.cusps)}
|
||||
if cs.notice:
|
||||
block["notice"] = cs.notice
|
||||
if cs.notice not in result["house_warnings"]:
|
||||
result["house_warnings"].append(cs.notice)
|
||||
result["house_systems"].append(block)
|
||||
for pdict, obj in zip(result["positions"], positions):
|
||||
pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cs.cusps)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
@@ -8,13 +8,31 @@ silnik dostarcza tylko RAMC i ε.
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
||||
|
||||
WHOLE_SIGN = "whole_sign"
|
||||
EQUAL = "equal"
|
||||
EQUAL_MC = "equal_mc" # równe domy zakotwiczone na MC, nie na Asc
|
||||
WHOLE_SIGN_ARIES = "whole_sign_aries" # znaki jako domy, ale dom I to ZAWSZE Baran
|
||||
PORPHYRY = "porphyry"
|
||||
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
|
||||
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania
|
||||
# iteracyjnego i dochodzą osobno.
|
||||
VEHLOW = "vehlow"
|
||||
MORINUS = "morinus"
|
||||
REGIOMONTANUS = "regiomontanus"
|
||||
CAMPANUS = "campanus"
|
||||
ALCABITUS = "alcabitus"
|
||||
TOPOCENTRIC = "topocentric"
|
||||
PLACIDUS = "placidus"
|
||||
KOCH = "koch"
|
||||
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris
|
||||
# (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny:
|
||||
# powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi.
|
||||
SYSTEMS = (WHOLE_SIGN, WHOLE_SIGN_ARIES, EQUAL, EQUAL_MC, PORPHYRY, VEHLOW,
|
||||
MORINUS, REGIOMONTANUS, CAMPANUS, ALCABITUS, TOPOCENTRIC,
|
||||
PLACIDUS, KOCH)
|
||||
|
||||
|
||||
def mean_obliquity(tt_jd: float) -> float:
|
||||
@@ -31,12 +49,27 @@ def compute_mc(ramc_deg: float, eps_deg: float) -> float:
|
||||
|
||||
|
||||
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
|
||||
"""Ascendent — punkt ekliptyki wschodzący na horyzoncie.
|
||||
|
||||
KOREKTA GAŁĘZI (błąd wykryty przez porównanie z wyrocznią, tests/oracle):
|
||||
ekliptyka przecina horyzont w DWÓCH punktach — wschodzącym (Asc) i zachodzącym
|
||||
(Dsc). `atan2` wybiera jeden z nich, ale powyżej koła podbiegunowego potrafi
|
||||
wskazać ten NIEWŁAŚCIWY: dla szerokości 67°+ i szerokiego zakresu RAMC
|
||||
zwracaliśmy Descendent, czyli Ascendent przesunięty o 180°. Skutek nie był
|
||||
subtelny — planety lądowały w PRZECIWNYCH domach dla całej Skandynawii
|
||||
północnej (Tromsø, Rovaniemi, Murmańsk).
|
||||
|
||||
Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze w półkolu
|
||||
(0°, 180°) na wschód od MC. Reguła zweryfikowana na 46 800 przypadkach wobec
|
||||
Swiss Ephemeris — zero rozbieżności.
|
||||
"""
|
||||
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
||||
asc = math.atan2(
|
||||
asc = norm360(math.degrees(math.atan2(
|
||||
math.cos(r),
|
||||
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
||||
)
|
||||
return norm360(math.degrees(asc))
|
||||
)))
|
||||
mc = compute_mc(ramc_deg, eps_deg)
|
||||
return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else asc
|
||||
|
||||
|
||||
def _trisect(a: float, b: float) -> tuple[float, float]:
|
||||
@@ -45,10 +78,29 @@ def _trisect(a: float, b: float) -> tuple[float, float]:
|
||||
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
|
||||
|
||||
|
||||
# Poniżej tej odległości od granicy znaku traktujemy Ascendent jak leżący DOKŁADNIE
|
||||
# na niej. 1e-9° to 3,6 mikrosekundy łuku — o rzędy wielkości poniżej jakiejkolwiek
|
||||
# realnej dokładności danych urodzeniowych, więc nie zmienia to żadnego horoskopu.
|
||||
_SIGN_SNAP_DEG = 1e-9
|
||||
|
||||
|
||||
def _snap_to_sign_boundary(lon: float) -> float:
|
||||
"""Przyciąga długość do granicy znaku, gdy jest od niej o włos.
|
||||
|
||||
Whole sign jest NIECIĄGŁY na granicach znaków: różnica 10⁻¹¹° w Ascendencie
|
||||
przerzuca cały dom I o 30°. Bez tego przyciągania ten sam horoskop policzony
|
||||
na innej maszynie mógłby dać inny wynik (wykryte przez porównanie z wyrocznią:
|
||||
nasz Asc = 359,999999999976, swissepha = 1e-10 — ta sama wartość po dwóch
|
||||
stronach granicy Ryby/Baran). Determinizm jest tu ważniejszy niż dosłowność
|
||||
zmiennoprzecinkowa."""
|
||||
nearest = round(lon / 30.0) * 30.0
|
||||
return norm360(nearest) if abs(lon - nearest) < _SIGN_SNAP_DEG else lon
|
||||
|
||||
|
||||
def cusps(asc: float, mc: float, system: str) -> list[float]:
|
||||
"""Zwraca 12 cusps (długości) domów 1..12."""
|
||||
if system == WHOLE_SIGN:
|
||||
start = sign_index(asc) * 30.0
|
||||
start = sign_index(_snap_to_sign_boundary(asc)) * 30.0
|
||||
return [norm360(start + 30.0 * i) for i in range(12)]
|
||||
if system == EQUAL:
|
||||
return [norm360(asc + 30.0 * i) for i in range(12)]
|
||||
@@ -64,14 +116,472 @@ def cusps(asc: float, mc: float, system: str) -> list[float]:
|
||||
raise ValueError(f"nieznany system domów: {system}")
|
||||
|
||||
|
||||
def polar_circle(eps_deg: float) -> float:
|
||||
"""Szerokość koła podbiegunowego [°] dla danego nachylenia ekliptyki.
|
||||
|
||||
NIE jest to stała 66,56°: ε zmienia się z datą (ok. 23,71° w 370 p.n.e.,
|
||||
23,44° dziś), więc granica przesuwa się o ~0,3° w zakresie dat programu.
|
||||
Powyżej niej stopnie ekliptyki bywają okołobiegunowe — nie wschodzą ani nie
|
||||
zachodzą — przez co systemy oparte na łuku dobowym (Placidus, Koch) tracą
|
||||
definicję."""
|
||||
return 90.0 - abs(eps_deg)
|
||||
|
||||
|
||||
# ── geometria wektorowa dla systemów dzielących koła wielkie ─────────────
|
||||
# Wzory na te systemy krążą w literaturze w kilku wariantach i łatwo o pomyłkę
|
||||
# w gałęzi albo znaku. Liczymy więc WPROST z geometrii: budujemy wektory kierunkowe
|
||||
# w układzie równikowym, przecinamy płaszczyzny i dopiero wynik zamieniamy na
|
||||
# długość ekliptyczną. Jest to dłuższe, ale jednoznaczne i sprawdzalne.
|
||||
|
||||
def _cross(a, b):
|
||||
return (a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0])
|
||||
|
||||
|
||||
def _dot(a, b):
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
|
||||
|
||||
def _equatorial_to_lon(v, eps_rad: float) -> float:
|
||||
"""Wektor w układzie RÓWNIKOWYM → długość ekliptyczna [°]."""
|
||||
x, y, z = v
|
||||
y_ecl = y * math.cos(eps_rad) + z * math.sin(eps_rad)
|
||||
return norm360(math.degrees(math.atan2(y_ecl, x)))
|
||||
|
||||
|
||||
def _ecliptic_pole(eps_rad: float):
|
||||
"""Biegun ekliptyki (normalna płaszczyzny ekliptyki) w układzie równikowym."""
|
||||
return (0.0, -math.sin(eps_rad), math.cos(eps_rad))
|
||||
|
||||
|
||||
def _horizon_north(ramc_rad: float, lat_rad: float):
|
||||
"""Punkt północny horyzontu: RA = RAMC+180°, deklinacja = 90°−φ."""
|
||||
return (-math.sin(lat_rad) * math.cos(ramc_rad),
|
||||
-math.sin(lat_rad) * math.sin(ramc_rad),
|
||||
math.cos(lat_rad))
|
||||
|
||||
|
||||
# Domy POŚREDNIE (bez osi) i to, po której stronie MC leżą. Domy 11, 12, 2, 3
|
||||
# są na wschód od MC (przesunięcie 0–180°), domy 5, 6, 8, 9 — na zachód.
|
||||
_INTERMEDIATE = {1: True, 2: True, 4: False, 5: False,
|
||||
7: False, 8: False, 10: True, 11: True}
|
||||
|
||||
|
||||
def _house_circle_cusp(north, q, eps_rad: float, mc: float, east_of_mc: bool) -> float:
|
||||
"""Cusp = przecięcie ekliptyki z kołem domu.
|
||||
|
||||
Koło domu przechodzi przez punkty N/S horyzontu oraz przez punkt podziału `q`
|
||||
(na równiku dla Regiomontanusa, na pierwszym wertykale dla Campanusa).
|
||||
Przecięcie dwóch płaszczyzn daje PROSTĄ, czyli DWA antypodyczne kierunki;
|
||||
wybieramy ten po właściwej stronie południka.
|
||||
|
||||
Używane WYŁĄCZNIE dla domów pośrednich. Osie (1, 4, 7, 10) znamy dokładnie
|
||||
z Asc i MC — liczenie ich tą drogą było błędem, bo leżą dokładnie na granicy
|
||||
„wschód/zachód" (przesunięcie 0° i 180°), gdzie porównanie zmiennoprzecinkowe
|
||||
się chwieje i potrafi wybrać przeciwny punkt nieba."""
|
||||
normal = _cross(north, q) # normalna płaszczyzny koła domu
|
||||
line = _cross(normal, _ecliptic_pole(eps_rad))
|
||||
lon = _equatorial_to_lon(line, eps_rad)
|
||||
return lon if ((lon - mc) % 360.0 < 180.0) == east_of_mc else norm360(lon + 180.0)
|
||||
|
||||
|
||||
def _culminating_mc(mc: float, eps: float, lat: float) -> float:
|
||||
"""Punkt południka, który dla tej szerokości leży NAD horyzontem.
|
||||
|
||||
Systemy oparte na horyzoncie (Regiomontanus, Campanus, Topocentric) biorą jako
|
||||
dziesiąty dom punkt GÓRUJĄCY, a nie matematyczne MC — a za kołem podbiegunowym
|
||||
to nie zawsze to samo. Punkt południka o deklinacji δ ma wysokość 90−|φ−δ|,
|
||||
więc jest nad horyzontem dokładnie wtedy, gdy |φ−δ| < 90.
|
||||
|
||||
Systemy dzielące samą ekliptykę (porphyry, equal, alcabitus, whole sign) tego
|
||||
nie robią — i tak samo zachowuje się wyrocznia."""
|
||||
dec = math.degrees(math.asin(math.sin(math.radians(mc)) * math.sin(math.radians(eps))))
|
||||
return norm360(mc + 180.0) if abs(lat - dec) > 90.0 else mc
|
||||
|
||||
|
||||
def _with_exact_angles(intermediate, asc: float, mc: float) -> list[float]:
|
||||
"""Składa 12 cuspów: osie wstawione dokładnie, reszta z geometrii."""
|
||||
out = [0.0] * 12
|
||||
out[0], out[3] = asc, norm360(mc + 180.0) # Asc, IC
|
||||
out[6], out[9] = norm360(asc + 180.0), mc # Dsc, MC
|
||||
for i, value in intermediate.items():
|
||||
out[i] = value
|
||||
return out
|
||||
|
||||
|
||||
def _ra_to_ecliptic_lon(ra_deg: float, eps_rad: float) -> float:
|
||||
"""Punkt ekliptyki o zadanej rektascensji (koło godzinne → ekliptyka)."""
|
||||
r = math.radians(ra_deg)
|
||||
return norm360(math.degrees(math.atan2(math.sin(r), math.cos(r) * math.cos(eps_rad))))
|
||||
|
||||
|
||||
def _equator_point(ra_deg: float):
|
||||
"""Kierunek punktu na równiku niebieskim o danej rektascensji."""
|
||||
r = math.radians(ra_deg)
|
||||
return (math.cos(r), math.sin(r), 0.0)
|
||||
|
||||
|
||||
def _prime_vertical_point(ramc_rad: float, lat_rad: float, angle_deg: float):
|
||||
"""Punkt pierwszego wertykału, `angle_deg` od punktu wschodu w stronę nadiru.
|
||||
|
||||
Pierwszy wertykał to koło przez wschód, zenit, zachód i nadir — Campanus dzieli
|
||||
właśnie je."""
|
||||
east = (-math.sin(ramc_rad), math.cos(ramc_rad), 0.0)
|
||||
zenith = (math.cos(lat_rad) * math.cos(ramc_rad),
|
||||
math.cos(lat_rad) * math.sin(ramc_rad),
|
||||
math.sin(lat_rad))
|
||||
a = math.radians(angle_deg)
|
||||
return tuple(east[i] * math.cos(a) - zenith[i] * math.sin(a) for i in range(3))
|
||||
|
||||
|
||||
def _cusps_regiomontanus(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Równik niebieski dzielony na 12 równych łuków, rzut kołami przez N/S horyzontu."""
|
||||
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
||||
north = _horizon_north(rr, lr)
|
||||
mid = {i: _house_circle_cusp(north, _equator_point(ramc + 90.0 + 30.0 * i), er, mc, e)
|
||||
for i, e in _INTERMEDIATE.items()}
|
||||
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
||||
|
||||
|
||||
def _cusps_campanus(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Pierwszy wertykał dzielony na 12 równych łuków, rzut tak samo jak wyżej."""
|
||||
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
||||
north = _horizon_north(rr, lr)
|
||||
mid = {i: _house_circle_cusp(north, _prime_vertical_point(rr, lr, 30.0 * i), er, mc, e)
|
||||
for i, e in _INTERMEDIATE.items()}
|
||||
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
||||
|
||||
|
||||
def _cusps_morinus(ramc: float, eps: float) -> list[float]:
|
||||
"""Równik dzielony od RAMC i rzutowany WPROST na ekliptykę — bez horyzontu.
|
||||
|
||||
Dlatego Morinus jako jedyny nie zależy od szerokości geograficznej, a jego
|
||||
dom I nie pokrywa się z Ascendentem. Uwaga: to ZAMIANA WSPÓŁRZĘDNYCH punktu
|
||||
równika (RA, dec=0) na ekliptyczne, a nie rzut kołem godzinnym — te dwie
|
||||
operacje dają różne wyniki i pomylenie ich kosztowało tu do 5°."""
|
||||
er = math.radians(eps)
|
||||
return [_equatorial_to_lon(_equator_point(ramc + 90.0 + 30.0 * i), er)
|
||||
for i in range(12)]
|
||||
|
||||
|
||||
def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
|
||||
"""Łuki równika MC→Asc i Asc→IC dzielone na trzy; rzut kołami godzinnymi."""
|
||||
er = math.radians(eps)
|
||||
a = math.radians(asc)
|
||||
ra_asc = norm360(math.degrees(math.atan2(math.sin(a) * math.cos(er), math.cos(a))))
|
||||
day = (ra_asc - ramc) % 360.0 # łuk MC → Asc (domy 11, 12)
|
||||
night = (ramc + 180.0 - ra_asc) % 360.0 # łuk Asc → IC (domy 2, 3)
|
||||
|
||||
ra = [0.0] * 12
|
||||
ra[9] = ramc # dom 10 = MC
|
||||
ra[10] = ramc + day / 3.0 # dom 11
|
||||
ra[11] = ramc + 2.0 * day / 3.0 # dom 12
|
||||
ra[0] = ra_asc # dom 1 = Asc
|
||||
ra[1] = ra_asc + night / 3.0 # dom 2
|
||||
ra[2] = ra_asc + 2.0 * night / 3.0 # dom 3
|
||||
for i in range(6): # domy 4–9 naprzeciw 10–3
|
||||
ra[i + 3] = ra[(i + 9) % 12] + 180.0
|
||||
return [_ra_to_ecliptic_lon(x, er) for x in ra]
|
||||
|
||||
|
||||
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (Polich–Page).
|
||||
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
|
||||
# Polich–Page: dom pośredni to Ascendent policzony pod własnym „biegunem"
|
||||
# tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu. Rodzina jest CIĄGŁA:
|
||||
# przy k=0 (biegun 0, przesunięcie −90°) daje MC, przy k=3 (biegun φ, przesunięcie 0)
|
||||
# Ascendent, a domy 11 i 12 leżą po drodze.
|
||||
#
|
||||
# Cała trudność tego systemu siedziała w wyborze gałęzi — dwa koła wielkie
|
||||
# przecinają się w dwóch punktach antypodycznych. Heurystyki („po której stronie
|
||||
# MC", „w łuku kwadrantu", „wschodnia połowa horyzontu", śledzenie ciągłości
|
||||
# krokami) myliły się na 6–11% przypadków powyżej ~70°, bo każda z nich rozstrzyga
|
||||
# LOKALNIE, a przy dużych szerokościach kolejność domów potrafi się odwrócić.
|
||||
#
|
||||
# Rozwiązanie: nie wybierać w ogóle. Iloczyn wektorowy zenitu z biegunem ekliptyki
|
||||
# jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot — dwuznaczność
|
||||
# wprowadza dopiero atan2. Zostajemy więc w wektorach, a znak ustalamy RAZ, kotwicząc
|
||||
# rodzinę na MC górującym. Stąd zgodność co do zera na całej dziedzinie, bez iteracji
|
||||
# i bez zawężania szerokości.
|
||||
|
||||
# Dom → (przesunięcie RAMC [°], ułamek bieguna k/3).
|
||||
_TOPO_STEP = {10: (-60.0, 1 / 3), 11: (-30.0, 2 / 3), # domy 11, 12
|
||||
1: (30.0, 2 / 3), 2: (60.0, 1 / 3)} # domy 2, 3
|
||||
|
||||
|
||||
def _cusps_topocentric(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Ascendenty pod biegunami tan(P) = tan(φ)·k/3, liczone wektorowo.
|
||||
|
||||
Domy 5, 6, 8, 9 bierzemy jako OPOZYCJE domów 11, 12, 2, 3 — to nie skrót,
|
||||
lecz własność konstrukcji: przeciwległe domy leżą na tym samym kole wielkim.
|
||||
|
||||
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik
|
||||
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
|
||||
konstrukcji „ascendent pod biegunem"."""
|
||||
# Cuspy topocentrica są poprawne na CAŁEJ dziedzinie (zgodne z wyrocznią co do
|
||||
# zera), ale powyżej koła podbiegunowego przestają DZIELIĆ OKRĄG: domy nachodzą
|
||||
# na siebie, bo cusp VII (= I + 180°) wypada przed cuspem VI. Przypisanie planety
|
||||
# do domu traci wtedy sens — co potwierdza sama wyrocznia, której swe_house_pos
|
||||
# przeczy tam własnym cuspom (100% zgodności do 62°, 83,9% przy 66°, ok. 50%
|
||||
# przy 72°). Odmawiamy, zamiast zwracać liczbę bez znaczenia.
|
||||
#
|
||||
# To INNY rodzaj granicy niż u Placidusa i Kocha: tam nie istnieją same cuspy,
|
||||
# tu istnieją, tylko nie tworzą podziału. Próg jest wyprowadzony z warunku
|
||||
# „dwanaście cuspów sumuje się do 360°", nie dobrany pod wynik testu — i wypada
|
||||
# na kole podbiegunowym (zmierzone: 100% podziałów do 65°, 78% w pasie 66-67°).
|
||||
if abs(lat) >= polar_circle(eps):
|
||||
raise HouseSystemUndefined(
|
||||
f"φ={lat:.4f}° powyżej koła podbiegunowego ({polar_circle(eps):.4f}° dla "
|
||||
f"ε={eps:.4f}°): cuspy topocentryczne przestają dzielić okrąg, "
|
||||
f"domy nachodzą na siebie")
|
||||
er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
|
||||
epole = _ecliptic_pole(er)
|
||||
# Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
|
||||
# cała rodzina obraca się razem z dziesiątym domem — stąd zwrot iloczynu.
|
||||
culminating = _culminating_mc(mc, eps, lat)
|
||||
sign = 1.0 if abs(((culminating - mc + 180.0) % 360.0) - 180.0) > 90.0 else -1.0
|
||||
|
||||
out = [0.0] * 12
|
||||
for i, (offset, fraction) in _TOPO_STEP.items():
|
||||
th = math.radians(ramc + offset)
|
||||
pole = math.atan(tan_lat * fraction)
|
||||
zenith = (math.cos(pole) * math.cos(th),
|
||||
math.cos(pole) * math.sin(th),
|
||||
math.sin(pole))
|
||||
v = _cross(zenith, epole)
|
||||
lon = _equatorial_to_lon(tuple(sign * x for x in v), er)
|
||||
out[i] = lon
|
||||
out[(i + 6) % 12] = norm360(lon + 180.0)
|
||||
out[0], out[3] = asc, norm360(culminating + 180.0)
|
||||
out[6], out[9] = norm360(asc + 180.0), culminating
|
||||
return out
|
||||
|
||||
|
||||
# ── systemy łuku dobowego (Placidus, Koch) ───────────────────────────────
|
||||
# Różnią się od wszystkich poprzednich tym, że NIE MAJĄ wzoru zamkniętego: cusp
|
||||
# jest zdefiniowany warunkiem na samego siebie („punkt, który przebył 1/3 swojego
|
||||
# półłuku"), więc trzeba go znaleźć iteracyjnie. Mają też jako jedyne REALNĄ
|
||||
# granicę dziedziny — powyżej koła podbiegunowego stopnie ekliptyki bywają
|
||||
# okołobiegunowe, nie wschodzą ani nie zachodzą, i półłuk po prostu nie istnieje.
|
||||
|
||||
|
||||
class HouseSystemUndefined(ValueError):
|
||||
"""System domów nie ma definicji dla podanych parametrów (nie: błąd liczenia).
|
||||
|
||||
Podnoszone zamiast zwrócenia liczby, bo cicha podmiana systemu jest gorsza
|
||||
niż błąd: wykres wygląda poprawnie, a planety siedzą w innych domach, niż
|
||||
astrolog zamawiał. Warstwa aplikacyjna łapie to w cusps_detailed() i robi
|
||||
JAWNY fallback."""
|
||||
|
||||
|
||||
_ITER_MAX = 100
|
||||
_ITER_TOL_DEG = 1e-11
|
||||
|
||||
|
||||
def _declination_of_ecliptic_lon(lon_deg: float, eps_rad: float) -> float:
|
||||
"""Deklinacja punktu LEŻĄCEGO NA EKLIPTYCE o danej długości."""
|
||||
return math.degrees(math.asin(math.sin(eps_rad) * math.sin(math.radians(lon_deg))))
|
||||
|
||||
|
||||
def _ascensional_difference(dec_deg: float, lat_deg: float) -> float:
|
||||
"""Różnica wschodnia: o ile półłuk dobowy odbiega od 90°.
|
||||
|
||||
sin(AD) = tan(φ)·tan(δ). Gdy |tan(φ)·tan(δ)| ≥ 1, punkt jest okołobiegunowy
|
||||
(nigdy nie wschodzi albo nigdy nie zachodzi) i półłuk nie istnieje."""
|
||||
v = math.tan(math.radians(lat_deg)) * math.tan(math.radians(dec_deg))
|
||||
if abs(v) >= 1.0:
|
||||
raise HouseSystemUndefined(
|
||||
f"punkt okołobiegunowy (tan φ·tan δ = {v:.6f}): półłuk dobowy nie istnieje")
|
||||
return math.degrees(math.asin(v))
|
||||
|
||||
|
||||
# Dom → (ułamek półłuku, czy łuk NOCNY). Domy 11 i 12 dzielą łuk dzienny licząc
|
||||
# od MC; domy 2 i 3 — łuk nocny, licząc WSTECZ od IC.
|
||||
_PLACIDUS_STEP = {10: (1 / 3, False), 11: (2 / 3, False),
|
||||
1: (2 / 3, True), 2: (1 / 3, True)}
|
||||
|
||||
|
||||
def _placidus_cusp(ramc: float, eps: float, lat: float,
|
||||
fraction: float, nocturnal: bool) -> float:
|
||||
"""Punkt ekliptyki, który przebył `fraction` swojego półłuku.
|
||||
|
||||
Warunek jest uwikłany: półłuk zależy od deklinacji, deklinacja od długości,
|
||||
a długość od położenia — więc iterujemy po punkcie stałym. Zbieżność jest
|
||||
szybka z dala od koła podbiegunowego i psuje się przy nim, dlatego brak
|
||||
zbieżności traktujemy jako wyjście poza dziedzinę, a nie jako wynik."""
|
||||
eps_rad = math.radians(eps)
|
||||
# Start od podziału równomiernego — to Porphyry na równiku, czyli dokładnie
|
||||
# ten przypadek, w którym Placidus się do niego sprowadza.
|
||||
ra = ramc + 180.0 - 90.0 * fraction if nocturnal else ramc + 90.0 * fraction
|
||||
for _ in range(_ITER_MAX):
|
||||
dec = _declination_of_ecliptic_lon(_ra_to_ecliptic_lon(ra, eps_rad), eps_rad)
|
||||
ad = _ascensional_difference(dec, lat)
|
||||
nxt = (ramc + 180.0 - fraction * (90.0 - ad) if nocturnal
|
||||
else ramc + fraction * (90.0 + ad))
|
||||
if abs(nxt - ra) < _ITER_TOL_DEG:
|
||||
return _ra_to_ecliptic_lon(nxt, eps_rad)
|
||||
ra = nxt
|
||||
raise HouseSystemUndefined(
|
||||
f"brak zbieżności po {_ITER_MAX} krokach (φ={lat:.4f}, RAMC={ramc:.4f})")
|
||||
|
||||
|
||||
def _cusps_placidus(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Półłuki dobowe i nocne dzielone na trzy — każdy punkt swoim własnym łukiem."""
|
||||
if abs(lat) >= polar_circle(eps):
|
||||
raise HouseSystemUndefined(
|
||||
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
|
||||
inter = {i: _placidus_cusp(ramc, eps, lat, f, noct)
|
||||
for i, (f, noct) in _PLACIDUS_STEP.items()}
|
||||
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
|
||||
return _with_exact_angles(inter, asc, mc)
|
||||
|
||||
|
||||
# Koch dzieli CZAS, nie łuk na niebie. Kryterium: ile czasu minęło od wschodu
|
||||
# tego stopnia zodiaku, który stoi na MC. Ten odcinek (półłuk dobowy stopnia MC)
|
||||
# dzielimy na trzy i dla punktów podziału liczymy ZWYKŁY Ascendent — stąd nazwa
|
||||
# „system miejsca urodzenia". Zgodne z definicją Astrodienst (astro.com/astrowiki).
|
||||
#
|
||||
# W przeciwieństwie do Placidusa NIE wymaga iteracji: półłuk zależy od deklinacji
|
||||
# stopnia MC, którą znamy wprost. Granicę dziedziny dzieli natomiast z Placidusem —
|
||||
# gdy stopień MC jest okołobiegunowy, „moment jego wschodu" nie istnieje.
|
||||
_KOCH_OFFSET = {10: -2 / 3, 11: -1 / 3, 1: 1 / 3, 2: 2 / 3}
|
||||
|
||||
|
||||
def _cusps_koch(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Ascendenty dla chwil trójdzielących drogę stopnia MC od wschodu do górowania."""
|
||||
if abs(lat) >= polar_circle(eps):
|
||||
raise HouseSystemUndefined(
|
||||
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
|
||||
dec_mc = _declination_of_ecliptic_lon(mc, math.radians(eps))
|
||||
half_arc = 90.0 + _ascensional_difference(dec_mc, lat)
|
||||
inter = {i: compute_asc(ramc + f * half_arc, eps, lat)
|
||||
for i, f in _KOCH_OFFSET.items()}
|
||||
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
|
||||
return _with_exact_angles(inter, asc, mc)
|
||||
|
||||
|
||||
def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]:
|
||||
"""Kanoniczne wejście: (RAMC, ε, φ) → 12 cusps.
|
||||
|
||||
Systemy proste (whole sign / equal / porphyry) potrzebują tylko Asc i MC,
|
||||
ale systemy egzotyczne dzielą inne koła wielkie i wymagają pełnego zestawu
|
||||
(RAMC, ε, φ). Ta funkcja jest wspólnym punktem wejścia dla obu rodzajów —
|
||||
i to ją porównuje z wyrocznią framework testowy (tests/oracle).
|
||||
"""
|
||||
asc = compute_asc(ramc, eps, lat)
|
||||
mc = compute_mc(ramc, eps)
|
||||
if system in (WHOLE_SIGN, EQUAL, PORPHYRY):
|
||||
return cusps(asc, mc, system)
|
||||
if system == WHOLE_SIGN_ARIES:
|
||||
# Znaki jako domy, ale numeracja rusza od Barana niezależnie od Ascendentu.
|
||||
# Wariant spotykany w tradycji indyjskiej i w części szkół hellenistycznych.
|
||||
return [norm360(30.0 * i) for i in range(12)]
|
||||
if system == EQUAL_MC:
|
||||
# Równe domy jak `equal`, ale zakotwiczone na MC: dom X zaczyna się
|
||||
# DOKŁADNIE na MC, więc oś południka wypada na granicy domu, a nie w środku.
|
||||
return [norm360(mc + 90.0 + 30.0 * i) for i in range(12)]
|
||||
if system == VEHLOW:
|
||||
# equal, ale Ascendent leży w ŚRODKU domu I, nie na jego początku
|
||||
return [norm360(asc - 15.0 + 30.0 * i) for i in range(12)]
|
||||
if system == MORINUS:
|
||||
return _cusps_morinus(ramc, eps)
|
||||
if system == REGIOMONTANUS:
|
||||
return _cusps_regiomontanus(ramc, eps, lat, asc, mc)
|
||||
if system == CAMPANUS:
|
||||
return _cusps_campanus(ramc, eps, lat, asc, mc)
|
||||
if system == ALCABITUS:
|
||||
return _cusps_alcabitus(ramc, eps, asc)
|
||||
if system == TOPOCENTRIC:
|
||||
return _cusps_topocentric(ramc, eps, lat, asc, mc)
|
||||
if system == PLACIDUS:
|
||||
return _cusps_placidus(ramc, eps, lat, asc, mc)
|
||||
if system == KOCH:
|
||||
return _cusps_koch(ramc, eps, lat, asc, mc)
|
||||
raise ValueError(f"nieznany system domów: {system}")
|
||||
|
||||
|
||||
# ── jawny fallback poza dziedziną ────────────────────────────────────────
|
||||
# Placidus i Koch jako jedyne mają miejsca, w których po prostu NIE ISTNIEJĄ.
|
||||
# Astrolog z Tromsø ma dostać wynik, ale musi wiedzieć, że dostał inny system —
|
||||
# cicha podmiana jest gorsza niż brak wyniku, bo jest nie do wykrycia z wykresu.
|
||||
|
||||
FALLBACK_SYSTEM = PORPHYRY
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CuspSet:
|
||||
"""12 cuspów + uczciwa informacja, czym naprawdę zostały policzone."""
|
||||
|
||||
cusps: list[float]
|
||||
system: str # system FAKTYCZNIE użyty
|
||||
requested: str # o który poproszono
|
||||
reason: str | None = None # dlaczego nie dało się użyć żądanego
|
||||
|
||||
@property
|
||||
def is_fallback(self) -> bool:
|
||||
return self.system != self.requested
|
||||
|
||||
@property
|
||||
def notice(self) -> str | None:
|
||||
"""Komunikat dla człowieka. Ma trafić na ekran, do raportu i do PDF-a."""
|
||||
if not self.is_fallback:
|
||||
return None
|
||||
return (f"UWAGA: system domów \u201e{self.requested}\u201d nie ma definicji "
|
||||
f"dla tego miejsca i czasu \u2014 {self.reason}. Domy policzono "
|
||||
f"systemem \u201e{self.system}\u201d. To NIE jest ten sam podzia\u0142: "
|
||||
f"pozycje planet s\u0105 poprawne, ale przypisanie ich do dom\u00f3w "
|
||||
f"pochodzi z innego systemu.")
|
||||
|
||||
|
||||
def cusps_detailed(ramc: float, eps: float, lat: float, system: str) -> CuspSet:
|
||||
"""Jak cusps_for, ale zamiast wyjątku poza dziedziną robi JAWNY fallback.
|
||||
|
||||
cusps_for zostaje funkcją czystą i nieustępliwą (to ją porównuje wyrocznia);
|
||||
ustępstwo wobec rzeczywistości jest tutaj — i zawsze zostawia ślad."""
|
||||
try:
|
||||
return CuspSet(cusps_for(ramc, eps, lat, system), system, system)
|
||||
except HouseSystemUndefined as e:
|
||||
return CuspSet(cusps_for(ramc, eps, lat, FALLBACK_SYSTEM),
|
||||
FALLBACK_SYSTEM, system, str(e))
|
||||
|
||||
|
||||
def _runs_forward(cusp_list: list[float]) -> bool:
|
||||
"""Czy domy biegną w stronę rosnących długości ekliptycznych.
|
||||
|
||||
Zwykle tak — ale NIE ZAWSZE. Przy dużych szerokościach systemy dzielące koła
|
||||
wielkie (regiomontanus, campanus, topocentric) mają kolejność ODWRÓCONĄ:
|
||||
przy φ = −84,3° cusp domu I wypada na 174,2°, a domu II na 165,3°. To nie
|
||||
jest błąd — wyrocznia zwraca dokładnie te same wartości.
|
||||
|
||||
Rozstrzygamy sumą przeskoków „do przodu": dwanaście cuspów dzieli okrąg, więc
|
||||
idąc we WŁAŚCIWĄ stronę zsumują się do 360°. Idąc pod prąd każdy przeskok
|
||||
obchodzi koło dookoła i suma wychodzi 11 × 360° = 3960°."""
|
||||
total = sum((cusp_list[(i + 1) % 12] - cusp_list[i]) % 360.0 for i in range(12))
|
||||
return abs(total - 360.0) < abs(total - 3960.0)
|
||||
|
||||
|
||||
def assign_house(lon: float, cusp_list: list[float]) -> int:
|
||||
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
|
||||
"""Numer domu (1..12), w którym leży dana długość ekliptyczna.
|
||||
|
||||
Kierunek liczenia bierzemy z samych cuspów. Zaszycie „zawsze do przodu"
|
||||
dawało przy |φ| powyżej koła podbiegunowego złe domy dla regiomontanusa,
|
||||
campanusa i topocentrica — mimo cuspów zgodnych z wyrocznią co do zera.
|
||||
Błąd był CICHY: wykres wyglądał poprawnie, tylko planety siedziały gdzie
|
||||
indziej. Sprawdzane wobec swe_house_pos (tests/oracle)."""
|
||||
lon = norm360(lon)
|
||||
forward = _runs_forward(cusp_list)
|
||||
for i in range(12):
|
||||
start = cusp_list[i]
|
||||
end = cusp_list[(i + 1) % 12]
|
||||
span = (end - start) % 360.0
|
||||
offset = (lon - start) % 360.0
|
||||
if forward:
|
||||
span, offset = (end - start) % 360.0, (lon - start) % 360.0
|
||||
else:
|
||||
span, offset = (start - end) % 360.0, (start - lon) % 360.0
|
||||
if offset < span:
|
||||
return i + 1
|
||||
return 12
|
||||
|
||||
@@ -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
|
||||
@@ -117,12 +117,28 @@ class SkyfieldEngine(EphemerisEngine):
|
||||
|
||||
Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo.
|
||||
"""
|
||||
from app.engine.houses import mean_obliquity
|
||||
|
||||
t = self.ts.from_datetime(moment.when_utc)
|
||||
ramc = norm360(t.gast * 15.0 + moment.lon)
|
||||
eps = mean_obliquity(t.tt)
|
||||
return ramc, eps
|
||||
return ramc, true_obliquity(t)
|
||||
|
||||
def health(self) -> dict:
|
||||
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
|
||||
|
||||
|
||||
def true_obliquity(t) -> float:
|
||||
"""ε PRAWDZIWE (średnie + nutacja w nachyleniu), w stopniach.
|
||||
|
||||
MUSI być prawdziwe, nie średnie. RAMC liczymy z `t.gast` — czasu gwiazdowego
|
||||
POZORNEGO, mierzonego od równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej
|
||||
samej równonocy ma ε z nutacją; sparowanie GAST z ε średnim miesza dwa układy
|
||||
odniesienia. Kosztowało to ~3,2″ na cuspach domów i było niewidoczne dla
|
||||
frameworka wyroczni, bo ten z założenia podaje to samo ε obu stronom
|
||||
(izoluje samą funkcję domów) — patrz tests/oracle/README.md.
|
||||
|
||||
Nutacja z serii IAU 2000A, czyli z tego samego źródła, którego Skyfield
|
||||
używa do policzenia GAST — dzięki temu oba są spójne z definicji."""
|
||||
from skyfield.nutationlib import iau2000a, mean_obliquity
|
||||
|
||||
mean_arcsec = float(mean_obliquity(t.tdb))
|
||||
d_eps_arcsec = float(iau2000a(t.tt)[1]) * 1e-7 # jednostki 0,1 µas
|
||||
return (mean_arcsec + d_eps_arcsec) / 3600.0
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Zbiorcza tabela dat z technik (LOG-14).
|
||||
|
||||
Spina w jedną, posortowaną oś czasu daty z kilku technik:
|
||||
- profekcje roczne (LOG-10) — rok życia,
|
||||
- Solar Return (LOG-12) — moment powrotu Słońca,
|
||||
- dyrekcje solar-arc — daty dokładnych aspektów kierowanych planet do punktów
|
||||
natalnych (wzorzec z notes3: „Profection planet | Aspect | Birth planet |
|
||||
Exact Date"). Klucz łuku konfigurowalny; domyślnie Naiboda (0°59'08"/rok).
|
||||
|
||||
Każdy wiersz ma kształt z notes2: technique | significator | start | exact | end.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from app.engine.aspects import MAJOR, PL_NAME
|
||||
from app.engine.profections import DOMICILE_RULERS, profected_sign
|
||||
from app.engine.returns import find_return
|
||||
|
||||
NAIBOD_KEY = 0.9856472 # °/rok (0°59'08") — domyślny klucz solar-arc
|
||||
DAYS_PER_YEAR = 365.2422
|
||||
DIRECTED = ["Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto"]
|
||||
|
||||
|
||||
def _add_years(birth: datetime, years: float) -> datetime:
|
||||
return birth + timedelta(days=years * DAYS_PER_YEAR)
|
||||
|
||||
|
||||
def _row(technique, significator, start, exact, end) -> dict:
|
||||
def iso(x):
|
||||
return x.date().isoformat() if isinstance(x, datetime) else x
|
||||
return {"technique": technique, "significator": significator,
|
||||
"start": iso(start), "exact": iso(exact), "end": iso(end)}
|
||||
|
||||
|
||||
def solar_arc_directions(
|
||||
natal: dict[str, float], birth: datetime, lo: datetime, hi: datetime,
|
||||
key: float = NAIBOD_KEY, orb_years: float = 1.0,
|
||||
) -> list[dict]:
|
||||
"""Daty dyrekcji solar-arc w oknie [lo, hi].
|
||||
|
||||
natal: nazwa punktu -> długość natalna (planety + Asc/MC). Kierowane są planety
|
||||
(DIRECTED), celem każdy punkt natalny. Aspekt dokładny gdy łuk = odległość
|
||||
kątowa (mod 360). Wiek = łuk/klucz; data = urodziny + wiek.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
lo_age = (lo - birth).days / DAYS_PER_YEAR - orb_years
|
||||
hi_age = (hi - birth).days / DAYS_PER_YEAR + orb_years
|
||||
for p in DIRECTED:
|
||||
if p not in natal:
|
||||
continue
|
||||
for q, q_lon in natal.items():
|
||||
for asp, angle in MAJOR.items():
|
||||
for target in ({angle, (360.0 - angle) % 360.0}):
|
||||
arc = (q_lon + target - natal[p]) % 360.0
|
||||
age = arc / key
|
||||
if not (lo_age <= age <= hi_age) or (p == q and arc < 1e-6):
|
||||
continue
|
||||
exact = _add_years(birth, age)
|
||||
row = _row(
|
||||
"solar_arc",
|
||||
f"dyr. {p} {PL_NAME[asp]} {q}",
|
||||
_add_years(birth, age - orb_years),
|
||||
exact,
|
||||
_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
|
||||
|
||||
|
||||
def profection_events(natal_asc: float, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||
"""Lata profekcyjne (LOG-10) nachodzące na okno."""
|
||||
out: list[dict] = []
|
||||
for age in range((lo.year - birth.year) - 1, (hi.year - birth.year) + 1):
|
||||
if age < 0:
|
||||
continue
|
||||
try:
|
||||
start = birth.replace(year=birth.year + age)
|
||||
end = birth.replace(year=birth.year + age + 1)
|
||||
except ValueError: # 29 lutego
|
||||
start = birth.replace(year=birth.year + age, day=28)
|
||||
end = birth.replace(year=birth.year + age + 1, day=28)
|
||||
if end < lo or start > hi:
|
||||
continue
|
||||
sign = profected_sign(natal_asc, age)
|
||||
lord = DOMICILE_RULERS[sign]
|
||||
row = _row(
|
||||
"profection", f"Władca Roku: {lord} (Asc {sign}, wiek {age})",
|
||||
start, start, end,
|
||||
)
|
||||
row.update(lord=lord, sign=sign) # do budowy tokenów (1B->2B)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def solar_return_events(engine, natal_moment, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||
"""Solariusze w oknie (LOG-12) — jeden na rok."""
|
||||
out: list[dict] = []
|
||||
for year in range(lo.year, hi.year + 1):
|
||||
try:
|
||||
around = birth.replace(year=year)
|
||||
except ValueError:
|
||||
around = birth.replace(year=year, day=28)
|
||||
hit = find_return(engine, "solar", natal_moment, around)
|
||||
if hit and lo <= hit <= hi:
|
||||
out.append(_row("solar_return", "Solar Return", hit, hit, _add_years(hit, 1)))
|
||||
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:
|
||||
if isinstance(d, datetime):
|
||||
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
||||
if isinstance(d, date):
|
||||
return datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||
return datetime.fromisoformat(str(d)).replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def build_timeline(
|
||||
engine, natal_moment, natal_points: dict[str, float],
|
||||
from_d, to_d, techniques: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Scala wybrane techniki w jedną oś czasu, posortowaną po dacie dokładnej."""
|
||||
lo, hi = _as_dt(from_d), _as_dt(to_d)
|
||||
birth = natal_moment.when_utc
|
||||
want = set(techniques or ["profection", "solar_return", "solar_arc", "firdaria"])
|
||||
events: list[dict] = []
|
||||
if "profection" in want:
|
||||
events += profection_events(natal_points["Asc"], birth, lo, hi)
|
||||
if "solar_return" in want:
|
||||
events += solar_return_events(engine, natal_moment, birth, lo, hi)
|
||||
if "solar_arc" in want:
|
||||
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"])
|
||||
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,525 @@
|
||||
"""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"
|
||||
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
||||
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
||||
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
||||
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
||||
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",
|
||||
}
|
||||
+375
-3
@@ -4,6 +4,7 @@ W górę: udostępnia API dla warstwy prezentacji.
|
||||
W dół: woła warstwę bazodanową (DataClient).
|
||||
Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
|
||||
"""
|
||||
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
@@ -12,12 +13,17 @@ import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import link_crypto, security
|
||||
from app.clients.data_client import DataClient
|
||||
from app.models import QueryRequest, QueryResponse
|
||||
from app.service import QueryService
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||
service = QueryService()
|
||||
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
||||
# 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 ---
|
||||
_engine = None
|
||||
@@ -37,8 +43,14 @@ class PositionsRequest(BaseModel):
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
objects: list[str] | None = None
|
||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry (PRYMARNY — pod kosmogram)
|
||||
house_systems: list[str] | None = None # PRE-05: dodatkowe systemy do porównania obok
|
||||
aspect_orb: float = 8.0 # PRE-06: orb aspektów (konfigurowalny)
|
||||
aspect_luminary_bonus: float = 2.0 # PRE-06: dodatkowy orb dla Słońca/Księżyca
|
||||
aspect_minor: bool = False # PRE-06: dołóż aspekty poboczne (30/45/150°)
|
||||
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)
|
||||
@@ -58,7 +70,14 @@ def chart_positions(req: PositionsRequest) -> dict:
|
||||
|
||||
engine = get_engine()
|
||||
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,
|
||||
house_systems=req.house_systems,
|
||||
aspect_orb=req.aspect_orb,
|
||||
aspect_luminary_bonus=req.aspect_luminary_bonus,
|
||||
aspect_minor=req.aspect_minor)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
if req.stations:
|
||||
from app.engine.stations import find_stations
|
||||
|
||||
@@ -66,9 +85,67 @@ def chart_positions(req: PositionsRequest) -> dict:
|
||||
st = find_stations(engine, moment, p["name"])
|
||||
if st:
|
||||
p["stations"] = st
|
||||
if req.tables:
|
||||
from app.engine.tables import build_tables
|
||||
|
||||
chart["tables"] = build_tables(engine, moment, chart)
|
||||
return chart
|
||||
|
||||
|
||||
class PersonMoment(BaseModel):
|
||||
when_utc: datetime
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
|
||||
|
||||
class SynastryRequest(BaseModel):
|
||||
"""Dwie osoby (PRE-04). Aspekty liczone MIĘDZY horoskopami, nie w środku."""
|
||||
person_a: PersonMoment
|
||||
person_b: PersonMoment
|
||||
zodiac: str = "tropical"
|
||||
aspect_orb: float = 8.0
|
||||
aspect_luminary_bonus: float = 2.0
|
||||
aspect_minor: bool = False
|
||||
|
||||
|
||||
@app.get("/bases")
|
||||
def bases() -> dict:
|
||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||
|
||||
Przelot do warstwy danych — same METADANE, żadnej treści baz."""
|
||||
from app.clients.data_client import DataClient
|
||||
|
||||
try:
|
||||
return DataClient().bases()
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||
|
||||
|
||||
@app.post("/chart/synastry")
|
||||
def chart_synastry(req: SynastryRequest) -> dict:
|
||||
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
|
||||
osoby A vs planeta osoby B). Bez interpretacji z bazy — sama siatka aspektów."""
|
||||
from app.engine import glyphs as GL
|
||||
from app.engine.aspects import find_cross_aspects
|
||||
from app.engine.chart import build_chart
|
||||
from app.engine.models import ChartMoment
|
||||
|
||||
engine = get_engine()
|
||||
a = build_chart(engine, ChartMoment(when_utc=req.person_a.when_utc,
|
||||
lat=req.person_a.lat, lon=req.person_a.lon), zodiac=req.zodiac)
|
||||
b = build_chart(engine, ChartMoment(when_utc=req.person_b.when_utc,
|
||||
lat=req.person_b.lat, lon=req.person_b.lon), zodiac=req.zodiac)
|
||||
cross = find_cross_aspects(
|
||||
a["positions"], b["positions"], orb=req.aspect_orb,
|
||||
luminary_bonus=req.aspect_luminary_bonus, minor=req.aspect_minor)
|
||||
for c in cross:
|
||||
c["glyph"] = GL.aspect_glyph(c["aspect"]) # symbol aspektu (LOG-22)
|
||||
return {"engine": engine.name,
|
||||
"person_a": {"positions": a["positions"]},
|
||||
"person_b": {"positions": b["positions"]},
|
||||
"aspects": cross}
|
||||
|
||||
|
||||
@app.post("/chart/compare")
|
||||
def chart_compare(req: PositionsRequest) -> dict:
|
||||
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
|
||||
@@ -110,13 +187,248 @@ def chart_report(req: ReportRequest) -> dict:
|
||||
try:
|
||||
report = build_report(
|
||||
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:
|
||||
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
||||
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):
|
||||
when_utc: datetime # moment urodzenia (UTC)
|
||||
lat: float = 0.0
|
||||
@@ -173,6 +485,66 @@ def chart_return(req: ReturnRequest) -> dict:
|
||||
"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):
|
||||
when_utc: datetime # moment urodzenia (UTC)
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
from_date: str # zakres: YYYY-MM-DD
|
||||
to_date: str
|
||||
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
||||
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
|
||||
|
||||
|
||||
@app.post("/chart/timeline")
|
||||
def chart_timeline(req: TimelineRequest) -> dict:
|
||||
"""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.models import ChartMoment
|
||||
from app.engine.timeline import build_timeline
|
||||
|
||||
engine = get_engine()
|
||||
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||
ramc, eps = engine.sidereal(natal)
|
||||
points = {"Asc": H.compute_asc(ramc, eps, natal.lat), "MC": H.compute_mc(ramc, eps)}
|
||||
for p in engine.positions(natal):
|
||||
points[p.name] = p.longitude
|
||||
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
|
||||
|
||||
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")
|
||||
def health() -> dict:
|
||||
info = {"status": "ok", "layer": "logic"}
|
||||
|
||||
@@ -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,357 @@
|
||||
"""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))
|
||||
|
||||
# Model interpretuje domy, więc musi wiedzieć, że podział pochodzi z innego
|
||||
# systemu, niż zamówiono — inaczej napisze „Twój Placidus" o Porphyrym.
|
||||
for w in chart.get("house_warnings") or []:
|
||||
lines.append(f"\n{w}")
|
||||
|
||||
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",
|
||||
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [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))
|
||||
|
||||
@@ -112,17 +114,24 @@ def build_report(
|
||||
positions: list[dict],
|
||||
data: DataSource,
|
||||
aspects: list[dict] | None = None,
|
||||
parallels: list[dict] | None = None,
|
||||
per_object_limit: int = 5000,
|
||||
group: bool = False,
|
||||
) -> dict:
|
||||
"""positions: pozycje z build_chart (name, sign, direction, house).
|
||||
|
||||
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
|
||||
aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty
|
||||
(ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety.
|
||||
Dla każdego obiektu fasety: „w znaku", „w domu", „w aspekcie" (dla każdego
|
||||
aspektu głównego z listy `aspects`) oraz „paralela deklinacji" (dla paraleli
|
||||
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
|
||||
|
||||
# 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] = []
|
||||
provider = None
|
||||
for p in positions:
|
||||
@@ -178,6 +187,25 @@ def build_report(
|
||||
"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
|
||||
for f in facets:
|
||||
f["score"] = score_facet(f)
|
||||
@@ -195,3 +223,61 @@ def build_report(
|
||||
"facets": facets,
|
||||
})
|
||||
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
|
||||
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
|
||||
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
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -28,4 +29,8 @@ def own_engine():
|
||||
try:
|
||||
return SkyfieldEngine()
|
||||
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}")
|
||||
|
||||
@@ -1,5 +1,62 @@
|
||||
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
||||
from app.engine.aspects import find_aspects, separation
|
||||
from app.engine.aspects import RIGID_PAIRS, find_aspects, find_cross_aspects, separation
|
||||
|
||||
|
||||
# ── synastria: aspekty MIĘDZY dwoma horoskopami (PRE-04) ──
|
||||
|
||||
def test_cross_aspects_between_two_charts():
|
||||
a = [{"name": "Sun", "decimal": 10.0}, {"name": "Moon", "decimal": 100.0}]
|
||||
b = [{"name": "Sun", "decimal": 12.0}, {"name": "Mars", "decimal": 70.0}]
|
||||
pairs = {(c["obj1"], c["obj2"], c["aspect"]) for c in find_cross_aspects(a, b)}
|
||||
assert ("Sun", "Sun", "conjunction") in pairs # A.Słońce ↔ B.Słońce (2°)
|
||||
assert ("Sun", "Mars", "sextile") in pairs # A.Słońce(10) ↔ B.Mars(70) = 60°
|
||||
|
||||
|
||||
def test_cross_aspect_obj1_is_person_a_obj2_person_b():
|
||||
a = [{"name": "Venus", "decimal": 10.0}]
|
||||
b = [{"name": "Mars", "decimal": 12.0}]
|
||||
c = find_cross_aspects(a, b)[0]
|
||||
assert c["obj1"] == "Venus" and c["obj2"] == "Mars"
|
||||
|
||||
|
||||
def test_cross_aspects_do_not_apply_rigid_pair_filter():
|
||||
"""NN osoby A vs SN osoby B to REALNY aspekt między ludźmi — nie wycinamy go
|
||||
jak pary sztywnej w jednym horoskopie."""
|
||||
a = [{"name": "North Node", "decimal": 10.0}]
|
||||
b = [{"name": "South Node", "decimal": 12.0}]
|
||||
assert find_cross_aspects(a, b) # niepuste
|
||||
|
||||
|
||||
def test_cross_aspects_no_applying_flag():
|
||||
"""Dwa natale — brak wspólnego czasu, więc bez applying/separating."""
|
||||
a = [{"name": "Sun", "decimal": 10.0, "speed": 1.0}]
|
||||
b = [{"name": "Moon", "decimal": 12.0, "speed": 13.0}]
|
||||
assert "as" not in find_cross_aspects(a, b)[0]
|
||||
|
||||
|
||||
# ── konfigurowalne aspekty i orby (PRE-06) ──
|
||||
|
||||
def test_minor_aspects_only_with_flag():
|
||||
"""Domyślnie tylko główne; `minor=True` dokłada poboczne (30/45/150°)."""
|
||||
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 40.0}] # 30° = półsekstyl
|
||||
assert find_aspects(pos) == [] # główne: brak
|
||||
assert [a["aspect"] for a in find_aspects(pos, minor=True)] == ["semisextile"]
|
||||
|
||||
|
||||
def test_orb_is_configurable():
|
||||
"""Węższy orb odsiewa aspekt, który szerszy łapie."""
|
||||
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 78.0}] # 68° = sextile dev 8°
|
||||
assert find_aspects(pos, orb=5.0) == []
|
||||
assert [a["aspect"] for a in find_aspects(pos, orb=10.0)] == ["sextile"]
|
||||
|
||||
|
||||
def test_luminary_bonus_widens_orb_for_lights():
|
||||
"""Bonus dolicza orb tylko gdy w parze jest Słońce/Księżyc."""
|
||||
lights = [{"name": "Sun", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}] # dev 9° od sextile
|
||||
assert find_aspects(lights, orb=8.0, luminary_bonus=0.0) == [] # 9 > 8
|
||||
assert [a["aspect"] for a in find_aspects(lights, orb=8.0, luminary_bonus=2.0)] == ["sextile"] # 9 < 10
|
||||
plain = [{"name": "Venus", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}]
|
||||
assert find_aspects(plain, orb=8.0, luminary_bonus=2.0) == [] # brak światła → bez bonusu
|
||||
|
||||
|
||||
def test_separation_wraparound():
|
||||
@@ -55,6 +112,28 @@ def test_separating_when_moving_apart():
|
||||
assert a["applying"] is False and a["as"] == "S"
|
||||
|
||||
|
||||
def test_rigid_pair_nodes_not_reported():
|
||||
# SN = NN + 180° z definicji — trywialna opozycja, nie aspekt
|
||||
pos = [
|
||||
{"name": "North Node", "decimal": 42.0, "speed": -0.053},
|
||||
{"name": "South Node", "decimal": 222.0, "speed": -0.053},
|
||||
{"name": "Sun", "decimal": 42.5, "speed": 0.96},
|
||||
]
|
||||
pairs = {frozenset((a["obj1"], a["obj2"])) for a in find_aspects(pos)}
|
||||
assert frozenset({"North Node", "South Node"}) not in pairs
|
||||
# aspekty węzłów do innych obiektów zostają nietknięte
|
||||
assert frozenset({"Sun", "North Node"}) in pairs
|
||||
assert frozenset({"Sun", "South Node"}) in pairs
|
||||
|
||||
|
||||
def test_no_rigid_pairs_in_real_chart(own_engine, reference_moment):
|
||||
from app.engine.chart import build_chart
|
||||
|
||||
chart = build_chart(own_engine, reference_moment)
|
||||
found = [a for a in chart["aspects"] if frozenset((a["obj1"], a["obj2"])) in RIGID_PAIRS]
|
||||
assert not found, f"trywialne aspekty par sztywnych w horoskopie: {found}"
|
||||
|
||||
|
||||
def test_no_as_flag_without_speeds():
|
||||
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||
assert "as" not in find_aspects(pos)[0]
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
Waliduje względem astro.com dla horoskopu referencyjnego.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.engine import houses as H
|
||||
from app.engine.chart import build_chart
|
||||
|
||||
|
||||
@@ -25,3 +28,67 @@ def test_house_systems_available(own_engine, reference_moment):
|
||||
chart = build_chart(own_engine, reference_moment, system)
|
||||
assert chart["house_system"] == system
|
||||
assert len(chart["cusps"]) == 12
|
||||
|
||||
|
||||
# ── wiele systemów domów naraz (PRE-05/LOG-05) ──
|
||||
|
||||
def test_multiple_house_systems_side_by_side(own_engine, reference_moment):
|
||||
"""Prymarny zostaje w cusps/house_system; `house_systems` niesie pełen zestaw
|
||||
do porównania, z prymarnym NA POCZĄTKU i bez duplikatów."""
|
||||
chart = build_chart(own_engine, reference_moment, "whole_sign",
|
||||
house_systems=["equal", "porphyry", "whole_sign"])
|
||||
assert chart["house_system"] == "whole_sign" # prymarny bez zmian
|
||||
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal", "porphyry"]
|
||||
for h in chart["house_systems"]:
|
||||
assert len(h["cusps"]) == 12
|
||||
|
||||
|
||||
def test_single_system_has_no_comparison_block(own_engine, reference_moment):
|
||||
"""Bez dodatkowych systemów nie zaśmiecamy wyniku."""
|
||||
assert "house_systems" not in build_chart(own_engine, reference_moment, "whole_sign")
|
||||
|
||||
|
||||
def test_object_gets_a_house_in_every_system(own_engine, reference_moment):
|
||||
"""Sedno porównania: każdy obiekt ma dom w KAŻDYM systemie, a prymarny jest
|
||||
spójny z polem `.house`."""
|
||||
chart = build_chart(own_engine, reference_moment, "whole_sign", house_systems=["porphyry"])
|
||||
sun = next(p for p in chart["positions"] if p["name"] == "Sun")
|
||||
assert set(sun["houses"]) == {"whole_sign", "porphyry"}
|
||||
assert sun["houses"]["whole_sign"] == sun["house"]
|
||||
|
||||
|
||||
def test_primary_always_first_even_if_not_listed(own_engine, reference_moment):
|
||||
chart = build_chart(own_engine, reference_moment, "equal", house_systems=["porphyry"])
|
||||
systems = [h["system"] for h in chart["house_systems"]]
|
||||
assert systems[0] == "equal" and "porphyry" in systems
|
||||
|
||||
|
||||
def test_unknown_extra_system_is_ignored(own_engine, reference_moment):
|
||||
"""Nieznana nazwa systemu jest po prostu pomijana, nie wywala horoskopu.
|
||||
|
||||
(Placidus był tu kiedyś przykładem systemu nieobsługiwanego — od Etapu 2
|
||||
jest liczony, więc przykładem musi być nazwa, której naprawdę nie znamy.)"""
|
||||
chart = build_chart(own_engine, reference_moment, "whole_sign",
|
||||
house_systems=["nie-ma-takiego-systemu", "equal"])
|
||||
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", H.SYSTEMS)
|
||||
def test_every_advertised_system_builds_a_chart(own_engine, reference_moment, system):
|
||||
"""Każdy system z houses.SYSTEMS musi przejść przez build_chart. Pilnuje,
|
||||
żeby rozszerzenie SYSTEMS bez przepięcia chart.py nie dało 500 przy wyborze
|
||||
systemu, który jest na liście, ale nie w użytej funkcji."""
|
||||
chart = build_chart(own_engine, reference_moment, system)
|
||||
assert chart["house_system"] == system
|
||||
assert len(chart["cusps"]) == 12
|
||||
assert all(1 <= p["house"] <= 12 for p in chart["positions"])
|
||||
|
||||
|
||||
def test_multiple_systems_side_by_side_cover_all(own_engine, reference_moment):
|
||||
chart = build_chart(own_engine, reference_moment, H.WHOLE_SIGN,
|
||||
house_systems=list(H.SYSTEMS))
|
||||
blocks = chart["house_systems"]
|
||||
assert blocks[0]["system"] == H.WHOLE_SIGN # prymarny zawsze pierwszy
|
||||
assert {b["system"] for b in blocks} == set(H.SYSTEMS)
|
||||
for p in chart["positions"]:
|
||||
assert set(p["houses"]) == set(H.SYSTEMS)
|
||||
|
||||
@@ -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("") == ""
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine import houses as H
|
||||
|
||||
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
||||
@@ -39,3 +43,306 @@ def test_assign_house_whole_sign():
|
||||
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°)
|
||||
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
||||
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
|
||||
|
||||
|
||||
# ── regresje wykryte przez porównanie z wyrocznią (tests/oracle) ──────────
|
||||
|
||||
def test_ascendant_branch_beyond_polar_circle():
|
||||
"""REGRESJA: powyżej koła podbiegunowego `atan2` wybierał NIEWŁAŚCIWY punkt
|
||||
przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent zamiast Ascendenta,
|
||||
czyli błąd o 180°. Planety lądowały w PRZECIWNYCH domach dla całej północnej
|
||||
Skandynawii. Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze
|
||||
w półkolu (0°,180°) na wschód od MC."""
|
||||
eps = 23.4393
|
||||
for lat in (67.0, 70.0, 85.0, -70.0):
|
||||
for ramc in (60.0, 100.0, 200.0, 270.0, 300.0):
|
||||
asc = H.compute_asc(ramc, eps, lat)
|
||||
mc = H.compute_mc(ramc, eps)
|
||||
offset = (asc - mc) % 360.0
|
||||
assert 0.0 <= offset <= 180.0, (
|
||||
f"Asc poza półkolem wschodzącym: lat={lat} ramc={ramc} offset={offset:.4f}")
|
||||
|
||||
|
||||
def test_whole_sign_is_deterministic_at_sign_boundary():
|
||||
"""REGRESJA: Ascendent o włos od granicy znaku (359,999999999976 vs 1e-10 —
|
||||
ta sama wartość, różne strony) przerzucał cały dom I o 30°. Ten sam horoskop
|
||||
na innej maszynie dawał inny wynik."""
|
||||
below = H.cusps(359.999999999976, 270.0, H.WHOLE_SIGN)
|
||||
above = H.cusps(1e-10, 270.0, H.WHOLE_SIGN)
|
||||
assert below[0] == above[0] == 0.0, (below[0], above[0])
|
||||
|
||||
|
||||
def test_polar_circle_moves_with_obliquity():
|
||||
"""Granica dziedziny Placidusa/Kocha NIE jest stałą 66,56° — zależy od ε,
|
||||
a ε zmienia się z datą. Testy brzegowe muszą ją liczyć per data."""
|
||||
assert H.polar_circle(23.4393) == pytest.approx(66.5607, abs=1e-4) # dziś
|
||||
assert H.polar_circle(23.747) == pytest.approx(66.253, abs=1e-4) # 370 p.n.e.
|
||||
|
||||
|
||||
# ── systemy egzotyczne o zamkniętym wzorze (Etap 1) ──────────────────────
|
||||
# Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które
|
||||
# muszą zachodzić także bez swissepha (czyli w każdym środowisku).
|
||||
|
||||
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus",
|
||||
"topocentric")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", EXOTIC)
|
||||
def test_exotic_returns_twelve_cusps_in_range(system):
|
||||
out = H.cusps_for(100.0, 23.4393, 50.0, system)
|
||||
assert len(out) == 12
|
||||
assert all(0.0 <= c < 360.0 for c in out)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", EXOTIC)
|
||||
def test_opposite_houses_are_antipodal(system):
|
||||
"""Domy przeciwległe leżą na tym samym kole wielkim, więc ich cuspy są
|
||||
dokładnie antypodyczne. Naruszenie tego oznacza błąd w wyborze gałęzi."""
|
||||
out = H.cusps_for(137.0, 23.4393, 42.0, system)
|
||||
for i in range(6):
|
||||
assert abs(((out[i + 6] - out[i]) % 360.0) - 180.0) < 1e-9, f"domy {i+1}/{i+7}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", ("regiomontanus", "campanus", "alcabitus"))
|
||||
def test_quadrant_systems_anchor_on_ascendant(system):
|
||||
"""Systemy kwadrantowe zaczynają dom I na Ascendencie."""
|
||||
ramc, eps, lat = 100.0, 23.4393, 50.0
|
||||
assert H.cusps_for(ramc, eps, lat, system)[0] == pytest.approx(
|
||||
H.compute_asc(ramc, eps, lat), abs=1e-9)
|
||||
|
||||
|
||||
def test_vehlow_puts_ascendant_in_the_middle_of_house_one():
|
||||
ramc, eps, lat = 100.0, 23.4393, 50.0
|
||||
asc = H.compute_asc(ramc, eps, lat)
|
||||
assert H.cusps_for(ramc, eps, lat, "vehlow")[0] == pytest.approx(
|
||||
H.norm360(asc - 15.0), abs=1e-9)
|
||||
|
||||
|
||||
def test_morinus_ignores_latitude():
|
||||
"""Morinus rzutuje równik wprost na ekliptykę, bez horyzontu — jako jedyny
|
||||
nie zależy od szerokości geograficznej."""
|
||||
a = H.cusps_for(100.0, 23.4393, 20.0, "morinus")
|
||||
b = H.cusps_for(100.0, 23.4393, 65.0, "morinus")
|
||||
assert a == pytest.approx(b, abs=1e-12)
|
||||
|
||||
|
||||
def test_unknown_system_is_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
H.cusps_for(100.0, 23.4393, 50.0, "nie-ma-takiego")
|
||||
|
||||
|
||||
# ── systemy łuku dobowego: Placidus i Koch (Etap 2) ──────────────────────
|
||||
|
||||
DIURNAL = ("placidus", "koch")
|
||||
TROMSO_LAT = 69.65 # za kołem podbiegunowym — tam te systemy nie istnieją
|
||||
KRAKOW_LAT = 50.06
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", DIURNAL)
|
||||
def test_diurnal_systems_refuse_beyond_the_polar_circle(system):
|
||||
"""Odmowa, nie liczba. Cicha podmiana systemu jest niewykrywalna z wykresu."""
|
||||
with pytest.raises(H.HouseSystemUndefined):
|
||||
H.cusps_for(100.0, 23.4393, TROMSO_LAT, system)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", DIURNAL)
|
||||
def test_fallback_says_what_it_did(system):
|
||||
cs = H.cusps_detailed(100.0, 23.4393, TROMSO_LAT, system)
|
||||
assert cs.is_fallback and cs.system == H.FALLBACK_SYSTEM and cs.requested == system
|
||||
assert len(cs.cusps) == 12
|
||||
assert cs.notice and system in cs.notice and H.FALLBACK_SYSTEM in cs.notice
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", DIURNAL)
|
||||
def test_no_fallback_and_no_notice_where_the_system_exists(system):
|
||||
cs = H.cusps_detailed(100.0, 23.4393, KRAKOW_LAT, system)
|
||||
assert not cs.is_fallback and cs.notice is None
|
||||
assert cs.cusps == H.cusps_for(100.0, 23.4393, KRAKOW_LAT, system)
|
||||
|
||||
|
||||
def test_placidus_cusps_satisfy_their_own_definition():
|
||||
"""Sprawdzian NIEZALEŻNY od wyroczni: czy znaleziony punkt naprawdę przebył
|
||||
zadany ułamek swojego półłuku. Wyrocznia mówi „zgadza się z swissephem",
|
||||
to mówi „zgadza się z definicją Placidusa" — i działa bez swissepha."""
|
||||
ramc, eps, lat = 212.9986, 23.4393, 66.3144 # tuż pod kołem, najtrudniej
|
||||
eps_rad = math.radians(eps)
|
||||
c = H.cusps_for(ramc, eps, lat, "placidus")
|
||||
for house, (frac, nocturnal) in ((11, (1 / 3, False)), (12, (2 / 3, False)),
|
||||
(2, (2 / 3, True)), (3, (1 / 3, True))):
|
||||
lon = c[house - 1]
|
||||
dec = H._declination_of_ecliptic_lon(lon, eps_rad)
|
||||
ad = H._ascensional_difference(dec, lat)
|
||||
ra = math.degrees(math.atan2(math.sin(math.radians(lon)) * math.cos(eps_rad),
|
||||
math.cos(math.radians(lon))))
|
||||
want = (ramc + 180.0 - frac * (90.0 - ad) if nocturnal
|
||||
else ramc + frac * (90.0 + ad))
|
||||
assert abs((ra - want + 180.0) % 360.0 - 180.0) < 1e-9, f"dom {house}"
|
||||
|
||||
|
||||
def test_koch_reduces_to_equal_spacing_on_the_equator():
|
||||
"""Na równiku półłuk każdego stopnia to dokładnie 90°, więc trójdzielenie
|
||||
czasu daje Ascendenty co 30° czasu gwiazdowego — dobry test zdrowia wzoru."""
|
||||
ramc, eps = 100.0, 23.4393
|
||||
c = H.cusps_for(ramc, eps, 0.0, "koch")
|
||||
for house, offset in ((11, -60.0), (12, -30.0), (2, 30.0), (3, 60.0)):
|
||||
assert c[house - 1] == pytest.approx(
|
||||
H.compute_asc(ramc + offset, eps, 0.0), abs=1e-9), f"dom {house}"
|
||||
|
||||
|
||||
def test_polar_circle_boundary_moves_with_the_date():
|
||||
"""Granica dziedziny NIE jest stałą 66,56° — zależy od ε, czyli od daty."""
|
||||
lat = 66.60
|
||||
assert lat > H.polar_circle(23.4393) # dziś: poza dziedziną
|
||||
assert lat < H.polar_circle(23.3528) # rok 2270: już w dziedzinie
|
||||
with pytest.raises(H.HouseSystemUndefined):
|
||||
H.cusps_for(100.0, 23.4393, lat, "placidus")
|
||||
assert len(H.cusps_for(100.0, 23.3528, lat, "placidus")) == 12
|
||||
|
||||
|
||||
def test_topocentric_cusps_stay_in_order_between_mc_and_ascendant():
|
||||
"""Regresja na to, co topocentrica psuło najdłużej — wybór gałęzi.
|
||||
|
||||
Cuspy 11 i 12 to punkty CIĄGŁEJ rodziny biegnącej od MC do Ascendentu, więc
|
||||
czwórka MC → 11 → 12 → Asc leży na okręgu w kolejności, bez wyprzedzania się.
|
||||
Zły wybór gałęzi przerzuca któryś punkt o 180° i kolejność pęka.
|
||||
|
||||
Kierunek celowo NIE jest ustalony: przy dużych szerokościach domy biegną
|
||||
wstecz (przy φ=−79,55° MC=306,8°, a dom 11 to 291,4°) i to jest poprawne —
|
||||
wyrocznia zwraca tam dokładnie to samo. Sprawdzamy więc, że kolejność trzyma
|
||||
się w JEDNĄ ze stron, a nie że rośnie."""
|
||||
def d(a, b):
|
||||
return H.norm360(b - a)
|
||||
|
||||
for lat in (-66.0, -50.0, -20.0, 0.0, 20.0, 50.06, 66.0):
|
||||
c = H.cusps_for(129.2387, 23.4393, lat, "topocentric")
|
||||
mc, c11, c12, asc = c[9], c[10], c[11], c[0]
|
||||
forward = d(mc, c11) + d(c11, c12) + d(c12, asc)
|
||||
backward = d(c11, mc) + d(c12, c11) + d(asc, c12)
|
||||
assert (abs(forward - d(mc, asc)) < 1e-9
|
||||
or abs(backward - d(asc, mc)) < 1e-9), \
|
||||
f"φ={lat}: MC={mc:.3f} 11={c11:.3f} 12={c12:.3f} Asc={asc:.3f} nie po kolei"
|
||||
|
||||
|
||||
def test_topocentric_refuses_where_its_houses_stop_partitioning_the_circle():
|
||||
"""Topocentric ma granicę INNEGO rodzaju niż Placidus i Koch.
|
||||
|
||||
Same cuspy są poprawne wszędzie (zgodne z wyrocznią co do zera), ale powyżej
|
||||
koła podbiegunowego przestają dzielić okrąg — cusp VII (= I + 180°) wypada
|
||||
przed cuspem VI i domy nachodzą na siebie. Przypisanie planety do domu traci
|
||||
wtedy sens, więc odmawiamy zamiast zwracać liczbę bez znaczenia.
|
||||
|
||||
Próg jest WYPROWADZONY z warunku „dwanaście cuspów sumuje się do 360°",
|
||||
a nie dobrany pod wynik testu — i wypada na kole podbiegunowym."""
|
||||
for lat in (0.0, 50.06, 66.0):
|
||||
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
|
||||
for lat in (69.65, 89.9, -89.9):
|
||||
with pytest.raises(H.HouseSystemUndefined):
|
||||
H.cusps_for(100.0, 23.4393, lat, "topocentric")
|
||||
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
|
||||
assert cs.is_fallback and cs.notice and "topocentric" in cs.notice
|
||||
|
||||
|
||||
def test_whole_sign_aries_ignores_the_ascendant():
|
||||
"""Wariant „od Barana": dom I zaczyna się na 0° Barana niezależnie od tego,
|
||||
co wschodzi. To odróżnia go od zwykłego whole sign."""
|
||||
for lat in (0.0, 50.06, -33.9):
|
||||
c = H.cusps_for(100.0, 23.4393, lat, "whole_sign_aries")
|
||||
assert c == [pytest.approx(30.0 * i) for i in range(12)]
|
||||
|
||||
|
||||
def test_equal_mc_starts_the_tenth_house_exactly_on_the_midheaven():
|
||||
"""W `equal` MC leży GDZIEŚ w domu X; w `equal_mc` zaczyna go dokładnie."""
|
||||
ramc, eps, lat = 100.0, 23.4393, 50.0
|
||||
mc = H.compute_mc(ramc, eps)
|
||||
c = H.cusps_for(ramc, eps, lat, "equal_mc")
|
||||
assert c[9] == pytest.approx(mc, abs=1e-9) # dom X rusza na MC
|
||||
assert c[0] == pytest.approx(H.norm360(mc + 90.0), abs=1e-9)
|
||||
for i in range(12): # nadal równe 30°
|
||||
assert H.norm360(c[(i + 1) % 12] - c[i]) == pytest.approx(30.0, abs=1e-9)
|
||||
|
||||
|
||||
# ── nachylenie ekliptyki: prawdziwe, nie średnie ─────────────────────────
|
||||
|
||||
def test_sidereal_returns_true_obliquity_not_mean():
|
||||
"""RAMC liczymy z GAST — czasu gwiazdowego POZORNEGO, mierzonego od
|
||||
równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej samej równonocy ma ε
|
||||
Z NUTACJĄ. Sparowanie GAST z ε średnim mieszało dwa układy odniesienia
|
||||
i przesuwało cuspy domów o ~3″.
|
||||
|
||||
Framework wyroczni tego NIE wykryje: z założenia podaje to samo ε obu
|
||||
stronom, żeby izolować samą funkcję domów. Dlatego sprawdzian jest tutaj,
|
||||
ze Skyfieldem jako niezależnym autorytetem."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from skyfield.api import load
|
||||
from skyfield.nutationlib import mean_obliquity
|
||||
|
||||
from app.engine.models import ChartMoment
|
||||
from app.engine.skyfield_engine import SkyfieldEngine, true_obliquity
|
||||
|
||||
when = datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc)
|
||||
t = load.timescale().from_datetime(when)
|
||||
_, eps = SkyfieldEngine().sidereal(ChartMoment(when_utc=when, lat=50.06, lon=19.94))
|
||||
|
||||
assert eps == pytest.approx(true_obliquity(t), abs=1e-12)
|
||||
eps_mean = float(mean_obliquity(t.tdb)) / 3600.0
|
||||
assert eps != pytest.approx(eps_mean, abs=1e-9), "ε jest średnie — nutacja zgubiona"
|
||||
# nutacja w nachyleniu nie przekracza ~9,2″; większa różnica to nie nutacja
|
||||
assert abs(eps - eps_mean) * 3600.0 < 9.3
|
||||
|
||||
|
||||
def test_true_obliquity_stays_in_a_sane_band_across_the_date_range():
|
||||
"""ε maleje ~0,013°/wiek. Na całym zakresie programu (370 p.n.e. – 2270 n.e.)
|
||||
musi zostać w widełkach 23,2–23,8° — wyjście oznacza błąd jednostek."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from skyfield.api import load
|
||||
|
||||
from app.engine.skyfield_engine import true_obliquity
|
||||
|
||||
ts = load.timescale()
|
||||
for year in (1600, 1900, 2000, 2100):
|
||||
t = ts.from_datetime(datetime(year, 6, 1, tzinfo=timezone.utc))
|
||||
assert 23.2 < true_obliquity(t) < 23.8, year
|
||||
|
||||
|
||||
# ── przypisanie obiektu do domu ──────────────────────────────────────────
|
||||
|
||||
REVERSED = (108.456, 23.4393, -84.324) # tu domy biegną WSTECZ
|
||||
|
||||
|
||||
def test_cusps_can_run_backwards_at_extreme_latitudes():
|
||||
"""Nie jest to błąd, tylko własność tych systemów — wyrocznia zwraca to samo.
|
||||
Test istnieje po to, żeby nikt tego „nie naprawił"."""
|
||||
ramc, eps, lat = REVERSED
|
||||
c = H.cusps_for(ramc, eps, lat, "regiomontanus")
|
||||
assert c[1] < c[0] and c[2] < c[1], "kolejność miała być odwrócona"
|
||||
assert not H._runs_forward(c)
|
||||
assert H._runs_forward(H.cusps_for(ramc, eps, 50.0, "regiomontanus"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", H.SYSTEMS)
|
||||
def test_every_point_lands_in_exactly_one_house(system):
|
||||
"""Cuspy dzielą okrąg bez luk i bez zakładek, więc każdy punkt ma dokładnie
|
||||
jeden dom, a cusp trafia do domu, który zaczyna. Sprawdzian SAMOSPÓJNOŚCI —
|
||||
działa bez swissepha, także tam, gdzie sama wyrocznia przeczy sobie
|
||||
(topocentric powyżej koła podbiegunowego)."""
|
||||
for ramc, eps, lat in (REVERSED, (100.0, 23.4393, 50.06), (210.33, 23.4393, 72.675)):
|
||||
try:
|
||||
cusps = H.cusps_for(ramc, eps, lat, system)
|
||||
except H.HouseSystemUndefined:
|
||||
continue
|
||||
for i, cusp in enumerate(cusps):
|
||||
assert H.assign_house(cusp, cusps) == i + 1, f"{system}: cusp {i+1}"
|
||||
seen = {H.assign_house(lon / 4.0, cusps) for lon in range(0, 1440)}
|
||||
assert seen == set(range(1, 13)), f"{system}: puste albo zdublowane domy {sorted(seen)}"
|
||||
|
||||
|
||||
def test_assign_house_follows_the_cusps_not_a_fixed_direction():
|
||||
"""Regresja: zaszyte „zawsze do przodu" dawało przy |φ| za kołem podbiegunowym
|
||||
złe domy dla systemów dzielących koła wielkie — mimo cuspów zgodnych
|
||||
z wyrocznią co do zera. Błąd był cichy: wykres wyglądał poprawnie."""
|
||||
ramc, eps, lat = REVERSED
|
||||
cusps = H.cusps_for(ramc, eps, lat, "regiomontanus")
|
||||
# 1,7822° leży między cuspem 6 (4,458°) a 7 (354,228°) — patrząc WSTECZ
|
||||
assert H.assign_house(1.7822, cusps) == 6
|
||||
|
||||
@@ -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_services_share_the_same_module():
|
||||
"""Moduł jest skopiowany do KAŻDEJ usługi, która szyfruje łącze (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", "render")}
|
||||
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"]
|
||||
|
||||
|
||||
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():
|
||||
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
|
||||
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,71 @@
|
||||
"""Zbiorcza oś czasu z technik (LOG-14)."""
|
||||
import datetime as dt
|
||||
|
||||
from app.engine.timeline import (
|
||||
NAIBOD_KEY,
|
||||
build_timeline,
|
||||
profection_events,
|
||||
solar_arc_directions,
|
||||
)
|
||||
|
||||
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||
# natalne długości (z horoskopu referencyjnego)
|
||||
NATAL = {
|
||||
"Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55, "Mercury": 27.38,
|
||||
"Venus": 27.68, "Mars": 234.54, "Jupiter": 282.96, "Saturn": 223.31,
|
||||
"Uranus": 252.81, "Neptune": 271.22, "Pluto": 210.48,
|
||||
}
|
||||
|
||||
|
||||
def _win(y0, y1):
|
||||
return (dt.datetime(y0, 1, 1, tzinfo=dt.timezone.utc),
|
||||
dt.datetime(y1, 12, 31, tzinfo=dt.timezone.utc))
|
||||
|
||||
|
||||
def test_profection_events_in_window():
|
||||
lo, hi = _win(2024, 2026)
|
||||
rows = profection_events(NATAL["Asc"], BIRTH, lo, hi)
|
||||
# rok profekcyjny wiek 42 zaczyna się 30.04.2026 -> Asc Capricorn
|
||||
ages = [r["significator"] for r in rows]
|
||||
assert any("Asc Capricorn" in a and "wiek 42" in a for a in ages)
|
||||
|
||||
|
||||
def test_solar_arc_exact_matches_arc_over_key():
|
||||
lo, hi = _win(2020, 2030)
|
||||
rows = solar_arc_directions(NATAL, BIRTH, lo, hi, key=NAIBOD_KEY)
|
||||
assert rows, "brak dyrekcji w oknie"
|
||||
# dla każdej dyrekcji: łuk = (wiek * klucz), a data = urodziny + wiek -> spójne
|
||||
for r in rows[:5]:
|
||||
exact = dt.date.fromisoformat(r["exact"])
|
||||
age_years = (dt.datetime(exact.year, exact.month, exact.day, tzinfo=dt.timezone.utc)
|
||||
- BIRTH).days / 365.2422
|
||||
assert 36 <= age_years <= 47 # okno 2020-2030 = wiek ~36-46
|
||||
assert r["technique"] == "solar_arc" and "dyr." in r["significator"]
|
||||
|
||||
|
||||
def test_directed_sun_conjunct_natal_mc_date():
|
||||
# Sun natal 40.14 -> MC natal 352.59: łuk koniunkcji = (352.59-40.14)%360 = 312.45
|
||||
# to > lifespan przy Naibod (~317 lat) -> NIE powinno być w oknie życia
|
||||
lo, hi = _win(1984, 2084)
|
||||
rows = solar_arc_directions(NATAL, BIRTH, lo, hi)
|
||||
sun_mc = [r for r in rows if r["significator"] == "dyr. Sun koniunkcja MC"]
|
||||
assert not sun_mc # łuk 312° = poza życiem
|
||||
|
||||
|
||||
def test_build_timeline_sorted_and_merged():
|
||||
events = build_timeline(_FakeEngine(), _FakeNatal(), NATAL,
|
||||
"2025-01-01", "2027-01-01",
|
||||
techniques=["profection", "solar_arc"])
|
||||
assert events
|
||||
dates = [e["exact"] for e in events]
|
||||
assert dates == sorted(dates) # posortowane po dacie dokładnej
|
||||
techs = {e["technique"] for e in events}
|
||||
assert "profection" in techs and "solar_arc" in techs
|
||||
|
||||
|
||||
class _FakeNatal:
|
||||
when_utc = BIRTH
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
"""Silnik-atrapa — build_timeline z solar_return by go użył, tu go pomijamy."""
|
||||
@@ -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
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Magazyn kont zarządzanych z aplikacji (PRE-27).
|
||||
|
||||
KONTO ADMINISTRACYJNE NIE LEŻY TUTAJ. Pochodzi z konfiguracji środowiska
|
||||
(APP_USER/APP_PASSWORD albo APP_USERS) i dlatego NIE DA SIĘ go skasować ani
|
||||
ograniczyć z poziomu ekranu — nawet przez pomyłkę, nawet spreparowanym żądaniem.
|
||||
Ekran zarządzania kontami operuje wyłącznie na tym pliku.
|
||||
|
||||
Format: JSON `{"users": {"login": {"secret": "scrypt$...", "features": [...],
|
||||
"note": "...", "created": "ISO"}}}`. Hasła WYŁĄCZNIE jako hash scrypt — ten sam
|
||||
mechanizm, co dla kont z APP_USERS (security.hash_password), więc nie ma tu
|
||||
drugiej implementacji do rozjechania.
|
||||
|
||||
Zapis jest ATOMOWY (plik tymczasowy + os.replace) i pod blokadą procesu. Plik
|
||||
leży na NFS, dzieli go kilka replik, a zwykły zapis „w miejscu" po przerwaniu
|
||||
zostawiłby plik obcięty — czyli utratę WSZYSTKICH kont naraz. os.replace jest
|
||||
w obrębie jednego katalogu operacją niepodzielną.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app import features
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def store_path() -> str:
|
||||
"""Ścieżka pliku kont.
|
||||
|
||||
Domyślnie WŁASNY podkatalog prezentacji na NFS, nie katalog z bazami:
|
||||
prezentacja nie ma powodu widzieć baz interpretacyjnych, a zamontowanie ich
|
||||
tutaj obeszłoby bokiem zamknięcie dostępu z DAN-25."""
|
||||
return os.getenv("ACCOUNTS_FILE", "/app/state/accounts.json")
|
||||
|
||||
|
||||
def _read() -> dict:
|
||||
try:
|
||||
with open(store_path(), encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {"users": {}}
|
||||
users = data.get("users")
|
||||
return {"users": users if isinstance(users, dict) else {}}
|
||||
|
||||
|
||||
def _write(data: dict) -> None:
|
||||
path = store_path()
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
# Plik tymczasowy MUSI powstać w tym samym katalogu — os.replace jest
|
||||
# niepodzielne tylko w obrębie jednego systemu plików.
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def all_users() -> dict[str, dict]:
|
||||
"""Konta zarządzane: login → {features, note, created}. BEZ sekretów.
|
||||
|
||||
Sekret nie wychodzi z tego modułu — ekran nigdy go nie potrzebuje, a to, czego
|
||||
nie ma w kontekście szablonu, nie wycieknie do HTML-a przez pomyłkę."""
|
||||
out = {}
|
||||
for login, row in _read()["users"].items():
|
||||
out[login] = {
|
||||
"features": sorted(features.normalise(row.get("features"))),
|
||||
"note": str(row.get("note") or ""),
|
||||
"created": str(row.get("created") or ""),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def secret_of(login: str) -> str | None:
|
||||
return (_read()["users"].get(login) or {}).get("secret")
|
||||
|
||||
|
||||
def permissions_of(login: str) -> frozenset[str]:
|
||||
return features.normalise((_read()["users"].get(login) or {}).get("features"))
|
||||
|
||||
|
||||
def exists(login: str) -> bool:
|
||||
return login in _read()["users"]
|
||||
|
||||
|
||||
def create(login: str, password: str, granted, note: str = "") -> None:
|
||||
"""Zakłada konto. Podnosi ValueError, gdy login zajęty albo dane puste."""
|
||||
from app.security import hash_password
|
||||
|
||||
login = (login or "").strip()
|
||||
if not login:
|
||||
raise ValueError("Login nie może być pusty.")
|
||||
if not (password or "").strip():
|
||||
raise ValueError("Hasło nie może być puste.")
|
||||
with _lock:
|
||||
data = _read()
|
||||
if login in data["users"]:
|
||||
raise ValueError(f"Konto „{login}” już istnieje.")
|
||||
data["users"][login] = {
|
||||
"secret": hash_password(password),
|
||||
"features": sorted(features.normalise(granted)),
|
||||
"note": (note or "").strip(),
|
||||
"created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
_write(data)
|
||||
|
||||
|
||||
def update(login: str, granted=None, password: str = "", note: str | None = None) -> None:
|
||||
"""Zmiana uprawnień, hasła albo opisu. Puste hasło = bez zmiany hasła."""
|
||||
from app.security import hash_password
|
||||
|
||||
with _lock:
|
||||
data = _read()
|
||||
row = data["users"].get(login)
|
||||
if row is None:
|
||||
raise ValueError(f"Nie ma konta „{login}”.")
|
||||
if granted is not None:
|
||||
row["features"] = sorted(features.normalise(granted))
|
||||
if (password or "").strip():
|
||||
row["secret"] = hash_password(password)
|
||||
if note is not None:
|
||||
row["note"] = note.strip()
|
||||
_write(data)
|
||||
|
||||
|
||||
def delete(login: str) -> None:
|
||||
with _lock:
|
||||
data = _read()
|
||||
if data["users"].pop(login, None) is None:
|
||||
raise ValueError(f"Nie ma konta „{login}”.")
|
||||
_write(data)
|
||||
@@ -0,0 +1,622 @@
|
||||
"""Kosmogram — koło horoskopowe jako SVG (PRE-12).
|
||||
|
||||
Rysujemy po stronie serwera, bez zależności zewnętrznych (spójne z CSP): czysty
|
||||
string SVG wstawiany do strony. Gotowe: pierścień znaków, podział na domy, osie
|
||||
oraz OBIEKTY (glify, stopień w znaku, retrogradacja). Linie aspektów w kolejnym
|
||||
kroku.
|
||||
|
||||
Geometria (konwencja astrologiczna):
|
||||
* Ascendent po LEWEJ (godzina 9), długość ekliptyczna rośnie PRZECIWNIE do
|
||||
ruchu wskazówek zegara — jak na niebie i w każdym programie.
|
||||
* Punkt o długości λ trafia na kąt φ = 180° − (λ − Asc). Zakotwiczenie:
|
||||
λ=Asc → lewo, λ=Asc+90° → dół, λ=Asc+270° (≈MC) → góra. W SVG y rośnie w dół,
|
||||
co ta formuła już uwzględnia (sin dodatni = w dół).
|
||||
|
||||
Dane bierzemy z wyniku /chart/positions (warstwa logiki): `angles` (z długością
|
||||
`decimal`), `cusps` (z `decimal` — dołożone pod ten widok) i `sign_glyphs`
|
||||
(pierścień 12 znaków z glifami — LOG-22). Prezentacja niczego nie liczy sama.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from html import escape
|
||||
|
||||
# ── promienie (viewBox 470×470, środek 235,235) ─────────────────────────
|
||||
# Płótno jest większe od koła CELOWO: margines poza obwodem mieści etykiety osi
|
||||
# (AC/DC/MC/IC). Wcześniej siedziały wewnątrz, w pierścieniu obiektów, i zasłaniały
|
||||
# glify w ciasnych skupiskach — widać było na renderze.
|
||||
_CX = _CY = 235.0
|
||||
_VIEW = 470
|
||||
R_OUT = 210.0 # zewnętrzny okrąg
|
||||
R_AXIS_LABEL = 222.0 # etykiety osi — w marginesie, poza kołem
|
||||
R_ZOD = 178.0 # wewnętrzna krawędź pasa znaków
|
||||
R_SIGN = 194.0 # promień glifów znaków (środek pasa)
|
||||
R_TICK = 172.0 # koniec drobnych podziałek stopni (do wewnątrz od pasa)
|
||||
R_HUB = 48.0 # centralne kółko — tu kończą się szprychy domów i osie
|
||||
R_HNUM = 64.0 # promień numerów domów (tuż przy piaście)
|
||||
R_PLANET = 150.0 # promień glifów obiektów
|
||||
R_LOT = 116.0 # promień glifów Lotów — pod pierścieniem planet (to nie ciała)
|
||||
R_DEG = 130.0 # promień podpisu stopnia (pod glifem)
|
||||
R_MARK = R_ZOD - 7.0 # koniec kreski wskazującej PRAWDZIWĄ pozycję na pasie
|
||||
|
||||
# Minimalny odstęp kątowy między glifami obiektów. O ciasnocie decyduje NIE glif,
|
||||
# tylko podpis stopnia — leży bliżej środka (r=130), gdzie ten sam kąt to mniej
|
||||
# pikseli: 8° daje tam ~18 px, a dwuznakowy podpis z ℞ zajmuje ~16 px. Przy 7°
|
||||
# (pierwsze podejście) podpisy w skupiskach zlewały się — widać było na renderze.
|
||||
# Bez rozsuwania w ogóle ciasne układy (u nas Merkury i Wenus 0,4°!) rysują się
|
||||
# jeden na drugim.
|
||||
MIN_SEP = 8.0
|
||||
|
||||
# muted kolory żywiołów — czytelne na ciemnym tle, spójne z paletą aplikacji
|
||||
_ELEMENT_COLOR = ["#d98a6a", "#8fae7a", "#cfc06a", "#6aa6c9"] # ogień, ziemia, powietrze, woda
|
||||
|
||||
# ── motywy ───────────────────────────────────────────────────────────────
|
||||
# „screen" korzysta ze zmiennych CSS strony, więc koło samo dostraja się do
|
||||
# motywu aplikacji. „print" MUSI mieć konkretne kolory: SVG idzie wtedy do
|
||||
# osobnego konwertera (rsvg → PDF, PRE-24), który o naszym arkuszu nic nie wie
|
||||
# i zmiennych `var(--…)` nie rozwiąże — wyszłyby czarne albo żadne kreski.
|
||||
# Przy okazji odwracamy kontrast: raport drukuje się na białym papierze.
|
||||
# Kolor linii aspektu koduje jego CHARAKTER, jak w klasycznych programach:
|
||||
# niebieski = harmonijny (trygon, sekstyl), czerwony = napięty (kwadratura,
|
||||
# opozycja), zielony = koniunkcja (neutralna, wzmacnia). To pierwszy sygnał, jaki
|
||||
# łapie oko — dwa kolory na środku koła mówią „gdzie łatwo, gdzie tarcie".
|
||||
_ASPECT_COLORS_SCREEN = {
|
||||
"conjunction": "#8fae7a", "sextile": "#6aa6c9", "trine": "#6aa6c9",
|
||||
"square": "#cf6a6a", "opposition": "#cf6a6a",
|
||||
"semisextile": "#6aa6c9", "semisquare": "#cf6a6a", "quincunx": "#cf6a6a",
|
||||
}
|
||||
_ASPECT_COLORS_PRINT = {
|
||||
"conjunction": "#3f6b33", "sextile": "#1f5c80", "trine": "#1f5c80",
|
||||
"square": "#a83232", "opposition": "#a83232",
|
||||
"semisextile": "#1f5c80", "semisquare": "#a83232", "quincunx": "#a83232",
|
||||
}
|
||||
|
||||
_THEMES = {
|
||||
"screen": {"line": "var(--line)", "ink": "var(--ink)", "muted": "var(--muted)",
|
||||
"accent": "var(--accent)", "disc": "#12132a", "retro": "#ff9b6a",
|
||||
"elements": _ELEMENT_COLOR, "aspects": _ASPECT_COLORS_SCREEN},
|
||||
"print": {"line": "#b9bcc9", "ink": "#111318", "muted": "#5d6270",
|
||||
"accent": "#5a49c8", "disc": "#ffffff", "retro": "#b2452a",
|
||||
# ciemniejsze warianty żywiołów — na białym tle jasne byłyby niewidoczne
|
||||
"elements": ["#a8442a", "#3f6b33", "#8a6d10", "#1f5c80"],
|
||||
"aspects": _ASPECT_COLORS_PRINT},
|
||||
}
|
||||
|
||||
|
||||
# Polskie nazwy aspektów — tylko do dymków; obliczenia trzymają nazwy angielskie.
|
||||
_ASPECT_PL = {
|
||||
"conjunction": "koniunkcja", "sextile": "sekstyl", "square": "kwadratura",
|
||||
"trine": "trygon", "opposition": "opozycja", "semisextile": "półsekstyl",
|
||||
"semisquare": "półkwadratura", "quincunx": "kwinkunks",
|
||||
}
|
||||
|
||||
# Glify aspektów (te same znaki co w warstwie logiki: engine/glyphs.py ASPECT).
|
||||
# Trzymamy je lokalnie, bo prezentacja ma już u siebie kolory i polskie nazwy
|
||||
# aspektów — dzięki temu aspektarian jest samowystarczalny i nie zależy od tego,
|
||||
# czy pojedynczy rekord aspektu niesie glif.
|
||||
_ASPECT_GLYPH = {
|
||||
"conjunction": "☌", "opposition": "☍", "trine": "△", "square": "□",
|
||||
"sextile": "⚹", "semisextile": "⚺", "quincunx": "⚻", "semisquare": "∠",
|
||||
}
|
||||
|
||||
|
||||
def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
|
||||
"""Grubość i przezroczystość linii aspektu wg orbu.
|
||||
|
||||
Im ciaśniejszy aspekt, tym mocniejsza linia — bo tym silniej działa. Poniżej
|
||||
1° POGRUBIAMY wyraźnie (wprost z wymagania): najściślejsze aspekty mają się
|
||||
rzucać w oczy. Szerokie, na granicy orbu, ledwo majaczą — żeby nie zamieniać
|
||||
środka koła w plątaninę."""
|
||||
allowed = allowed or 8.0
|
||||
frac = max(0.0, 1.0 - orb / allowed) # 1 = dokładny, 0 = na krawędzi orbu
|
||||
if orb < 1.0:
|
||||
return 2.0, 0.95 # ciasny — pogrubiony
|
||||
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
|
||||
|
||||
|
||||
# Co stoi po lewej stronie koła (LOG-05: „warianty 0°Aries / fixed Asc").
|
||||
ASC_LEFT = "asc"
|
||||
ARIES_LEFT = "aries"
|
||||
ORIENTATIONS = ((ASC_LEFT, "Ascendent po lewej"), (ARIES_LEFT, "0° Barana po lewej"))
|
||||
|
||||
|
||||
def _pt(lon: float, ref: float, r: float) -> tuple[float, float]:
|
||||
"""Punkt na kole dla długości `lon`, przy `ref` po lewej stronie rysunku."""
|
||||
phi = math.radians(180.0 - (lon - ref))
|
||||
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
|
||||
|
||||
|
||||
def _line(x1: float, y1: float, x2: float, y2: float, stroke: str, w: float = 1.0,
|
||||
opacity: float = 1.0, title: str = "") -> str:
|
||||
attrs = (f'x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
|
||||
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"')
|
||||
# <title> jako dziecko daje natywny dymek po najechaniu — bez JS-a.
|
||||
if title:
|
||||
return f'<line {attrs}><title>{escape(title)}</title></line>'
|
||||
return f'<line {attrs}/>'
|
||||
|
||||
|
||||
# Stack fontów z glifami astrologicznymi. Na stronie dostarcza go klasa CSS
|
||||
# `.glyph`, ale w wariancie do druku SVG jedzie do SAMODZIELNEGO konwertera, który
|
||||
# naszego arkusza nie widzi — wtedy trzeba wpisać font wprost do elementu, inaczej
|
||||
# rysunek wychodzi BEZ symboli (konwerter bierze domyślny font, a ten ich nie ma).
|
||||
GLYPH_FONT = ("Apple Symbols, Segoe UI Symbol, Noto Sans Symbols2, "
|
||||
"DejaVu Sans, sans-serif")
|
||||
|
||||
|
||||
def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "",
|
||||
font: str = "", title: str = "", weight: str = "") -> str:
|
||||
c = f' class="{cls}"' if cls else ""
|
||||
f = f' font-family="{font}"' if font else ""
|
||||
w = f' font-weight="{weight}"' if weight else ""
|
||||
t = f"<title>{escape(title)}</title>" if title else "" # dymek po najechaniu
|
||||
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
|
||||
f' text-anchor="middle" dominant-baseline="central"{c}{f}{w}>{t}{escape(s)}</text>')
|
||||
|
||||
|
||||
def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]:
|
||||
"""Rozsuwa kąty tak, by sąsiedzi mieli co najmniej `min_sep`, zachowując
|
||||
KOLEJNOŚĆ obiektów na kole i trzymając je blisko prawdziwych pozycji.
|
||||
|
||||
Metoda: relaksacja po okręgu — każdą zbyt ciasną parę rozpychamy po połowie
|
||||
brakującego odstępu i powtarzamy, aż nic się nie rusza. Symetryczne pchnięcie
|
||||
sprawia, że skupisko rozkłada się wokół swojego środka, zamiast dryfować
|
||||
w jedną stronę.
|
||||
|
||||
Gdy obiektów jest tyle, że nie da się ich rozsunąć (n·min_sep ≥ 360°),
|
||||
rozkładamy je równomiernie — lepiej równo niż w nieskończonej pętli.
|
||||
"""
|
||||
n = len(lons)
|
||||
if n < 2:
|
||||
return list(lons)
|
||||
order = sorted(range(n), key=lambda i: lons[i] % 360.0)
|
||||
a = [lons[i] % 360.0 for i in order]
|
||||
|
||||
if n * min_sep >= 360.0:
|
||||
a = [(a[0] + i * 360.0 / n) % 360.0 for i in range(n)]
|
||||
else:
|
||||
# ROZWIJAMY kąty do osi monotonicznej. To nie kosmetyka: gdy liczyliśmy
|
||||
# odstęp modulo 360, przesunięcie, które przerzuciło obiekt ZA sąsiada,
|
||||
# dawało lukę ~359,9° zamiast ujemnej — algorytm uznawał, że jest luzem,
|
||||
# i kończył z dwoma glifami na sobie (Wenus lądowała na Księżycu).
|
||||
# Na osi rozwiniętej ujemna luka zostaje ujemna i zawsze się ją wyłapie.
|
||||
for i in range(1, n):
|
||||
while a[i] < a[i - 1]:
|
||||
a[i] += 360.0
|
||||
|
||||
for _ in range(passes):
|
||||
moved = False
|
||||
for i in range(n - 1):
|
||||
gap = a[i + 1] - a[i]
|
||||
if gap < min_sep - 1e-9:
|
||||
push = (min_sep - gap) / 2.0
|
||||
a[i] -= push
|
||||
a[i + 1] += push
|
||||
moved = True
|
||||
closing = (a[0] + 360.0) - a[n - 1] # domknięcie okręgu
|
||||
if closing < min_sep - 1e-9:
|
||||
push = (min_sep - closing) / 2.0
|
||||
a[n - 1] -= push
|
||||
a[0] += push
|
||||
moved = True
|
||||
if not moved:
|
||||
break
|
||||
|
||||
out = [0.0] * n
|
||||
for slot, original in enumerate(order):
|
||||
out[original] = a[slot] % 360.0
|
||||
return out
|
||||
|
||||
|
||||
def _delta(a: float, b: float) -> float:
|
||||
"""Najmniejsza różnica kątów w [0,180]."""
|
||||
return abs(((a - b + 180.0) % 360.0) - 180.0)
|
||||
|
||||
|
||||
def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> str:
|
||||
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
|
||||
|
||||
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
|
||||
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
|
||||
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
|
||||
|
||||
orientation — co stoi po LEWEJ stronie koła (LOG-05, warianty rysunku):
|
||||
'asc' — Ascendent (domyślne; tak rysuje większość szkół zachodnich,
|
||||
dom I zawsze zaczyna się w tym samym miejscu rysunku),
|
||||
'aries' — 0° Barana (koło nieruchome względem zodiaku, więc dwa horoskopy
|
||||
da się porównywać „na oko"; osie wypadają za to gdzie indziej).
|
||||
Zmienia się WYŁĄCZNIE obrót rysunku — żadna liczba nie jest przeliczana.
|
||||
"""
|
||||
T = _THEMES.get(theme, _THEMES["screen"])
|
||||
# tylko dla druku — na stronie font podaje CSS (.glyph)
|
||||
glyph_font = GLYPH_FONT if theme == "print" else ""
|
||||
angles = chart.get("angles")
|
||||
cusps = chart.get("cusps")
|
||||
signs = chart.get("sign_glyphs")
|
||||
if not (angles and cusps and signs) or "decimal" not in (cusps[0] if cusps else {}):
|
||||
return ""
|
||||
|
||||
asc = float(angles["Asc"]["decimal"])
|
||||
# Oś obrotu rysunku. Długości ekliptyczne zostają nietknięte — przesuwamy
|
||||
# tylko punkt, który ląduje po lewej stronie koła.
|
||||
ref = 0.0 if orientation == ARIES_LEFT else asc
|
||||
parts: list[str] = []
|
||||
|
||||
# tło i okręgi
|
||||
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_OUT}" fill="{T["disc"]}" stroke="{T["line"]}" stroke-width="1.5"/>')
|
||||
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_ZOD}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
|
||||
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_HUB}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
|
||||
|
||||
# ── linie aspektów (LOG-06) w środku koła ──────────────────────────
|
||||
# Łączą PRAWDZIWE pozycje obiektów (nie rozsunięte glify — aspekt dotyczy
|
||||
# tego, gdzie planeta faktycznie stoi) na okręgu piasty. Rysowane jako
|
||||
# PIERWSZE, pod resztą: obiekty siedzą na R_PLANET=150, daleko od piasty
|
||||
# (R_HUB=48), więc się nie stykają, a osie i szprychy lądują na wierzchu.
|
||||
pos_by_name = {p["name"]: float(p["decimal"])
|
||||
for p in (chart.get("positions") or []) if p.get("decimal") is not None}
|
||||
for asp in (chart.get("aspects") or []):
|
||||
la = pos_by_name.get(asp.get("obj1"))
|
||||
lb = pos_by_name.get(asp.get("obj2"))
|
||||
if la is None or lb is None:
|
||||
continue
|
||||
color = T["aspects"].get(asp.get("aspect"), T["muted"])
|
||||
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
|
||||
ax1, ay1 = _pt(la, ref, R_HUB)
|
||||
bx1, by1 = _pt(lb, ref, R_HUB)
|
||||
tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} '
|
||||
f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°')
|
||||
parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
|
||||
|
||||
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
|
||||
for deg in range(0, 360, 5):
|
||||
r_in = R_TICK if deg % 10 else R_TICK - 4
|
||||
x1, y1 = _pt(deg, ref, R_ZOD)
|
||||
x2, y2 = _pt(deg, ref, r_in)
|
||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
|
||||
|
||||
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
|
||||
for i, sg in enumerate(signs):
|
||||
b = 30.0 * i
|
||||
x1, y1 = _pt(b, ref, R_ZOD)
|
||||
x2, y2 = _pt(b, ref, R_OUT)
|
||||
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
|
||||
gx, gy = _pt(b + 15.0, ref, R_SIGN)
|
||||
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
|
||||
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
|
||||
|
||||
# szprychy domów (od pasa do piasty) + numery domów w środku każdego domu
|
||||
n = len(cusps)
|
||||
for i, c in enumerate(cusps):
|
||||
lon = float(c["decimal"])
|
||||
x1, y1 = _pt(lon, ref, R_ZOD)
|
||||
x2, y2 = _pt(lon, ref, R_HUB)
|
||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
|
||||
nxt = float(cusps[(i + 1) % n]["decimal"])
|
||||
span = (nxt - lon) % 360.0 or 360.0
|
||||
mid = lon + span / 2.0
|
||||
hx, hy = _pt(mid, ref, R_HNUM)
|
||||
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
|
||||
# Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy
|
||||
# są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer;
|
||||
# w systemach kwadratowych ta liczba realnie coś mówi.
|
||||
deg_in_sign = lon % 30.0
|
||||
if deg_in_sign >= 0.5:
|
||||
cx1, cy1 = _pt(lon, ref, R_ZOD - 9)
|
||||
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
|
||||
|
||||
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
|
||||
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
|
||||
lon_a = float(angles[a]["decimal"])
|
||||
ax, ay = _pt(lon_a, ref, R_ZOD)
|
||||
bx, by = _pt(lon_a + 180.0, ref, R_ZOD)
|
||||
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
|
||||
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
|
||||
lax, lay = _pt(lon_a, ref, R_AXIS_LABEL)
|
||||
lbx, lby = _pt(lon_a + 180.0, ref, R_AXIS_LABEL)
|
||||
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
|
||||
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
|
||||
|
||||
# ── obiekty: glify na pozycjach, stopień w znaku, retrogradacja ─────
|
||||
# Glify rozsuwamy, ale kreska na pasie zostaje w PRAWDZIWEJ pozycji —
|
||||
# inaczej wykres kłamałby o tym, gdzie planeta faktycznie stoi. Gdy glif
|
||||
# jest przesunięty, łączymy go z kreską cienką linią odniesienia.
|
||||
objects = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
|
||||
true_lons = [float(p["decimal"]) for p in objects]
|
||||
draw_lons = spread(true_lons)
|
||||
|
||||
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
|
||||
retro = obj.get("direction") == "Rx"
|
||||
|
||||
mx1, my1 = _pt(true_lon, ref, R_ZOD)
|
||||
mx2, my2 = _pt(true_lon, ref, R_MARK)
|
||||
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
|
||||
|
||||
if _delta(draw_lon, true_lon) > 0.4:
|
||||
lx, ly = _pt(draw_lon, ref, R_PLANET + 12)
|
||||
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
|
||||
|
||||
gx, gy = _pt(draw_lon, ref, R_PLANET)
|
||||
tip = obj.get("name") or ""
|
||||
if obj.get("in_sign"):
|
||||
tip += f" · {obj['in_sign']}"
|
||||
if obj.get("house"):
|
||||
tip += f" · dom {obj['house']}"
|
||||
if retro:
|
||||
tip += " · retrogradacja"
|
||||
parts.append(_text(gx, gy, obj.get("glyph") or "", size=16,
|
||||
fill=T["ink"], cls="glyph", font=glyph_font, title=tip))
|
||||
|
||||
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
|
||||
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
|
||||
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
|
||||
dx, dy = _pt(draw_lon, ref, R_DEG)
|
||||
label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
|
||||
parts.append(_text(dx, dy, label, size=8,
|
||||
fill=T["retro"] if retro else T["muted"]))
|
||||
|
||||
# ── Lots (LOG-08): tylko te z glifem (Fortuna ⊗) ──────────────────
|
||||
# Rysowane NA PIASTOWEJ stronie planet (R_LOT < R_PLANET) i akcentem, żeby
|
||||
# od razu było widać, że to punkt wyliczony, nie ciało niebieskie. Bez
|
||||
# rozsuwania — Lotów z glifem jest mało, a i tak siedzą pod pierścieniem planet.
|
||||
for lot in (chart.get("lots") or []):
|
||||
glyph = lot.get("glyph")
|
||||
if not glyph or lot.get("longitude") is None:
|
||||
continue
|
||||
lon = float(lot["longitude"])
|
||||
mx1, my1 = _pt(lon, ref, R_ZOD)
|
||||
mx2, my2 = _pt(lon, ref, R_MARK)
|
||||
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
|
||||
lx, ly = _pt(lon, ref, R_LOT)
|
||||
tip = lot.get("name") or ""
|
||||
if lot.get("in_sign"):
|
||||
tip += f" · {lot['in_sign']}"
|
||||
if lot.get("house"):
|
||||
tip += f" · dom {lot['house']}"
|
||||
parts.append(_text(lx, ly, glyph, size=13, fill=T["accent"],
|
||||
cls="glyph", font=glyph_font, title=tip))
|
||||
|
||||
body = "".join(parts)
|
||||
return (f'<svg class="wheel" viewBox="0 0 {_VIEW} {_VIEW}" role="img" '
|
||||
f'aria-label="Kosmogram — koło horoskopowe" '
|
||||
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
||||
|
||||
|
||||
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
|
||||
ASP_CELL = 30.0 # bok komórki siatki aspektów
|
||||
|
||||
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
|
||||
"""Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG.
|
||||
|
||||
Klasyczny „schodkowy" układ: glify obiektów biegną po przekątnej, a każda
|
||||
komórka POD nią to aspekt między obiektem ze swojego wiersza i ze swojej
|
||||
kolumny — albo pusto, gdy pary nie łączy żaden aspekt (brak też coś mówi).
|
||||
Koło pokazuje GEOMETRIĘ aspektów, aspektarian — ich TABELĘ na jeden rzut oka.
|
||||
|
||||
Kolory i pogrubienie ciasnych aspektów są SPÓJNE z liniami na kole (ten sam
|
||||
motyw i te same barwy). Zwraca '' przy mniej niż dwóch obiektach.
|
||||
|
||||
theme='print' — konkretne kolory + font glifów wprost w elementach, bo SVG
|
||||
trafia wtedy do samodzielnego konwertera (PDF), który arkusza CSS nie widzi.
|
||||
"""
|
||||
T = _THEMES.get(theme, _THEMES["screen"])
|
||||
glyph_font = GLYPH_FONT if theme == "print" else ""
|
||||
|
||||
objects = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
|
||||
n = len(objects)
|
||||
if n < 2:
|
||||
return ""
|
||||
|
||||
# aspekt po parze NAZW — kolejność obiektów w parze nie ma znaczenia
|
||||
by_pair: dict = {}
|
||||
for a in (chart.get("aspects") or []):
|
||||
by_pair[frozenset((a.get("obj1"), a.get("obj2")))] = a
|
||||
|
||||
S = ASP_CELL
|
||||
pad = 1.0
|
||||
dim = n * S + 2 * pad
|
||||
parts: list[str] = []
|
||||
|
||||
for i, oi in enumerate(objects):
|
||||
# przekątna: glif obiektu na delikatnym tle — kręgosłup siatki
|
||||
dx = pad + i * S
|
||||
dy = pad + i * S
|
||||
parts.append(
|
||||
f'<rect x="{dx:.1f}" y="{dy:.1f}" width="{S}" height="{S}" '
|
||||
f'fill="{T["accent"]}" opacity="0.14" stroke="{T["line"]}" stroke-width="0.6"/>')
|
||||
dtip = oi.get("name") or ""
|
||||
if oi.get("in_sign"):
|
||||
dtip += f" · {oi['in_sign']}"
|
||||
parts.append(_text(dx + S / 2, dy + S / 2, oi.get("glyph") or "", size=15,
|
||||
fill=T["ink"], cls="glyph", font=glyph_font, title=dtip))
|
||||
|
||||
# komórki aspektów w tym wierszu: kolumny j < i (dolny-lewy trójkąt)
|
||||
for j in range(i):
|
||||
oj = objects[j]
|
||||
cx = pad + j * S
|
||||
cy = pad + i * S
|
||||
parts.append(
|
||||
f'<rect x="{cx:.1f}" y="{cy:.1f}" width="{S}" height="{S}" '
|
||||
f'fill="none" stroke="{T["line"]}" stroke-width="0.6"/>')
|
||||
asp = by_pair.get(frozenset((oi.get("name"), oj.get("name"))))
|
||||
if not asp:
|
||||
continue
|
||||
name = asp.get("aspect")
|
||||
glyph = _ASPECT_GLYPH.get(name)
|
||||
if not glyph:
|
||||
continue
|
||||
orb = float(asp.get("orb", 0.0))
|
||||
tight = orb < 1.0 # jak na kole: ciasny = wyróżniony
|
||||
color = T["aspects"].get(name, T["muted"])
|
||||
tip = (f'{oi.get("name")} {_ASPECT_PL.get(name, name)} {oj.get("name")} · '
|
||||
f'orb {orb:.2f}°')
|
||||
if asp.get("as"):
|
||||
tip += " · " + ("aplikacyjny" if asp["as"] == "A" else "separacyjny")
|
||||
parts.append(_text(cx + S / 2, cy + S / 2, glyph, size=17 if tight else 14,
|
||||
fill=color, cls="glyph", font=glyph_font, title=tip,
|
||||
weight="bold" if tight else ""))
|
||||
|
||||
body = "".join(parts)
|
||||
return (f'<svg class="aspectarian" viewBox="0 0 {dim:.1f} {dim:.1f}" role="img" '
|
||||
f'aria-label="Aspektarian — siatka aspektów obiekt na obiekt" '
|
||||
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
||||
|
||||
|
||||
# ── deklinacja i antyscja (LOG-07 → kosmogram etap 6) ─────────────────────
|
||||
# Polskie nazwy zjawisk pozazodiakalnych — tylko do dymków.
|
||||
_OOZ_PL = {
|
||||
"parallel": "paralela", "contraparallel": "kontrparalela",
|
||||
"antiscion": "antyscja", "contra_antiscion": "kontrantyscja",
|
||||
}
|
||||
|
||||
|
||||
def _dashed(x1: float, y1: float, x2: float, y2: float, stroke: str,
|
||||
w: float = 0.8, opacity: float = 0.9, dash: str = "4 3") -> str:
|
||||
return (f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
|
||||
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}" '
|
||||
f'stroke-dasharray="{dash}"/>')
|
||||
|
||||
|
||||
_DECL_W, _DECL_H = 470.0, 300.0
|
||||
_DPX0, _DPX1 = 54.0, 452.0 # obszar rysunku w poziomie
|
||||
_DPY0, _DPY1 = 30.0, 262.0 # góra (+Rmax) i dół (−Rmax)
|
||||
|
||||
|
||||
def render_declination(chart: dict, theme: str = "screen") -> str:
|
||||
"""Wykres deklinacji (LOG-07, etap 6). Pionowa skala deklinacji: obiekty na
|
||||
tej samej wysokości są w PARALELI (działa jak koniunkcja poza ekliptyką), na
|
||||
lustrzanych względem równika — w KONTRPARALELI (jak opozycja). Zwrotniki (±ε)
|
||||
to granica „poza zakresem" (OOB): ciało za nimi ma większą deklinację niż
|
||||
kiedykolwiek Słońce. Kolory łączników jak na kole. '' przy < 2 obiektach."""
|
||||
T = _THEMES.get(theme, _THEMES["screen"])
|
||||
glyph_font = GLYPH_FONT if theme == "print" else ""
|
||||
|
||||
objs = [p for p in (chart.get("positions") or []) if p.get("declination") is not None]
|
||||
if len(objs) < 2:
|
||||
return ""
|
||||
eps = float(chart.get("obliquity") or 23.4367)
|
||||
maxabs = max(abs(float(p["declination"])) for p in objs)
|
||||
Rmax = max(eps + 3.0, maxabs + 2.0) # skala mieści zwrotniki i OOB
|
||||
|
||||
def y_of(dec: float) -> float:
|
||||
return _DPY0 + (Rmax - dec) / (2.0 * Rmax) * (_DPY1 - _DPY0)
|
||||
|
||||
parts: list[str] = []
|
||||
# strefy OOB (za zwrotnikami) — delikatne tło
|
||||
yN, yS = y_of(eps), y_of(-eps)
|
||||
parts.append(f'<rect x="{_DPX0:.1f}" y="{_DPY0:.1f}" width="{_DPX1 - _DPX0:.1f}" '
|
||||
f'height="{yN - _DPY0:.1f}" fill="{T["retro"]}" opacity="0.07"/>')
|
||||
parts.append(f'<rect x="{_DPX0:.1f}" y="{yS:.1f}" width="{_DPX1 - _DPX0:.1f}" '
|
||||
f'height="{_DPY1 - yS:.1f}" fill="{T["retro"]}" opacity="0.07"/>')
|
||||
# równik (0°) i zwrotniki (±ε)
|
||||
yeq = y_of(0.0)
|
||||
parts.append(_line(_DPX0, yeq, _DPX1, yeq, T["line"], 1.0, 0.9))
|
||||
parts.append(_text(_DPX0 - 8, yeq, "0°", size=8, fill=T["muted"]))
|
||||
for d, lab in ((eps, f"+{eps:.0f}°"), (-eps, f"−{eps:.0f}°")):
|
||||
yy = y_of(d)
|
||||
parts.append(_dashed(_DPX0, yy, _DPX1, yy, T["accent"], 0.8, 0.8))
|
||||
parts.append(_text(_DPX0 - 8, yy, lab, size=8, fill=T["accent"]))
|
||||
parts.append(_text((_DPX0 + _DPX1) / 2, 16,
|
||||
"deklinacja — poza ±ε (zwrotniki) obiekt jest OOB", size=8, fill=T["muted"]))
|
||||
|
||||
# X równomiernie wg POSORTOWANEJ deklinacji — paralele lądują obok siebie
|
||||
order = sorted(range(len(objs)), key=lambda i: float(objs[i]["declination"]))
|
||||
n = len(order)
|
||||
xy: dict = {}
|
||||
for slot, idx in enumerate(order):
|
||||
x = _DPX0 + (slot + 0.5) / n * (_DPX1 - _DPX0)
|
||||
xy[objs[idx]["name"]] = (x, y_of(float(objs[idx]["declination"])))
|
||||
|
||||
# łączniki paralel/kontrparalel (zielony/czerwony — jak koniunkcja/opozycja)
|
||||
for par in (chart.get("parallels") or []):
|
||||
a = xy.get(par.get("obj1")); b = xy.get(par.get("obj2"))
|
||||
if not a or not b:
|
||||
continue
|
||||
kind = par.get("type")
|
||||
color = T["aspects"]["conjunction"] if kind == "parallel" else T["aspects"]["opposition"]
|
||||
orb = float(par.get("orb", 0.0))
|
||||
w, op = _aspect_pen(orb, par.get("allowed"))
|
||||
tip = f'{par.get("obj1")} {_OOZ_PL.get(kind, kind)} {par.get("obj2")} · orb {orb:.2f}°'
|
||||
parts.append(_line(a[0], a[1], b[0], b[1], color, w, op, title=tip))
|
||||
|
||||
# glify na wysokości ich deklinacji + podpis stopnia; OOB wyróżnione
|
||||
for p in objs:
|
||||
x, y = xy[p["name"]]
|
||||
oob = bool(p.get("out_of_bounds"))
|
||||
dec = float(p["declination"])
|
||||
tip = f'{p.get("name")} · dekl. {dec:+.2f}°' + (" · OOB" if oob else "")
|
||||
parts.append(_text(x, y, p.get("glyph") or "", size=15,
|
||||
fill=T["retro"] if oob else T["ink"],
|
||||
cls="glyph", font=glyph_font, title=tip))
|
||||
parts.append(_text(x, y + 12, f"{dec:+.1f}", size=7,
|
||||
fill=T["retro"] if oob else T["muted"]))
|
||||
|
||||
body = "".join(parts)
|
||||
return (f'<svg class="declination" viewBox="0 0 {_DECL_W:.0f} {_DECL_H:.0f}" role="img" '
|
||||
f'aria-label="Wykres deklinacji — paralele i out-of-bounds" '
|
||||
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
||||
|
||||
|
||||
_ANT_W, _ANT_H = 470.0, 150.0
|
||||
_ANT_X0, _ANT_X1 = 22.0, 448.0
|
||||
_ANT_Y = 100.0 # oś ekliptyki
|
||||
|
||||
|
||||
def render_antiscia(chart: dict, theme: str = "screen") -> str:
|
||||
"""Oś antyscji (LOG-07, etap 6). Ekliptyka rozwinięta w poziomą oś; pionowo
|
||||
zaznaczona OŚ PRZESILEŃ (0° Raka / 0° Koziorożca) — względem niej odbija się
|
||||
antyscja, a względem osi równonocy (0° Barana / Wagi) — kontrantyscja. Pary
|
||||
połączone łukiem (zielony antyscja / czerwony kontrantyscja). '' przy < 2
|
||||
obiektach."""
|
||||
T = _THEMES.get(theme, _THEMES["screen"])
|
||||
glyph_font = GLYPH_FONT if theme == "print" else ""
|
||||
objs = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
|
||||
if len(objs) < 2:
|
||||
return ""
|
||||
|
||||
def x_of(lon: float) -> float:
|
||||
return _ANT_X0 + (lon % 360.0) / 360.0 * (_ANT_X1 - _ANT_X0)
|
||||
|
||||
parts: list[str] = []
|
||||
parts.append(_line(_ANT_X0, _ANT_Y, _ANT_X1, _ANT_Y, T["line"], 1.0, 0.9))
|
||||
signs = chart.get("sign_glyphs") or []
|
||||
for i in range(12):
|
||||
xb = x_of(i * 30.0)
|
||||
parts.append(_line(xb, _ANT_Y - 5, xb, _ANT_Y + 5, T["line"], 0.6, 0.6))
|
||||
if i < len(signs):
|
||||
parts.append(_text(x_of(i * 30.0 + 15.0), _ANT_Y + 17, signs[i].get("glyph") or "",
|
||||
size=11, fill=T["muted"], cls="glyph", font=glyph_font))
|
||||
# osie odbicia: przesileń (90/270) mocno, równonocy (0/180) delikatnie
|
||||
for lon, col, op in ((90.0, T["accent"], 0.9), (270.0, T["accent"], 0.9),
|
||||
(0.0, T["muted"], 0.5), (180.0, T["muted"], 0.5)):
|
||||
xx = x_of(lon)
|
||||
parts.append(_dashed(xx, _ANT_Y - 44, xx, _ANT_Y + 24, col, 0.9, op))
|
||||
parts.append(_text((_ANT_X0 + _ANT_X1) / 2, 15,
|
||||
"oś przesileń — antyscja · oś równonocy — kontrantyscja",
|
||||
size=8, fill=T["muted"]))
|
||||
|
||||
lon_by = {p["name"]: float(p["decimal"]) for p in objs}
|
||||
for row in (chart.get("antiscia") or []):
|
||||
la = lon_by.get(row.get("obj1")); lb = lon_by.get(row.get("obj2"))
|
||||
if la is None or lb is None:
|
||||
continue
|
||||
kind = row.get("type")
|
||||
color = T["aspects"]["conjunction"] if kind == "antiscion" else T["aspects"]["opposition"]
|
||||
xa, xb = x_of(la), x_of(lb)
|
||||
orb = float(row.get("orb", 0.0))
|
||||
tip = f'{row.get("obj1")} {_OOZ_PL.get(kind, kind)} {row.get("obj2")} · orb {orb:.2f}°'
|
||||
midx = (xa + xb) / 2.0
|
||||
top = _ANT_Y - 24 - abs(xb - xa) * 0.05
|
||||
parts.append(f'<path d="M {xa:.1f} {_ANT_Y - 6:.1f} Q {midx:.1f} {top:.1f} '
|
||||
f'{xb:.1f} {_ANT_Y - 6:.1f}" fill="none" stroke="{color}" '
|
||||
f'stroke-width="1.2" opacity="0.9"><title>{escape(tip)}</title></path>')
|
||||
|
||||
for p in objs:
|
||||
x = x_of(float(p["decimal"]))
|
||||
parts.append(_text(x, _ANT_Y - 12, p.get("glyph") or "", size=12, fill=T["ink"],
|
||||
cls="glyph", font=glyph_font,
|
||||
title=f'{p.get("name")} · {p.get("in_sign", "")}'.strip(" ·")))
|
||||
|
||||
body = "".join(parts)
|
||||
return (f'<svg class="antiscia" viewBox="0 0 {_ANT_W:.0f} {_ANT_H:.0f}" role="img" '
|
||||
f'aria-label="Oś antyscji — odbicia względem przesileń i równonocy" '
|
||||
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
||||
@@ -5,23 +5,44 @@ opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app import link_crypto
|
||||
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:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
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]:
|
||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/api/query", json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
return self._post("/api/query", payload, settings.http_timeout)
|
||||
|
||||
def positions(
|
||||
self,
|
||||
@@ -31,6 +52,12 @@ class LogicClient:
|
||||
objects: list[str] | None = None,
|
||||
house_system: str = "whole_sign",
|
||||
stations: bool = False,
|
||||
zodiac: str = "tropical",
|
||||
tables: bool = False,
|
||||
house_systems: list[str] | None = None,
|
||||
aspect_orb: float = 8.0,
|
||||
aspect_luminary_bonus: float = 2.0,
|
||||
aspect_minor: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||
payload = {
|
||||
@@ -39,20 +66,111 @@ class LogicClient:
|
||||
"lon": lon,
|
||||
"objects": objects,
|
||||
"house_system": house_system,
|
||||
"house_systems": house_systems,
|
||||
"aspect_orb": aspect_orb,
|
||||
"aspect_luminary_bonus": aspect_luminary_bonus,
|
||||
"aspect_minor": aspect_minor,
|
||||
"stations": stations,
|
||||
"zodiac": zodiac,
|
||||
"tables": tables,
|
||||
}
|
||||
# stacje wymagają root-findów — dłuższy timeout
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/chart/positions", json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
|
||||
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
|
||||
return self._post("/chart/positions", payload, timeout)
|
||||
|
||||
def synastry(
|
||||
self, person_a: dict, person_b: dict, zodiac: str = "tropical",
|
||||
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0, aspect_minor: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Synastria dwóch osób (PRE-04) — aspekty między horoskopami."""
|
||||
payload = {
|
||||
"person_a": person_a, "person_b": person_b, "zodiac": zodiac,
|
||||
"aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus,
|
||||
"aspect_minor": aspect_minor,
|
||||
}
|
||||
return self._post("/chart/synastry", payload, settings.http_timeout)
|
||||
|
||||
def report(
|
||||
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
|
||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||
r = client.post(f"{self.base_url}/chart/report", json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
|
||||
|
||||
def prompt(
|
||||
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
|
||||
provider: str | None = None, model: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
|
||||
|
||||
`provider` jest potrzebny dla budżetu „maksymalny kontekst modelu": limit
|
||||
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
|
||||
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 bases(self) -> dict[str, Any]:
|
||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Klient HTTP do usługi render (PRE-24).
|
||||
|
||||
Jedyny punkt styku prezentacji z usługą składającą PDF. Idzie po szyfrowanym
|
||||
łączu, tak jak reszta ruchu wewnątrz klastra — z WŁASNYM, trzecim kluczem
|
||||
(`LINK_KEY_PRESENTATION_RENDER`). Osobny klucz, bo przez to łącze leci CAŁY
|
||||
raport: dane urodzeniowe i opisy z baz. Przejęcie go nie może otwierać łącza do
|
||||
logiki ani do danych.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from app import link_crypto
|
||||
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↔render. Czytany przy każdym wywołaniu, bo
|
||||
konfiguracja może się zmienić bez restartu procesu."""
|
||||
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_RENDER)
|
||||
return link_crypto.Link(key) if key else None
|
||||
|
||||
|
||||
class RenderClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.render_url).rstrip("/")
|
||||
|
||||
def pdf(self, report: dict) -> bytes:
|
||||
"""Raport → bajty PDF. Timeout długi: XeLaTeX na obszernym tekście
|
||||
potrzebuje chwili, a raport bywa na kilkadziesiąt stron."""
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 180.0)) as client:
|
||||
return link_crypto.call(client, "POST", f"{self.base_url}/pdf",
|
||||
payload=report, headers=_auth_headers(),
|
||||
link=_link())
|
||||
|
||||
def health(self) -> dict:
|
||||
"""/health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
||||
nie mają, a nie przechodzi tędy nic z raportu."""
|
||||
import json
|
||||
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return json.loads(r.content)
|
||||
@@ -11,7 +11,23 @@ from dataclasses import dataclass, field
|
||||
@dataclass
|
||||
class Settings:
|
||||
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
|
||||
# Usługa składająca PDF (PRE-24) — osobny komponent, bo dźwiga TeX Live.
|
||||
render_url: str = field(default_factory=lambda: os.getenv("RENDER_URL", "http://localhost:8004"))
|
||||
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
|
||||
|
||||
|
||||
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,123 @@
|
||||
"""Katalog funkcji i uprawnień (PRE-27).
|
||||
|
||||
DWIE ZASADY, KTÓRE RZĄDZĄ TYM MODUŁEM
|
||||
|
||||
1. Konto ograniczone ma widzieć program KOMPLETNY — tylko mniejszy. Nic nie może
|
||||
sugerować, że istnieje coś więcej: żadnych wyszarzonych pozycji, żadnego
|
||||
„brak uprawnień", żadnego 403. Ścieżka bez uprawnienia zwraca **404**, bo 403
|
||||
jest samo w sobie informacją, że coś tam jest.
|
||||
|
||||
2. Mapa trasa → uprawnienie jest JEDNA i leży tutaj. Rozproszenie jej po
|
||||
dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka
|
||||
dziura jest niewidoczna, dopóki ktoś jej nie znajdzie. Test przechodzi po
|
||||
wszystkich trasach aplikacji i wymaga, żeby każda była tu wymieniona.
|
||||
|
||||
PODZIAŁ NA GRUPY. Ekrany odpowiadają zakładkom — to naturalna jednostka, bo
|
||||
zakładka jest tym, co widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI
|
||||
wewnątrz ekranów: ktoś może liczyć horoskopy, nie mając dostępu do porównywania
|
||||
systemów domów ani do generowania tekstu przez model (co kosztuje pieniądze).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
ADMIN = "admin" # zakładanie kont i nadawanie uprawnień — nie do nadania z UI
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Feature:
|
||||
key: str
|
||||
label: str
|
||||
hint: str
|
||||
group: str
|
||||
href: str = "" # tylko ekrany; rozszerzenia nie mają własnej zakładki
|
||||
|
||||
|
||||
# ── ekrany (zakładki) ────────────────────────────────────────────────────
|
||||
SCREENS: tuple[Feature, ...] = (
|
||||
Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.", "Ekrany", "/"),
|
||||
Feature("interpret", "Interpretacje", "Interpretacja natalna z baz i od modelu.", "Ekrany", "/interpret"),
|
||||
Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.", "Ekrany", "/timeline"),
|
||||
Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.", "Ekrany", "/synastry"),
|
||||
Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach interpretacyjnych.", "Ekrany", "/significators"),
|
||||
Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.", "Ekrany", "/compile"),
|
||||
Feature("settings", "Ustawienia", "Podgląd baz i konfiguracji modelu.", "Ekrany", "/settings"),
|
||||
)
|
||||
|
||||
# ── rozszerzenia (poziomy złożoności wewnątrz ekranów) ───────────────────
|
||||
EXTRAS: tuple[Feature, ...] = (
|
||||
Feature("houses_compare", "Porównanie systemów domów",
|
||||
"Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
|
||||
"Rozszerzenia"),
|
||||
Feature("extra_charts", "Wykresy dodatkowe",
|
||||
"Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia"),
|
||||
Feature("advanced_calc", "Obliczenia zaawansowane",
|
||||
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
|
||||
"Rozszerzenia"),
|
||||
Feature("ai", "Generowanie tekstu przez model",
|
||||
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
|
||||
"Rozszerzenia"),
|
||||
Feature("export", "Eksport plików",
|
||||
"Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
|
||||
)
|
||||
|
||||
ALL: tuple[Feature, ...] = SCREENS + EXTRAS
|
||||
BY_KEY: dict[str, Feature] = {f.key: f for f in ALL}
|
||||
GRANTABLE: frozenset[str] = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
|
||||
|
||||
|
||||
# ── mapa trasa → wymagane uprawnienie ────────────────────────────────────
|
||||
# None = dostępne każdemu zalogowanemu. Dotyczy to pomocników (geokoder, strefa
|
||||
# czasowa, health), które same w sobie NICZEGO nie zdradzają o funkcjach programu.
|
||||
ROUTES: dict[tuple[str, str], str | None] = {
|
||||
("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
|
||||
("POST", "/"): "chart",
|
||||
("GET", "/interpret"): "interpret",
|
||||
("POST", "/interpret"): "interpret",
|
||||
("GET", "/timeline"): "timeline",
|
||||
("POST", "/timeline"): "timeline",
|
||||
("GET", "/synastry"): "synastry",
|
||||
("POST", "/synastry"): "synastry",
|
||||
("GET", "/significators"): "significators",
|
||||
("POST", "/significators"): "significators",
|
||||
("GET", "/compile"): "compile",
|
||||
("POST", "/compile"): "compile",
|
||||
("POST", "/compile/pdf"): "export",
|
||||
("GET", "/settings"): "settings",
|
||||
("POST", "/horoscope/stream"): "ai",
|
||||
("GET", "/accounts"): ADMIN,
|
||||
("POST", "/accounts/create"): ADMIN,
|
||||
("POST", "/accounts/update"): ADMIN,
|
||||
("POST", "/accounts/delete"): ADMIN,
|
||||
("GET", "/geocode"): None,
|
||||
("GET", "/reverse"): None,
|
||||
("GET", "/timezone"): None,
|
||||
("GET", "/health"): None,
|
||||
}
|
||||
|
||||
|
||||
def required(method: str, path: str) -> str | None:
|
||||
"""Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina.
|
||||
|
||||
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
|
||||
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
|
||||
wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
|
||||
return ROUTES.get((method.upper(), path), ADMIN)
|
||||
|
||||
|
||||
def can(perms: frozenset[str], feature: str | None) -> bool:
|
||||
return feature is None or feature in perms
|
||||
|
||||
|
||||
def screens_for(perms: frozenset[str]) -> list[Feature]:
|
||||
"""Zakładki do pokazania w nawigacji — w stałej kolejności katalogu."""
|
||||
return [f for f in SCREENS if f.key in perms]
|
||||
|
||||
|
||||
def normalise(keys) -> frozenset[str]:
|
||||
"""Zbiór uprawnień z wejścia formularza: tylko znane klucze, bez ADMIN-a.
|
||||
|
||||
ADMIN odsiewamy TUTAJ, a nie w handlerze: konto administracyjne pochodzi
|
||||
wyłącznie z konfiguracji środowiska, więc żadne wysłanie formularza — nawet
|
||||
spreparowane — nie może go nadać."""
|
||||
return frozenset(k for k in (keys or ()) if k in GRANTABLE)
|
||||
@@ -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,39 @@
|
||||
"""Katalog systemów domów dla warstwy prezentacji — JEDNO źródło nazw.
|
||||
|
||||
Nazwy były zaszyte w czterech szablonach naraz (selektor na „Horoskop", selektor
|
||||
na „Skompiluj", checkboxy porównania, nagłówki tabeli). Przy trzech systemach
|
||||
uchodziło to na sucho; przy dziesięciu rozjazd jest kwestią czasu — wystarczy
|
||||
dopisać system w jednym miejscu i zapomnieć o trzech pozostałych.
|
||||
|
||||
Kolejność jest celowa: najpierw dwa najpowszechniejsze (Placidus i Whole Sign),
|
||||
potem reszta. `LIMITED` oznacza systemy, które powyżej koła podbiegunowego nie
|
||||
istnieją i podlegają jawnemu fallbackowi — UI ma o tym uprzedzać ZANIM ktoś
|
||||
policzy horoskop dla Tromsø, a nie dopiero w ostrzeżeniu przy wyniku.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
HOUSE_SYSTEMS: list[tuple[str, str]] = [
|
||||
("placidus", "Placidus"),
|
||||
("whole_sign", "Whole Sign"),
|
||||
("whole_sign_aries", "Whole Sign (od Barana)"),
|
||||
("koch", "Koch"),
|
||||
("equal", "Equal (od Asc)"),
|
||||
("equal_mc", "Equal (od MC)"),
|
||||
("porphyry", "Porphyry"),
|
||||
("regiomontanus", "Regiomontanus"),
|
||||
("campanus", "Campanus"),
|
||||
("alcabitus", "Alcabitus"),
|
||||
("topocentric", "Topocentric"),
|
||||
("morinus", "Morinus"),
|
||||
("vehlow", "Vehlow"),
|
||||
]
|
||||
|
||||
# Systemy oparte na łuku dobowym — jedyne z realną granicą dziedziny.
|
||||
LIMITED = frozenset({"placidus", "koch"})
|
||||
|
||||
HOUSE_SYSTEM_NAMES: dict[str, str] = dict(HOUSE_SYSTEMS)
|
||||
|
||||
|
||||
def label(system: str) -> str:
|
||||
"""Nazwa do pokazania; nieznany identyfikator zwracamy bez zmian, nie gubimy go."""
|
||||
return HOUSE_SYSTEM_NAMES.get(system, system)
|
||||
@@ -0,0 +1,525 @@
|
||||
"""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"
|
||||
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
||||
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
||||
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
||||
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
||||
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")
|
||||
@@ -7,22 +7,107 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
|
||||
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
|
||||
„/significators".
|
||||
"""
|
||||
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.clients.logic_client import LogicClient
|
||||
from app import accounts as accounts_store
|
||||
from app import chartwheel as chartwheel_mod
|
||||
from app import features
|
||||
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa prezentacji")
|
||||
from app import geocode, security
|
||||
from app.clients.logic_client import LogicClient
|
||||
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||||
|
||||
# Automatyczna dokumentacja WYŁĄCZONA. /docs, /redoc i /openapi.json wypisują
|
||||
# KOMPLET tras aplikacji — czyli spis wszystkich funkcji, łącznie z tymi, o których
|
||||
# konto ograniczone nie ma prawa wiedzieć (PRE-27). Ochrona zakładek nic by nie dała,
|
||||
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
|
||||
app = FastAPI(title="astrololo · warstwa prezentacji",
|
||||
docs_url=None, redoc_url=None, openapi_url=None)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
logic = LogicClient()
|
||||
security.install(app) # logowanie + limit żądań (LOG-32)
|
||||
|
||||
|
||||
# ── cache-busting statyki (PRE-27) ─────────────────────────────────────────
|
||||
# Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache).
|
||||
# Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL →
|
||||
# przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej).
|
||||
# Hash liczony raz na proces (lru_cache) — nowy pod po deployu = świeży hash.
|
||||
_STATIC_DIR = Path("app/static")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _asset_version(name: str) -> str:
|
||||
try:
|
||||
return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8]
|
||||
except OSError:
|
||||
return "0" # brak pliku nie może wywrócić strony
|
||||
|
||||
|
||||
def static_url(name: str) -> str:
|
||||
return f"/static/{name}?v={_asset_version(name)}"
|
||||
|
||||
|
||||
templates.env.globals["static"] = static_url
|
||||
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
|
||||
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
|
||||
templates.env.globals["house_label"] = house_label
|
||||
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
|
||||
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
|
||||
|
||||
|
||||
def _perms(request: Request) -> frozenset[str]:
|
||||
"""Uprawnienia zalogowanego konta — pusty zbiór, gdy nie wiadomo kto to."""
|
||||
who = getattr(request.state, "principal", None)
|
||||
return who.permissions if who is not None else frozenset()
|
||||
|
||||
|
||||
# Szablony pytają o uprawnienia PRZEZ TE DWIE FUNKCJE, nigdy o konto wprost.
|
||||
# Dzięki temu nie da się w szablonie napisać warunku na login — a to jest
|
||||
# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym
|
||||
# nikt już nie pamięta.
|
||||
templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request))
|
||||
templates.env.globals["can"] = lambda request, key: key in _perms(request)
|
||||
|
||||
|
||||
def _limit_options(request: Request, opts: dict) -> dict:
|
||||
"""Ścina opcje, których konto nie ma prawa użyć — PO STRONIE SERWERA.
|
||||
|
||||
Ukrycie pola w formularzu chroni tylko przed przypadkiem. Kto zna nazwy pól,
|
||||
wyśle je wprost — a wtedy policzyłby funkcję, której mu nie przyznano, i to
|
||||
jeszcze zobaczyłby ją w wyniku. Granicą jest ta funkcja, nie szablon.
|
||||
|
||||
Wartości zastępcze są DOMYŚLNE, nie puste: konto ograniczone ma dostać
|
||||
działający horoskop w podstawowej postaci, a nie komunikat o braku czegoś.
|
||||
"""
|
||||
perms = _perms(request)
|
||||
if "advanced_calc" not in perms:
|
||||
opts.update(stations=False, tables=False, aspect_minor=False, zodiac="tropical")
|
||||
if "houses_compare" not in perms:
|
||||
opts.update(house_system="whole_sign", house_systems=[],
|
||||
wheel_orientation=chartwheel_mod.ASC_LEFT)
|
||||
return opts
|
||||
|
||||
|
||||
def _landing(request: Request) -> str | None:
|
||||
"""Pierwszy ekran dostępny dla konta — dokąd odesłać z korzenia."""
|
||||
allowed = features.screens_for(_perms(request))
|
||||
return allowed[0].href if allowed else None
|
||||
|
||||
|
||||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
@@ -36,6 +121,27 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
return utc.isoformat(), label
|
||||
|
||||
|
||||
def _llm_catalog() -> dict:
|
||||
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
|
||||
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
|
||||
try:
|
||||
return logic.llm_models()
|
||||
except httpx.HTTPError:
|
||||
return {"providers": {}, "defaults": {}}
|
||||
|
||||
|
||||
def _report_records(report: dict | None) -> int:
|
||||
"""Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17).
|
||||
|
||||
Liczymy próbki we wszystkich fasetach: to one są treścią z baz. Sama liczba,
|
||||
nigdy treść — dziennik ma pokazywać SKALĘ dostępu, nie powielać danych."""
|
||||
total = 0
|
||||
for o in (report or {}).get("objects") or []:
|
||||
for f in o.get("facets") or []:
|
||||
total += len(f.get("samples") or [])
|
||||
return total
|
||||
|
||||
|
||||
def _logic_error(e: Exception) -> str:
|
||||
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
||||
return (
|
||||
@@ -48,30 +154,69 @@ def _logic_error(e: Exception) -> str:
|
||||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def chart_form(request: Request):
|
||||
return templates.TemplateResponse(request, "chart.html", {"result": None, "form": {}})
|
||||
# Korzeń jest dostępny dla każdego zalogowanego, ale ekran „Horoskop" już nie.
|
||||
# Bez tego konto bez uprawnienia do horoskopu dostawałoby 404 pod adresem
|
||||
# głównym — czyli wyglądałoby to na zepsuty program, a nie na węższy zestaw.
|
||||
if "chart" not in _perms(request):
|
||||
target = _landing(request)
|
||||
if target:
|
||||
return RedirectResponse(target, status_code=303)
|
||||
return HTMLResponse("<h1>Brak przypisanych funkcji</h1>"
|
||||
"<p>Skontaktuj się z osobą, która zakładała konto.</p>",
|
||||
status_code=200)
|
||||
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)
|
||||
def chart_compute(
|
||||
request: Request,
|
||||
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||||
date: str = Form(...),
|
||||
time: str = Form(...),
|
||||
tz_offset: float = Form(0.0),
|
||||
lat: float = Form(0.0),
|
||||
lon: float = Form(0.0),
|
||||
house_system: str = Form("whole_sign"),
|
||||
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
|
||||
aspect_orb: float = Form(8.0), # PRE-06: konfigurowalny orb aspektów
|
||||
aspect_luminary_bonus: float = Form(2.0),
|
||||
aspect_minor: bool = Form(False),
|
||||
stations: bool = Form(False),
|
||||
zodiac: str = Form("tropical"),
|
||||
tables: bool = Form(False),
|
||||
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
|
||||
):
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations}
|
||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "house_system": house_system,
|
||||
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||||
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||||
"wheel_orientation": wheel_orientation}
|
||||
form = _limit_options(request, form)
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.positions(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
house_system=house_system, stations=stations,
|
||||
house_system=form["house_system"], house_systems=form["house_systems"],
|
||||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||||
aspect_minor=form["aspect_minor"],
|
||||
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||||
)
|
||||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||||
orientation=form["wheel_orientation"])
|
||||
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
|
||||
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
|
||||
if "extra_charts" in _perms(request):
|
||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||||
except (httpx.HTTPError,) as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
except ValueError as e:
|
||||
@@ -79,6 +224,215 @@ def chart_compute(
|
||||
return templates.TemplateResponse(request, "chart.html", ctx)
|
||||
|
||||
|
||||
# ---------------- Skompiluj: zbiorczy raport (PRE-23) ----------------
|
||||
@app.get("/compile", response_class=HTMLResponse)
|
||||
def compile_form(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request, "compile.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/compile", response_class=HTMLResponse)
|
||||
def compile_build(
|
||||
request: Request,
|
||||
person: str = Form(""),
|
||||
date: str = Form(...),
|
||||
time: str = Form(...),
|
||||
tz_offset: float = Form(0.0),
|
||||
lat: float = Form(0.0),
|
||||
lon: float = Form(0.0),
|
||||
house_system: str = Form("whole_sign"),
|
||||
house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu
|
||||
aspect_orb: float = Form(8.0), # PRE-06 — te same ustawienia aspektów
|
||||
aspect_luminary_bonus: float = Form(2.0),
|
||||
aspect_minor: bool = Form(False),
|
||||
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
|
||||
zodiac: str = Form("tropical"),
|
||||
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
|
||||
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
|
||||
):
|
||||
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
|
||||
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
||||
|
||||
Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez
|
||||
trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które
|
||||
wybrano przy horoskopie (stacje, tabele, porównanie domów) — inaczej
|
||||
podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między
|
||||
zakładkami przez formsync.
|
||||
"""
|
||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "house_system": house_system,
|
||||
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||||
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||||
"wheel_orientation": wheel_orientation}
|
||||
form = _limit_options(request, form)
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.positions(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
house_system=form["house_system"], house_systems=form["house_systems"],
|
||||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||||
aspect_minor=form["aspect_minor"],
|
||||
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||||
)
|
||||
from app import chartwheel
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||||
orientation=form["wheel_orientation"])
|
||||
if "extra_charts" in _perms(request):
|
||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||||
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, "compile.html", ctx)
|
||||
|
||||
|
||||
@app.post("/compile/pdf")
|
||||
def compile_pdf(payload: dict):
|
||||
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
|
||||
|
||||
Wejście z przeglądarki, bo części od AI (interpretacja natalna i predykcje)
|
||||
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
|
||||
zawierał dokładnie to, co widać na stronie.
|
||||
|
||||
Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVG→PDF nie zna
|
||||
naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
|
||||
"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.clients.render_client import RenderClient
|
||||
|
||||
data = payload.get("data") or {}
|
||||
try:
|
||||
iso_utc, label = _build_utc(
|
||||
str(data.get("date") or ""), str(data.get("time") or ""),
|
||||
float(data.get("tz_offset") or 0.0),
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
||||
|
||||
figures: list[dict] = []
|
||||
warnings: list[str] = []
|
||||
try:
|
||||
chart = logic.positions(
|
||||
when_utc_iso=iso_utc,
|
||||
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
|
||||
house_system=str(data.get("house_system") or "whole_sign"),
|
||||
house_systems=[s for s in (data.get("house_systems") or []) if s],
|
||||
aspect_orb=float(data.get("aspect_orb") or 8.0),
|
||||
aspect_luminary_bonus=float(data.get("aspect_luminary_bonus") or 2.0),
|
||||
aspect_minor=bool(data.get("aspect_minor")),
|
||||
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
|
||||
zodiac=str(data.get("zodiac") or "tropical"),
|
||||
)
|
||||
# Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu
|
||||
# nie da się poznać, że podział jest z innego systemu, niż zamówiono.
|
||||
warnings = [w for w in (chart.get("house_warnings") or []) if w]
|
||||
|
||||
from app import chartwheel
|
||||
|
||||
# Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
|
||||
# w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
|
||||
# zmienne CSS i font glifów muszą być wprost w rysunku.
|
||||
for svg, caption in (
|
||||
(chartwheel.render(chart, theme="print",
|
||||
orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)),
|
||||
"Kosmogram"),
|
||||
(chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
|
||||
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
|
||||
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
|
||||
):
|
||||
if svg:
|
||||
figures.append({"svg": svg, "caption": caption})
|
||||
except httpx.HTTPError as e:
|
||||
# Brak rysunków nie może zablokować raportu — tekst jest ważniejszy.
|
||||
data = {**data, "wheel_error": _logic_error(e)}
|
||||
|
||||
report = {
|
||||
"person": payload.get("person") or "",
|
||||
"data": {**data, "moment_utc": label},
|
||||
"figures": figures,
|
||||
"warnings": warnings,
|
||||
"natal": payload.get("natal") or {},
|
||||
"predictions": payload.get("predictions") or [],
|
||||
}
|
||||
try:
|
||||
pdf = RenderClient().pdf(report)
|
||||
except httpx.HTTPError as e:
|
||||
return JSONResponse(
|
||||
{"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
|
||||
status_code=502,
|
||||
)
|
||||
return Response(content=pdf, media_type="application/pdf",
|
||||
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
|
||||
|
||||
|
||||
# ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ----------------
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
def settings_view(request: Request):
|
||||
"""Podgląd baz dostępnych na udziale + które biorą udział w interpretacji.
|
||||
|
||||
Same metadane (nazwa, rozmiar, data) — bez treści baz. Przełączniki są
|
||||
DEKLARATYWNE (`DISABLED_BASES`), bo warstwa danych nie ma trwałego zapisu:
|
||||
udział jest read-only, a cache to emptyDir. Zapis do pliku ginąłby po
|
||||
restarcie, po cichu włączając z powrotem wyłączoną bazę."""
|
||||
ctx: dict = {"result": None, "error": None}
|
||||
try:
|
||||
ctx["result"] = logic.bases()
|
||||
except httpx.HTTPError as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
return templates.TemplateResponse(request, "settings.html", ctx)
|
||||
|
||||
|
||||
# ---------------- Synastria (technika relacyjna) ----------------
|
||||
@app.get("/synastry", response_class=HTMLResponse)
|
||||
def synastry_form(request: Request):
|
||||
# domyślne liczby, żeby pola number nie były puste (puste → błąd przy wysyłce)
|
||||
form = {"a_tz_offset": 0, "a_lat": 0, "a_lon": 0, "b_tz_offset": 0, "b_lat": 0, "b_lon": 0,
|
||||
"aspect_orb": 8, "aspect_luminary_bonus": 2}
|
||||
return templates.TemplateResponse(request, "synastry.html", {"result": None, "form": form})
|
||||
|
||||
|
||||
@app.post("/synastry", response_class=HTMLResponse)
|
||||
def synastry_run(
|
||||
request: Request,
|
||||
a_person: str = Form(""), a_date: str = Form(...), a_time: str = Form(...),
|
||||
a_tz_offset: float = Form(0.0), a_lat: float = Form(0.0), a_lon: float = Form(0.0),
|
||||
b_person: str = Form(""), b_date: str = Form(...), b_time: str = Form(...),
|
||||
b_tz_offset: float = Form(0.0), b_lat: float = Form(0.0), b_lon: float = Form(0.0),
|
||||
zodiac: str = Form("tropical"),
|
||||
aspect_orb: float = Form(8.0), aspect_luminary_bonus: float = Form(2.0),
|
||||
aspect_minor: bool = Form(False),
|
||||
):
|
||||
"""Synastria (PRE-04): dwie osoby → aspekty między ich horoskopami."""
|
||||
form = {"a_person": a_person, "a_date": a_date, "a_time": a_time, "a_tz_offset": a_tz_offset,
|
||||
"a_lat": a_lat, "a_lon": a_lon, "b_person": b_person, "b_date": b_date,
|
||||
"b_time": b_time, "b_tz_offset": b_tz_offset, "b_lat": b_lat, "b_lon": b_lon,
|
||||
"zodiac": zodiac, "aspect_orb": aspect_orb,
|
||||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor}
|
||||
ctx: dict = {"form": form, "result": None, "error": None}
|
||||
try:
|
||||
iso_a, _ = _build_utc(a_date, a_time, a_tz_offset)
|
||||
iso_b, _ = _build_utc(b_date, b_time, b_tz_offset)
|
||||
ctx["result"] = logic.synastry(
|
||||
{"when_utc": iso_a, "lat": a_lat, "lon": a_lon},
|
||||
{"when_utc": iso_b, "lat": b_lat, "lon": b_lon},
|
||||
zodiac=zodiac, aspect_orb=aspect_orb,
|
||||
aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor,
|
||||
)
|
||||
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, "synastry.html", ctx)
|
||||
|
||||
|
||||
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
|
||||
@app.get("/significators", response_class=HTMLResponse)
|
||||
def significators_form(request: Request):
|
||||
@@ -97,6 +451,8 @@ def significators_search(
|
||||
ctx: dict = {"form": form, "result": None, "error": None}
|
||||
try:
|
||||
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
|
||||
# ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści
|
||||
security.audit_records(request, len((ctx["result"] or {}).get("rows") or []))
|
||||
except httpx.HTTPError as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
return templates.TemplateResponse(request, "significators.html", ctx)
|
||||
@@ -105,26 +461,64 @@ def significators_search(
|
||||
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
|
||||
@app.get("/interpret", response_class=HTMLResponse)
|
||||
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)
|
||||
def interpret_run(
|
||||
request: Request,
|
||||
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||||
date: str = Form(...),
|
||||
time: str = Form(...),
|
||||
tz_offset: float = Form(0.0),
|
||||
lat: float = Form(0.0),
|
||||
lon: float = Form(0.0),
|
||||
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,
|
||||
"lat": lat, "lon": lon, "group": group}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||
"llm_catalog": _llm_catalog()}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||
if action == "export":
|
||||
# Tabela robocza do Excela (DAN-23/PRE-10) — pobranie pliku, nie strona.
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.report_export import report_to_xlsx
|
||||
|
||||
report = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||
# Eksport wynosi NAJWIĘCEJ treści baz naraz — tym bardziej ma zostawiać
|
||||
# ślad w dzienniku (PRE-17): sama liczba rekordów, nigdy treść.
|
||||
security.audit_records(request, _report_records(report))
|
||||
return Response(
|
||||
content=report_to_xlsx(report),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
|
||||
)
|
||||
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)
|
||||
security.audit_records(request, _report_records(ctx["result"]))
|
||||
except httpx.HTTPError as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
except ValueError as e:
|
||||
@@ -132,6 +526,213 @@ def interpret_run(
|
||||
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,
|
||||
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||||
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 = {"person": person, "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("/timezone")
|
||||
def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"):
|
||||
"""Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03).
|
||||
|
||||
Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404,
|
||||
gdy strefy nie da się ustalić — front zostawia wtedy ręczny offset."""
|
||||
from app import timezone as tzmod
|
||||
|
||||
res = tzmod.resolve(lat, lon, date, time)
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.")
|
||||
return res
|
||||
|
||||
|
||||
# ---------------- Konta i uprawnienia (PRE-27) ----------------
|
||||
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
|
||||
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
|
||||
|
||||
def _accounts_context(request: Request, error: str = "", done: str = "") -> dict:
|
||||
return {
|
||||
"users": accounts_store.all_users(),
|
||||
"catalog": features.ALL,
|
||||
"screens": features.SCREENS,
|
||||
"extras": features.EXTRAS,
|
||||
"admin_login": security.app_user(),
|
||||
"store_path": accounts_store.store_path(),
|
||||
"error": error, "done": done,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/accounts", response_class=HTMLResponse)
|
||||
def accounts_view(request: Request, error: str = "", done: str = ""):
|
||||
return templates.TemplateResponse(request, "accounts.html",
|
||||
_accounts_context(request, error, done))
|
||||
|
||||
|
||||
def _accounts_redirect(error: str = "", done: str = "") -> RedirectResponse:
|
||||
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
||||
powtórzyć zakładania ani kasowania konta."""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||||
return RedirectResponse(f"/accounts{'?' + q if q else ''}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/accounts/create")
|
||||
def accounts_create(request: Request, login: str = Form(""), password: str = Form(""),
|
||||
note: str = Form(""), granted: list[str] = Form([])):
|
||||
try:
|
||||
accounts_store.create(login, password, granted, note)
|
||||
except ValueError as e:
|
||||
return _accounts_redirect(error=str(e))
|
||||
return _accounts_redirect(done=f"Założono konto „{login.strip()}”.")
|
||||
|
||||
|
||||
@app.post("/accounts/update")
|
||||
def accounts_update(request: Request, login: str = Form(...), password: str = Form(""),
|
||||
note: str = Form(""), granted: list[str] = Form([])):
|
||||
try:
|
||||
accounts_store.update(login, granted=granted, password=password, note=note)
|
||||
except ValueError as e:
|
||||
return _accounts_redirect(error=str(e))
|
||||
changed = "uprawnienia i hasło" if password.strip() else "uprawnienia"
|
||||
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
|
||||
|
||||
|
||||
@app.post("/accounts/delete")
|
||||
def accounts_delete(request: Request, login: str = Form(...)):
|
||||
try:
|
||||
accounts_store.delete(login)
|
||||
except ValueError as e:
|
||||
return _accounts_redirect(error=str(e))
|
||||
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "layer": "presentation"}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Eksport raportu interpretacji do Excela — „tabela robocza" (DAN-23 / PRE-10).
|
||||
|
||||
Raport z logiki jest zagnieżdżony: obiekt → fasety → próbki (dopasowania z bazy).
|
||||
Astrolog chce z tym PRACOWAĆ w Excelu: filtrować, sortować, zaznaczać, usuwać —
|
||||
więc spłaszczamy go do JEDNEJ płaskiej tabeli (wiersz = jedno dopasowanie) z
|
||||
auto-filtrem i zamrożonym nagłówkiem. Bez wykresów i ozdób — to materiał roboczy.
|
||||
|
||||
Świadomie budujemy plik TU, w prezentacji (jak PDF idzie przez usługę render):
|
||||
warstwa danych/logiki liczy, prezentacja formatuje wyjście.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Font
|
||||
|
||||
HEADERS = ["Obiekt", "Faseta", "Typ", "Token", "Sygnifikator", "Rozwinięcie", "Opis / efekt"]
|
||||
_WIDTHS = [16, 24, 10, 20, 22, 28, 72] # „Opis" najszerszy — tam jest treść
|
||||
|
||||
|
||||
def _rows(report: dict):
|
||||
"""Spłaszcza obiekt→fasety→próbki na wiersze płaskiej tabeli."""
|
||||
for o in report.get("objects") or []:
|
||||
name = o.get("name") or ""
|
||||
for f in o.get("facets") or []:
|
||||
label, typ, token = f.get("label") or "", f.get("type") or "", f.get("token") or ""
|
||||
for s in (f.get("samples") or []):
|
||||
yield [name, label, typ, token,
|
||||
s.get("significator") or "", s.get("expanded") or "", s.get("effect") or ""]
|
||||
|
||||
|
||||
def report_to_xlsx(report: dict) -> bytes:
|
||||
"""Raport (dict z /chart/report) → bajty pliku .xlsx z jedną tabelą roboczą."""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Interpretacje"
|
||||
|
||||
ws.append(HEADERS)
|
||||
for c in ws[1]:
|
||||
c.font = Font(bold=True)
|
||||
|
||||
for row in _rows(report):
|
||||
ws.append(row)
|
||||
|
||||
# Auto-filtr + zamrożony nagłówek = filtrowanie/sortowanie od razu w Excelu (PRE-10).
|
||||
ws.auto_filter.ref = f"A1:{chr(64 + len(HEADERS))}{ws.max_row}"
|
||||
ws.freeze_panes = "A2"
|
||||
for i, w in enumerate(_WIDTHS, start=1):
|
||||
ws.column_dimensions[chr(64 + i)].width = w
|
||||
for row in ws.iter_rows(min_row=2): # opis bywa długi — zawijamy
|
||||
row[6].alignment = Alignment(wrap_text=True, vertical="top")
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,336 @@
|
||||
"""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ę.
|
||||
|
||||
Trzy mechanizmy:
|
||||
* **HTTP Basic** — wejście do aplikacji; włącza się, gdy są jakiekolwiek konta.
|
||||
* **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`).
|
||||
* **konta imienne + dziennik audytowy** (PRE-17) — patrz niżej.
|
||||
|
||||
KONTA IMIENNE (PRE-17). Jedno wspólne hasło nie mówi, KTO sięgał do baz, a
|
||||
odebranie dostępu jednej osobie wymagało zmiany hasła wszystkim. `APP_USERS`
|
||||
trzyma listę kont `login:sekret` — sekret najlepiej jako HASH scrypt (stdlib,
|
||||
bez nowych zależności): `scrypt$<sól_hex>$<hash_hex>`, patrz scripts/make_user.py.
|
||||
Odebranie dostępu = usunięcie wpisu. Gdy `APP_USERS` jest ustawione, wspólne
|
||||
APP_PASSWORD PRZESTAJE działać — inaczej stare hasło cicho zostawiałoby tylne
|
||||
wejście, czyli dokładnie problem, który to wymaganie zamyka.
|
||||
|
||||
DZIENNIK AUDYTOWY (PRE-17). Każde żądanie do chronionej ścieżki zostawia wpis:
|
||||
kto, kiedy, jaka ścieżka, status, ile rekordów (gdy handler to poda przez
|
||||
`audit_records`). To jedyny sposób, by zauważyć POWOLNE wypompowywanie bazy przez
|
||||
osobę uprawnioną — pojedyncze zapytanie wygląda niewinnie, suma już nie.
|
||||
Świadomie NIE logujemy treści żądań, promptów ani rekordów — logi to kolejny
|
||||
nośnik wycieku; liczby i metadane wystarczą do wykrycia nadużycia.
|
||||
|
||||
UWAGA: bez kont 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 hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
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", "")
|
||||
|
||||
|
||||
# ── konta imienne (PRE-17) ───────────────────────────────────────────────
|
||||
# Parametry scrypt: rozsądny kompromis czas/pamięć dla logowania do aplikacji
|
||||
# wewnętrznej. Zmiana wymaga przeliczenia haseł (hash niesie tylko sól).
|
||||
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
|
||||
_HASH_PREFIX = "scrypt$"
|
||||
|
||||
|
||||
def hash_password(password: str, salt: bytes | None = None) -> str:
|
||||
"""Hasło → wpis do APP_USERS: `scrypt$<sól_hex>$<hash_hex>`."""
|
||||
salt = salt or secrets.token_bytes(16)
|
||||
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
||||
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
|
||||
return f"{_HASH_PREFIX}{salt.hex()}${dk.hex()}"
|
||||
|
||||
|
||||
def accounts() -> dict[str, str]:
|
||||
"""Konta z `APP_USERS` (`login:sekret`, po przecinku). Gdy pusto — zgodność
|
||||
wstecz: pojedyncze konto z APP_USER/APP_PASSWORD."""
|
||||
out: dict[str, str] = {}
|
||||
for entry in os.getenv("APP_USERS", "").split(","):
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
user, sep, secret = entry.partition(":")
|
||||
if sep and user.strip() and secret.strip():
|
||||
out[user.strip()] = secret.strip()
|
||||
if out:
|
||||
return out
|
||||
return {app_user(): app_password()} if app_password() else {}
|
||||
|
||||
|
||||
def _secret_matches(spec: str, password: str) -> bool:
|
||||
"""Sekret konta vs podane hasło. Hash scrypt albo (zgodność wstecz) jawne."""
|
||||
if spec.startswith(_HASH_PREFIX):
|
||||
try:
|
||||
_, salt_hex, hash_hex = spec.split("$", 2)
|
||||
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
|
||||
except ValueError:
|
||||
return False
|
||||
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
||||
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
|
||||
return hmac.compare_digest(actual, expected)
|
||||
# Porównujemy BAJTY, nie tekst: `compare_digest` na stringach rzuca TypeError
|
||||
# przy znakach spoza ASCII, więc hasło z polskimi literami wywracało logowanie
|
||||
# błędem 500 zamiast zwykłej odmowy (błąd zastany, złapany testem).
|
||||
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
|
||||
|
||||
|
||||
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(accounts())
|
||||
|
||||
|
||||
def _is_public(path: str) -> bool:
|
||||
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Principal:
|
||||
"""Kto się zalogował i co wolno mu zobaczyć."""
|
||||
|
||||
login: str
|
||||
permissions: frozenset[str]
|
||||
is_admin: bool
|
||||
|
||||
|
||||
def _admin_permissions() -> frozenset[str]:
|
||||
"""Administrator widzi wszystko — z definicji, bez wpisywania listy ręcznie.
|
||||
|
||||
Liczone z katalogu, więc dopisanie nowej funkcji NIE wymaga pamiętania
|
||||
o koncie administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja
|
||||
byłaby niewidoczna dla jedynej osoby, która ma ją komu nadać."""
|
||||
from app import features
|
||||
|
||||
return frozenset(features.GRANTABLE) | {features.ADMIN}
|
||||
|
||||
|
||||
def principal(header: str | None) -> Principal | None:
|
||||
"""Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują.
|
||||
|
||||
Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne
|
||||
uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej
|
||||
założenie w pliku konta o loginie administratora przesłoniłoby konto
|
||||
administracyjne i odebrało mu uprawnienia.
|
||||
|
||||
Nieznany login i złe hasło traktujemy tak samo: odmowa nie mówi, które konto
|
||||
istnieje."""
|
||||
if not header or not header.lower().startswith("basic "):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
||||
user, _, password = raw.partition(":")
|
||||
except (binascii.Error, UnicodeDecodeError, IndexError):
|
||||
return None
|
||||
|
||||
spec = accounts().get(user)
|
||||
if spec is not None:
|
||||
return (Principal(user, _admin_permissions(), True)
|
||||
if _secret_matches(spec, password) else None)
|
||||
|
||||
from app import accounts as store
|
||||
|
||||
managed = store.secret_of(user)
|
||||
if managed is None:
|
||||
return None
|
||||
if not _secret_matches(managed, password):
|
||||
return None
|
||||
return Principal(user, store.permissions_of(user), False)
|
||||
|
||||
|
||||
def authenticate(header: str | None) -> str | None:
|
||||
"""Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz."""
|
||||
who = principal(header)
|
||||
return who.login if who else None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── dziennik audytowy (PRE-17) ───────────────────────────────────────────
|
||||
audit_log = logging.getLogger("astrololo.audit")
|
||||
|
||||
|
||||
def _setup_audit_logging() -> None:
|
||||
"""Gwarantuje, że wpisy audytowe FAKTYCZNIE wychodzą na stdout.
|
||||
|
||||
Domyślna konfiguracja uvicorna nie obsługuje naszych loggerów, więc wpisy na
|
||||
poziomie INFO ginęły — dziennik istniał w kodzie, ale był pusty (sprawdzone
|
||||
na żywym serwerze). Niewidoczny dziennik jest gorszy niż jego brak: daje
|
||||
złudzenie kontroli. Własny handler na stdout, bo w k8s to stamtąd zbierane
|
||||
są logi. `propagate=False` — żeby wpis nie dublował się przez korzeń.
|
||||
"""
|
||||
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
|
||||
if not audit_log.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
audit_log.addHandler(handler)
|
||||
audit_log.propagate = False
|
||||
|
||||
|
||||
def audit_records(request: Request, count: int) -> None:
|
||||
"""Handler zgłasza, ILE rekordów baz oddał — trafi do wpisu audytowego.
|
||||
|
||||
Sama liczba, nigdy treść. Bez tego dziennik pokazywałby tylko „ktoś wchodził",
|
||||
a nie „ktoś wyniósł 5000 rekordów" — a to drugie jest sygnałem nadużycia."""
|
||||
try:
|
||||
request.state.audit_records = int(count)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _audit(request: Request, user: str, status: int, ms: float) -> None:
|
||||
records = getattr(request.state, "audit_records", None)
|
||||
audit_log.info(
|
||||
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
|
||||
user or "-", client_ip(request), request.method, request.url.path,
|
||||
status, "-" if records is None else records, ms,
|
||||
)
|
||||
|
||||
|
||||
def install(app) -> None:
|
||||
"""Podpina ochronę pod wszystkie ścieżki poza /health i /static."""
|
||||
_setup_audit_logging()
|
||||
if not auth_enabled():
|
||||
log.warning(
|
||||
"UWAGA: brak kont (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA "
|
||||
"dla każdego, kto ma do niej dostęp sieciowy, wraz z treścią baz "
|
||||
"interpretacyjnych."
|
||||
)
|
||||
elif os.getenv("APP_USERS", "").strip() and app_password():
|
||||
# Gdyby wspólne hasło działało obok kont imiennych, zostawiałoby tylne
|
||||
# wejście bez śladu w dzienniku — czyli problem, który PRE-17 zamyka.
|
||||
log.warning(
|
||||
"APP_USERS jest ustawione, więc wspólne APP_PASSWORD NIE działa "
|
||||
"(konta imienne mają pierwszeństwo). Usuń APP_PASSWORD z konfiguracji."
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def _guard(request: Request, call_next):
|
||||
if _is_public(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
started = time.monotonic()
|
||||
if _rate_limited(client_ip(request)):
|
||||
_audit(request, "-", 429, (time.monotonic() - started) * 1000)
|
||||
return JSONResponse(
|
||||
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
||||
status_code=429, headers={"Retry-After": "60"},
|
||||
)
|
||||
|
||||
from app import features
|
||||
|
||||
user, who = "", None
|
||||
if auth_enabled():
|
||||
who = principal(request.headers.get("authorization"))
|
||||
if who is None:
|
||||
_audit(request, "-", 401, (time.monotonic() - started) * 1000)
|
||||
return HTMLResponse(
|
||||
"<h1>401 — wymagane logowanie</h1>", status_code=401,
|
||||
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
|
||||
)
|
||||
user = who.login
|
||||
else:
|
||||
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
|
||||
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
|
||||
who = Principal("", _admin_permissions(), True)
|
||||
|
||||
request.state.user = user
|
||||
request.state.principal = who
|
||||
|
||||
# 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest —
|
||||
# a konto ograniczone ma widzieć program kompletny, tylko mniejszy.
|
||||
need = features.required(request.method, request.url.path)
|
||||
if not features.can(who.permissions, need):
|
||||
_audit(request, user, 404, (time.monotonic() - started) * 1000)
|
||||
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
|
||||
response = await call_next(request)
|
||||
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
|
||||
return response
|
||||
@@ -0,0 +1,174 @@
|
||||
// Zakładka „Skompiluj" — składanie raportu (PRE-23).
|
||||
//
|
||||
// Raport ma trzy kawałki liczone w różnych miejscach:
|
||||
// 1. horoskop — liczy serwer przy wysłaniu tego formularza,
|
||||
// 2. interpretacja natalna — z zakładki Interpretacje (natal.js),
|
||||
// 3. predykcje okresowe — z zakładki Kalendarz (predictions.js).
|
||||
//
|
||||
// Punkty 2 i 3 mieszkają w magazynie przeglądarki, więc to tutaj je dokładamy.
|
||||
// Odczytujemy je przez API tamtych modułów, a nie sięgając wprost do
|
||||
// localStorage — dzięki temu format danych ma JEDNEGO właściciela.
|
||||
//
|
||||
// Pokazujemy też, CZEGO BRAKUJE. Bez tego użytkownik złożyłby niekompletny raport
|
||||
// i dowiedział się o tym dopiero po otwarciu PDF-a.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function readNatal() {
|
||||
return (window.astrololoNatal && window.astrololoNatal.read()) || null;
|
||||
}
|
||||
|
||||
function readPredictions() {
|
||||
return (window.astrololoPredictions && window.astrololoPredictions.read()) || [];
|
||||
}
|
||||
|
||||
function item(ok, label, hint) {
|
||||
return '<li class="' + (ok ? 'rdy-ok' : 'rdy-missing') + '">' +
|
||||
(ok ? '✓ ' : '✗ ') + esc(label) +
|
||||
(ok ? '' : ' <span class="muted small">— ' + esc(hint) + '</span>') + '</li>';
|
||||
}
|
||||
|
||||
function renderReadiness(hasChart) {
|
||||
var host = document.getElementById('readiness');
|
||||
if (!host) return;
|
||||
var natal = readNatal();
|
||||
var preds = readPredictions();
|
||||
var parts = [
|
||||
item(hasChart, 'Policzony horoskop', 'wypełnij dane i kliknij „Złóż podsumowanie"'),
|
||||
item(!!natal, 'Interpretacja natalna',
|
||||
'wygeneruj ją na zakładce Interpretacje — zapamięta się sama'),
|
||||
item(preds.length > 0,
|
||||
'Predykcje okresowe' + (preds.length ? ' (' + preds.length + ')' : ''),
|
||||
'policz horoskop na wybrany okres w Kalendarzu')
|
||||
];
|
||||
host.innerHTML = '<div class="meta">Materiał do raportu</div><ul class="readiness">' +
|
||||
parts.join('') + '</ul>';
|
||||
}
|
||||
|
||||
function renderNatal() {
|
||||
var host = document.getElementById('reportNatal');
|
||||
if (!host) return;
|
||||
var natal = readNatal();
|
||||
if (!natal) {
|
||||
host.innerHTML = '<div class="meta">Interpretacja natalna</div>' +
|
||||
'<p class="muted small">Brak — wygeneruj ją na zakładce Interpretacje.</p>';
|
||||
return;
|
||||
}
|
||||
host.innerHTML = '<div class="meta">Interpretacja natalna ' +
|
||||
'<span class="muted small">· zapamiętana ' + esc(String(natal.saved_at).slice(0, 10)) +
|
||||
'</span></div><div class="report-text">' + esc(natal.text) + '</div>';
|
||||
}
|
||||
|
||||
function renderPredictions() {
|
||||
var host = document.getElementById('reportPredictions');
|
||||
if (!host) return;
|
||||
var preds = readPredictions();
|
||||
if (!preds.length) {
|
||||
host.innerHTML = '<div class="meta">Predykcje okresowe</div>' +
|
||||
'<p class="muted small">Brak — policz horoskop na wybrany okres w Kalendarzu.</p>';
|
||||
return;
|
||||
}
|
||||
var blocks = preds.map(function (p) {
|
||||
return '<div class="meta">' + esc(p.from_date) + ' – ' + esc(p.to_date) + '</div>' +
|
||||
'<div class="report-text">' + esc(p.text) + '</div>';
|
||||
}).join('');
|
||||
host.innerHTML = '<div class="meta">Predykcje okresowe (' + preds.length + ')</div>' + blocks;
|
||||
}
|
||||
|
||||
function ready(fn) {
|
||||
if (document.readyState !== 'loading') fn();
|
||||
else document.addEventListener('DOMContentLoaded', fn);
|
||||
}
|
||||
|
||||
// ── PDF (PRE-24) ────────────────────────────────────────────────────
|
||||
// Części od AI mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
|
||||
// do serwera, który dokłada kosmogram i woła usługę render.
|
||||
function formData() {
|
||||
function v(name) {
|
||||
var el = document.querySelector('[name=' + name + ']');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
function checked(name) {
|
||||
var el = document.querySelector('[name=' + name + ']');
|
||||
return !!(el && el.checked);
|
||||
}
|
||||
function multi(name) {
|
||||
var out = [];
|
||||
document.querySelectorAll('[name=' + name + ']:checked').forEach(function (el) { out.push(el.value); });
|
||||
return out;
|
||||
}
|
||||
// Opcje (stacje, tabele, porównanie domów) MUSZĄ jechać do PDF-a — inaczej
|
||||
// PDF liczyłby okrojony horoskop i miałby braki względem podsumowania.
|
||||
return {
|
||||
date: v('date'), time: v('time'), tz_offset: v('tz_offset'),
|
||||
lat: v('lat'), lon: v('lon'),
|
||||
house_system: v('house_system'), zodiac: v('zodiac'),
|
||||
house_systems: multi('house_systems'),
|
||||
aspect_orb: v('aspect_orb'), aspect_luminary_bonus: v('aspect_luminary_bonus'),
|
||||
aspect_minor: checked('aspect_minor'),
|
||||
stations: checked('stations'), tables: checked('tables'),
|
||||
place: (document.querySelector('#geoSearch') || {}).value || ''
|
||||
};
|
||||
}
|
||||
|
||||
function download(blob) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'raport.pdf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// Zwalniamy obiekt dopiero po kliknięciu — wcześniej przeglądarka nie zdąży
|
||||
// rozpocząć pobierania.
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
|
||||
}
|
||||
|
||||
function buildPdf(btn, note) {
|
||||
var person = (document.querySelector('[name=person]') || {}).value || '';
|
||||
var payload = {
|
||||
person: person,
|
||||
data: formData(),
|
||||
natal: readNatal() || {},
|
||||
predictions: readPredictions()
|
||||
};
|
||||
btn.disabled = true;
|
||||
note.textContent = 'Składam PDF — LaTeX potrzebuje chwili przy obszernym tekście…';
|
||||
fetch('/compile/pdf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function (r) {
|
||||
if (!r.ok) {
|
||||
return r.json().catch(function () { return {}; }).then(function (d) {
|
||||
throw new Error(d.detail || ('HTTP ' + r.status));
|
||||
});
|
||||
}
|
||||
return r.blob();
|
||||
}).then(function (blob) {
|
||||
download(blob);
|
||||
note.textContent = 'Gotowe — PDF pobrany.';
|
||||
}).catch(function (e) {
|
||||
note.textContent = 'Nie udało się złożyć PDF-a: ' + e.message;
|
||||
}).finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
ready(function () {
|
||||
if (!document.getElementById('readiness')) return; // nie ta zakładka
|
||||
// Horoskop jest w dokumencie tylko po wysłaniu formularza.
|
||||
renderReadiness(!!document.getElementById('reportPerson'));
|
||||
renderNatal();
|
||||
renderPredictions();
|
||||
|
||||
var btn = document.getElementById('pdfBtn');
|
||||
var note = document.getElementById('pdfNote');
|
||||
if (btn && note) btn.addEventListener('click', function () { buildPdf(btn, note); });
|
||||
});
|
||||
})();
|
||||
@@ -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,128 @@
|
||||
// Wspólne dane formularza między zakładkami (PRE-21).
|
||||
//
|
||||
// Problem: każdą zakładkę (Horoskop / Interpretacje / Kalendarz) wypełniało się
|
||||
// od nowa — te same imię, data, godzina, strefa i miejsce. Teraz raz wpisane
|
||||
// dane wędrują za użytkownikiem.
|
||||
//
|
||||
// Dlaczego localStorage, a nie sesja na serwerze: serwer zostaje BEZSTANOWY
|
||||
// (żadnego magazynu sesji, żadnych danych urodzeniowych trzymanych po stronie
|
||||
// usługi), a przy okazji dane synchronizują się też między osobnymi kartami
|
||||
// przeglądarki — zdarzenie `storage` daje to za darmo.
|
||||
//
|
||||
// UWAGA na kolejność: ten skrypt ładujemy w <head> z `defer`, więc wykonuje się
|
||||
// PO sparsowaniu dokumentu, ale PRZED geo.js (skrypty defer idą w kolejności
|
||||
// dokumentu). Dzięki temu mapa startuje już z odtworzonymi współrzędnymi i nie
|
||||
// trzeba jej potem przestawiać.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var KEY = 'astrololo.form.v1';
|
||||
|
||||
// Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć.
|
||||
// `geoSearch` (nazwa miejsca) nie ma atrybutu name — szukamy po id.
|
||||
// type: brak = zwykła wartość (.value); 'check' = pojedynczy checkbox (.checked);
|
||||
// 'multi' = kilka checkboxów o tej samej nazwie (lista zaznaczonych wartości).
|
||||
// Opcje (stacje, tabele, porównanie domów) są tu, żeby wybór z „Horoskopu"
|
||||
// wędrował na „Skompiluj" — inaczej podsumowanie liczyłoby się bez nich.
|
||||
var FIELDS = [
|
||||
{ key: 'person', sel: 'input[name=person]' },
|
||||
{ key: 'date', sel: 'input[name=date]' },
|
||||
{ key: 'time', sel: 'input[name=time]' },
|
||||
{ key: 'tz_offset', sel: 'input[name=tz_offset]' },
|
||||
{ key: 'lat', sel: 'input[name=lat]' },
|
||||
{ key: 'lon', sel: 'input[name=lon]' },
|
||||
{ key: 'house_system', sel: 'select[name=house_system]' },
|
||||
{ key: 'zodiac', sel: 'select[name=zodiac]' },
|
||||
{ key: 'place', sel: '#geoSearch' },
|
||||
{ key: 'stations', sel: 'input[name=stations]', type: 'check' },
|
||||
{ key: 'tables', sel: 'input[name=tables]', type: 'check' },
|
||||
{ key: 'house_systems', name: 'house_systems', type: 'multi' },
|
||||
{ key: 'aspect_orb', sel: 'input[name=aspect_orb]' },
|
||||
{ key: 'aspect_luminary_bonus', sel: 'input[name=aspect_luminary_bonus]' },
|
||||
{ key: 'aspect_minor', sel: 'input[name=aspect_minor]', type: 'check' }
|
||||
];
|
||||
|
||||
function elementsOf(f) {
|
||||
if (f.type === 'multi') return Array.prototype.slice.call(document.querySelectorAll('[name=' + f.name + ']'));
|
||||
var el = document.querySelector(f.sel);
|
||||
return el ? [el] : [];
|
||||
}
|
||||
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) || '{}') || {};
|
||||
} catch (e) {
|
||||
return {}; // uszkodzony wpis nie może zablokować strony
|
||||
}
|
||||
}
|
||||
|
||||
function save(state) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
/* tryb prywatny albo brak miejsca — trudno, formularz ma działać dalej */
|
||||
}
|
||||
}
|
||||
|
||||
function apply(state) {
|
||||
FIELDS.forEach(function (f) {
|
||||
var els = elementsOf(f);
|
||||
if (!els.length) return;
|
||||
var v = state[f.key];
|
||||
if (v === undefined || v === null) return;
|
||||
if (f.type === 'multi') {
|
||||
if (!Array.isArray(v)) return;
|
||||
els.forEach(function (el) { el.checked = v.indexOf(el.value) !== -1; });
|
||||
} else if (f.type === 'check') {
|
||||
els[0].checked = !!v;
|
||||
} else {
|
||||
if (v === '') return;
|
||||
if (els[0].value !== v) els[0].value = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function collect() {
|
||||
var state = load();
|
||||
FIELDS.forEach(function (f) {
|
||||
var els = elementsOf(f);
|
||||
if (!els.length) return; // pola nieobecnego na tej zakładce nie kasujemy
|
||||
if (f.type === 'multi') {
|
||||
state[f.key] = els.filter(function (el) { return el.checked; }).map(function (el) { return el.value; });
|
||||
} else if (f.type === 'check') {
|
||||
state[f.key] = els[0].checked;
|
||||
} else {
|
||||
if (els[0].value === '' || els[0].value === null) return;
|
||||
state[f.key] = els[0].value;
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
// 1) odtwórz to, co użytkownik wpisał gdzie indziej
|
||||
apply(load());
|
||||
|
||||
// 2) zapisuj każdą zmianę (input dla wpisywania, change dla list, checkboxów, kalendarzy)
|
||||
FIELDS.forEach(function (f) {
|
||||
elementsOf(f).forEach(function (el) {
|
||||
['input', 'change'].forEach(function (ev) {
|
||||
el.addEventListener(ev, function () { save(collect()); });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 3) zapisz stan po wysłaniu formularza — po przeładowaniu strony pola mają
|
||||
// pokazywać to, co faktycznie policzyliśmy
|
||||
document.addEventListener('submit', function () { save(collect()); }, true);
|
||||
|
||||
// 4) druga otwarta karta zmieniła dane -> dociągnij je tutaj na żywo
|
||||
window.addEventListener('storage', function (e) {
|
||||
if (e.key === KEY) apply(load());
|
||||
});
|
||||
|
||||
// 5) geo.js ustawia lat/lon z mapy poza polami formularza — po takiej zmianie
|
||||
// też chcemy zapamiętać (zdarzenie leci z „Tu i teraz" oraz z mapy)
|
||||
document.addEventListener('astrololo:coords', function () {
|
||||
setTimeout(function () { save(collect()); }, 0);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,182 @@
|
||||
// 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');
|
||||
|
||||
// ---- strefa czasowa z lokalizacji (PRE-03) ----
|
||||
// Offset GMT liczymy ze współrzędnych + daty (DST-świadomie), żeby użytkownik
|
||||
// nie musiał go znać ani pamiętać o czasie letnim. Podpowiedź trafia do pola
|
||||
// tz_offset (PRE-19), które zostaje edytowalne — to pomoc, nie kaganiec.
|
||||
var tzEl = document.querySelector('input[name=tz_offset]');
|
||||
var dateEl = document.querySelector('input[name=date]');
|
||||
var timeEl = document.querySelector('input[name=time]');
|
||||
var tzNote = null;
|
||||
if (tzEl && tzEl.parentNode) {
|
||||
tzNote = document.createElement('span');
|
||||
tzNote.className = 'muted small';
|
||||
tzNote.id = 'tzNote';
|
||||
tzEl.parentNode.appendChild(tzNote);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Ustawienie `.value` z kodu NIE wywołuje zdarzeń, więc synchronizacja
|
||||
// między zakładkami (formsync.js) przegapiłaby zmiany z mapy. Zgłaszamy je
|
||||
// jawnie przez `change`.
|
||||
function setVal(el, value) {
|
||||
if (!el) return;
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
function setCoords(lat, lon, fromMap) {
|
||||
setVal(latEl, lat.toFixed(4));
|
||||
setVal(lonEl, lon.toFixed(4));
|
||||
marker.setLatLng([lat, lon]);
|
||||
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
|
||||
if (fromMap) reverseName(lat, lon);
|
||||
resolveTz(); // nowe współrzędne → odśwież strefę/offset
|
||||
}
|
||||
|
||||
// Współrzędne + data → offset GMT (DST-świadomy). Wypełnia tz_offset i pokazuje,
|
||||
// jaką strefę wykryto. Bez pola/daty/biblioteki po cichu pasuje — zostaje
|
||||
// ręczny offset. Offset trzymamy potem jako stałą liczbę: to on decyduje przy
|
||||
// przeliczaniu, więc dokładne współrzędne nie przerzucą już czasu letniego.
|
||||
function resolveTz() {
|
||||
if (!tzEl || !dateEl || !dateEl.value) return;
|
||||
var lat = curLat(), lon = curLon();
|
||||
if (!lat && !lon) return; // 0,0 = brak lokalizacji
|
||||
var url = '/timezone?lat=' + lat + '&lon=' + lon +
|
||||
'&date=' + encodeURIComponent(dateEl.value) +
|
||||
'&time=' + encodeURIComponent((timeEl && timeEl.value) || '12:00');
|
||||
fetch(url)
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) { if (tzNote) tzNote.textContent = ''; return; }
|
||||
setVal(tzEl, d.offset); // change → formsync podłapie
|
||||
if (tzNote) tzNote.textContent = 'Wykryto: ' + d.label;
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function reverseName(lat, lon) {
|
||||
// Czyścimy pole OD RAZU. Dopóki nie znamy nazwy nowego punktu, stara jest
|
||||
// po prostu nieprawdziwa — pokazywałaby inne miejsce niż współrzędne obok.
|
||||
// Dotyczy też sytuacji, gdy /reverse nie odpowie (brak sieci): lepiej puste
|
||||
// pole i poprawne współrzędne niż nazwa, która kłamie.
|
||||
if (searchEl) setVal(searchEl, '');
|
||||
fetch('/reverse?lat=' + lat + '&lon=' + lon)
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) { if (d && d.name && searchEl) setVal(searchEl, 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);
|
||||
setVal(searchEl, 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" ----
|
||||
// Poza przesunięciem pineski MUSIMY odświeżyć pole tekstowe: po „Tu i teraz"
|
||||
// zostawała w nim stara, ręcznie wpisana nazwa, więc formularz pokazywał jedno
|
||||
// miejsce, a współrzędne wskazywały już inne. reverseName pyta o nazwę punktu,
|
||||
// na który faktycznie ustawiła się mapa.
|
||||
document.addEventListener('astrololo:coords', function () {
|
||||
marker.setLatLng([curLat(), curLon()]);
|
||||
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
|
||||
reverseName(curLat(), curLon());
|
||||
resolveTz(); // „Tu i teraz" → nowe miejsce → nowa strefa
|
||||
});
|
||||
|
||||
// Zmiana daty przelicza offset — czas letni zależy od daty (inny w czerwcu
|
||||
// niż w styczniu), więc bez tego offset zostałby z poprzedniej pory roku.
|
||||
if (dateEl) dateEl.addEventListener('change', resolveTz);
|
||||
|
||||
// Na wejściu podpowiadamy strefę TYLKO gdy offset wygląda na nieustawiony
|
||||
// (0 lub pusto) — żeby nie nadpisać wartości, którą użytkownik wpisał ręcznie
|
||||
// i wysłał. Przy każdej późniejszej zmianie miejsca/daty już aktualizujemy.
|
||||
if (tzEl && (tzEl.value === '' || parseFloat(tzEl.value) === 0)) resolveTz();
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// Zapamiętana interpretacja natalna (PRE-23).
|
||||
//
|
||||
// Odpowiednik predictions.js, tylko że natalna jest JEDNA — dotyczy momentu
|
||||
// urodzenia, nie wybranego okresu. Ponowne wygenerowanie podmienia poprzednią.
|
||||
//
|
||||
// Po co magazyn: zakładka „Skompiluj" składa raport z trzech kawałków liczonych
|
||||
// w różnych miejscach (horoskop, interpretacja natalna, predykcje okresowe).
|
||||
// Bez zapamiętania trzeba by je generować od nowa na jednej stronie.
|
||||
//
|
||||
// localStorage, tak jak reszta stanu (PRE-21/22) — serwer zostaje bezstanowy.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var KEY = 'astrololo.natal.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
var v = JSON.parse(localStorage.getItem(KEY) || 'null');
|
||||
return (v && typeof v === 'object' && v.text) ? v : null;
|
||||
} catch (e) {
|
||||
return null; // uszkodzony wpis nie może zablokować strony
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try { localStorage.removeItem(KEY); } catch (e) { /* trudno */ }
|
||||
}
|
||||
|
||||
function val(sel) {
|
||||
var el = document.querySelector(sel);
|
||||
return el ? el.value.trim() : '';
|
||||
}
|
||||
|
||||
function save() {
|
||||
var text = val('#horoscopeText');
|
||||
if (!text) return false; // nie ma czego zapamiętać
|
||||
var entry = {
|
||||
text: text,
|
||||
person: val('input[name=person]'),
|
||||
date: val('input[name=date]'),
|
||||
time: val('input[name=time]'),
|
||||
tz_offset: val('input[name=tz_offset]'),
|
||||
lat: val('input[name=lat]'),
|
||||
lon: val('input[name=lon]'),
|
||||
saved_at: new Date().toISOString()
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(entry));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false; // przepełniony magazyn — zgłaszamy wyżej
|
||||
}
|
||||
}
|
||||
|
||||
function note(msg) {
|
||||
var el = document.getElementById('natalNote');
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
function saveCurrent() {
|
||||
var text = val('#horoscopeText');
|
||||
if (!text) return;
|
||||
if (save()) {
|
||||
note('Interpretacja natalna zapamiętana — trafi do raportu (zakładka Skompiluj).');
|
||||
} else {
|
||||
// Interpretacje bywają długie; cicha strata byłaby najgorsza.
|
||||
note('Nie udało się zapamiętać — magazyn przeglądarki pełny. '
|
||||
+ 'Usuń starsze predykcje i spróbuj ponownie.');
|
||||
}
|
||||
}
|
||||
|
||||
function ready(fn) {
|
||||
if (document.readyState !== 'loading') fn();
|
||||
else document.addEventListener('DOMContentLoaded', fn);
|
||||
}
|
||||
|
||||
ready(function () {
|
||||
if (!document.getElementById('natalNote')) return; // nie ta zakładka
|
||||
|
||||
// 1) interpretacja policzona przez okno postępu (strumień)
|
||||
document.addEventListener('astrololo:horoscope', function (e) {
|
||||
if (!e.detail || e.detail.profile === 'natal') saveCurrent();
|
||||
});
|
||||
|
||||
// 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
|
||||
// POST. Zapis nadpisuje jeden slot, więc odświeżenie niczego nie mnoży.
|
||||
if (document.querySelector('#horoscopeText')) saveCurrent();
|
||||
|
||||
var status = read();
|
||||
if (status) {
|
||||
note('W raporcie jest zapamiętana interpretacja natalna z '
|
||||
+ String(status.saved_at).slice(0, 10) + '.');
|
||||
}
|
||||
});
|
||||
|
||||
window.astrololoNatal = { read: read, clear: clear, key: KEY };
|
||||
})();
|
||||
@@ -32,6 +32,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
function (pos) {
|
||||
document.querySelector('input[name=lat]').value = pos.coords.latitude.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 ✓');
|
||||
},
|
||||
function (err) {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Zapamiętane predykcje okresowe (PRE-22).
|
||||
//
|
||||
// Kalendarz pozwala policzyć horoskop na wybrany okres. Partnerzy chcą kilku
|
||||
// okresów naraz — i żeby wszystkie trafiły potem do raportu (zakładka
|
||||
// „Skompiluj", PRE-23/24). Tu jest magazyn i lista.
|
||||
//
|
||||
// Trzymamy w localStorage, tak jak wspólne dane formularza (PRE-21): serwer
|
||||
// zostaje bezstanowy, żadne dane urodzeniowe ani treści z baz nie lądują po
|
||||
// stronie usługi. Zakładka „Skompiluj" odczyta to samo miejsce.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var KEY = 'astrololo.predictions.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
var v = JSON.parse(localStorage.getItem(KEY) || '[]');
|
||||
return Array.isArray(v) ? v : [];
|
||||
} catch (e) {
|
||||
return []; // uszkodzony wpis nie może zablokować strony
|
||||
}
|
||||
}
|
||||
|
||||
function write(list) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(list));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false; // najczęściej przepełniony magazyn
|
||||
}
|
||||
}
|
||||
|
||||
function byPeriod(a, b) {
|
||||
return String(a.from_date).localeCompare(String(b.from_date));
|
||||
}
|
||||
|
||||
// Klucz tożsamości to OKRES. Ponowne policzenie tego samego zakresu ma
|
||||
// podmienić wpis, a nie dokładać duplikat — inaczej lista puchłaby przy
|
||||
// każdej próbie z innym modelem.
|
||||
function upsert(entry) {
|
||||
var list = read().filter(function (p) {
|
||||
return !(p.from_date === entry.from_date && p.to_date === entry.to_date);
|
||||
});
|
||||
list.push(entry);
|
||||
list.sort(byPeriod);
|
||||
return write(list) ? list : null;
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
var list = read().filter(function (p) { return p.id !== id; });
|
||||
write(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
function val(sel) {
|
||||
var el = document.querySelector(sel);
|
||||
return el ? el.value.trim() : '';
|
||||
}
|
||||
|
||||
/** Wyciąga świeżo policzony horoskop okresowy ze strony. */
|
||||
function capture() {
|
||||
var text = val('#horoscopeText');
|
||||
var from = val('input[name=from_date]');
|
||||
var to = val('input[name=to_date]');
|
||||
if (!text || !from || !to) return null;
|
||||
return {
|
||||
id: from + '..' + to,
|
||||
from_date: from,
|
||||
to_date: to,
|
||||
person: val('input[name=person]'),
|
||||
text: text,
|
||||
saved_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
return String(iso || '').slice(0, 10);
|
||||
}
|
||||
|
||||
function render() {
|
||||
var host = document.getElementById('savedPredictions');
|
||||
if (!host) return;
|
||||
var list = read();
|
||||
if (!list.length) {
|
||||
host.innerHTML = '<p class="muted small">Brak zapamiętanych predykcji. ' +
|
||||
'Policz horoskop na wybrany okres — zapamięta się sam i trafi do raportu.</p>';
|
||||
return;
|
||||
}
|
||||
var rows = list.map(function (p) {
|
||||
var chars = (p.text || '').length;
|
||||
return '<tr>' +
|
||||
'<td class="nowrap">' + fmtDate(p.from_date) + ' – ' + fmtDate(p.to_date) + '</td>' +
|
||||
'<td class="muted small">' + fmtDate(p.saved_at) + '</td>' +
|
||||
'<td class="muted small">' + chars.toLocaleString('pl-PL') + ' znaków</td>' +
|
||||
'<td><button type="button" class="ghost" data-drop="' +
|
||||
String(p.id).replace(/"/g, '"') + '">Usuń</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
host.innerHTML =
|
||||
'<div class="meta">Zapamiętane predykcje (' + list.length + ') — wejdą do raportu</div>' +
|
||||
'<table class="angles"><thead><tr><th>Okres</th><th>Zapisano</th>' +
|
||||
'<th>Objętość</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
|
||||
Array.prototype.forEach.call(host.querySelectorAll('[data-drop]'), function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
remove(btn.getAttribute('data-drop'));
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveCurrent() {
|
||||
var entry = capture();
|
||||
if (!entry) return;
|
||||
var note = document.getElementById('predictionsNote');
|
||||
if (upsert(entry)) {
|
||||
if (note) note.textContent = 'Zapamiętano predykcję ' +
|
||||
entry.from_date + ' – ' + entry.to_date + '.';
|
||||
} else if (note) {
|
||||
// Horoskopy bywają długie; gdy magazyn się przepełni, trzeba powiedzieć
|
||||
// wprost, zamiast po cichu zgubić wynik.
|
||||
note.textContent = 'Nie udało się zapamiętać — magazyn przeglądarki pełny. ' +
|
||||
'Usuń starsze predykcje i spróbuj ponownie.';
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function ready(fn) {
|
||||
if (document.readyState !== 'loading') fn();
|
||||
else document.addEventListener('DOMContentLoaded', fn);
|
||||
}
|
||||
|
||||
ready(function () {
|
||||
if (!document.getElementById('savedPredictions')) return; // nie ta zakładka
|
||||
render();
|
||||
|
||||
// 1) horoskop policzony przez okno postępu (strumień)
|
||||
document.addEventListener('astrololo:horoscope', function (e) {
|
||||
if (!e.detail || e.detail.profile === 'period') saveCurrent();
|
||||
});
|
||||
|
||||
// 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
|
||||
// POST. Zapis jest idempotentny (klucz = okres), więc odświeżenie strony
|
||||
// niczego nie duplikuje.
|
||||
if (document.querySelector('#horoscopeText')) saveCurrent();
|
||||
});
|
||||
|
||||
// Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy.
|
||||
window.astrololoPredictions = { read: read, remove: remove, key: KEY };
|
||||
})();
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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' });
|
||||
// Gotowy horoskop ogłaszamy zdarzeniem, zamiast zapisywać go tutaj:
|
||||
// to okno postępu, a nie magazyn. Nasłuchuje predictions.js (PRE-22).
|
||||
document.dispatchEvent(new CustomEvent('astrololo:horoscope', {
|
||||
detail: { profile: profile }
|
||||
}));
|
||||
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,69 @@ h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||
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; }
|
||||
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
|
||||
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
|
||||
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
|
||||
.wheel-fig figcaption { margin-top: .4rem; }
|
||||
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
|
||||
svg.wheel text.glyph { font-size: inherit; }
|
||||
|
||||
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
|
||||
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
|
||||
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
|
||||
.aspectarian-fig figcaption { margin-top: .4rem; }
|
||||
svg.aspectarian text.glyph { font-size: inherit; }
|
||||
|
||||
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
|
||||
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
|
||||
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
|
||||
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
|
||||
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
|
||||
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
|
||||
|
||||
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
|
||||
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
|
||||
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
|
||||
zostać na wierzchu. */
|
||||
.wheel-fig svg.wheel { cursor: zoom-in; }
|
||||
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
||||
.wheel-fig.zoomed {
|
||||
position: fixed; inset: 0; z-index: 1100; margin: 0;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: .5rem; padding: 1rem; background: var(--bg);
|
||||
}
|
||||
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
|
||||
bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
|
||||
.wheel-fig.zoomed svg.wheel {
|
||||
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
|
||||
}
|
||||
body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */
|
||||
|
||||
/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
|
||||
koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
|
||||
(1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
|
||||
kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
|
||||
.wheel-tip {
|
||||
position: fixed; z-index: 1150; pointer-events: none;
|
||||
max-width: 22rem; padding: .4rem .6rem;
|
||||
background: var(--panel); color: var(--fg, inherit);
|
||||
border: 1px solid var(--line); border-radius: 8px;
|
||||
font-size: .85rem; line-height: 1.35;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
|
||||
}
|
||||
.wheel-tip[hidden] { display: none; }
|
||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||
.row { display: flex; gap: .5rem; }
|
||||
.row input[type=text] { flex: 1; }
|
||||
@@ -42,11 +105,48 @@ input, select, button {
|
||||
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
|
||||
button:hover { filter: brightness(1.1); }
|
||||
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
|
||||
|
||||
/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
|
||||
poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
|
||||
nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
|
||||
kolumn jest po prostu mniej. */
|
||||
.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
|
||||
gap: .35rem 1rem; align-items: start; }
|
||||
.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
|
||||
.opts.systems label { display: flex; align-items: center; gap: .35rem; }
|
||||
.opts .hint { font-style: normal; opacity: .75; }
|
||||
.opts input[type=number] { width: 5rem; }
|
||||
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
||||
|
||||
/* Fallback systemu domów. Celowo NIE jest to dyskretny „muted" — po fallbacku
|
||||
kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
|
||||
podział domów pochodzi z innego systemu, niż zamówiono. */
|
||||
.house-warning {
|
||||
margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
|
||||
border: 1px solid var(--warn-border, #b8860b);
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--warn-bg, rgba(184, 134, 11, .10));
|
||||
color: var(--warn-fg, inherit);
|
||||
}
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
.empty { color: var(--muted); }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
|
||||
|
||||
/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
|
||||
przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
|
||||
1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
|
||||
się sama tabela, nigdy strona. */
|
||||
.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
|
||||
.table-scroll > table { margin-top: 0; border-radius: 0; }
|
||||
.table-scroll th, .table-scroll td { white-space: nowrap; }
|
||||
|
||||
/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
|
||||
nie wiadomo, który to wiersz. */
|
||||
.table-scroll th:first-child, .table-scroll td:first-child {
|
||||
position: sticky; left: 0; background: var(--panel); z-index: 1;
|
||||
box-shadow: 1px 0 0 var(--line);
|
||||
}
|
||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
@@ -64,3 +164,86 @@ tr:last-child td { border-bottom: none; }
|
||||
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||
.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; }
|
||||
|
||||
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
|
||||
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
|
||||
.readiness li { padding: .2rem 0; }
|
||||
.readiness .rdy-ok { color: #7fd18b; }
|
||||
.readiness .rdy-missing { color: #e0c060; }
|
||||
.report-head { margin: 1.5rem 0 .5rem; }
|
||||
.report-head h2 { margin: 0; font-size: 1.4rem; }
|
||||
/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu,
|
||||
bez przepisywania ich na HTML. */
|
||||
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
|
||||
|
||||
|
||||
/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */
|
||||
.account-card {
|
||||
margin-top: 1rem; padding: 1rem 1.25rem;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
}
|
||||
.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; }
|
||||
.account-note input { width: 100%; }
|
||||
.account-card .actions { margin-top: .9rem; }
|
||||
button.danger { background: #8b2f2f; }
|
||||
|
||||
/* Potwierdzenie operacji. Odróżnia się od .house-warning, bo tamto ostrzega,
|
||||
a to tylko potwierdza — pomylenie ich uczy ignorować oba. */
|
||||
.done-note {
|
||||
margin: 1rem 0; padding: .6rem .9rem; font-size: .9rem;
|
||||
border: 1px solid var(--line); border-left: 4px solid var(--accent);
|
||||
border-radius: 4px; background: rgba(255, 255, 255, .03);
|
||||
}
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user