Compare commits

..

1 Commits

Author SHA1 Message Date
gitea 09c3a39fa1 feat(prezentacja): eksport wyników do Excela — tabela robocza (DAN-23/PRE-10)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m32s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 3m39s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 10s
Astrolog chce PRACOWAĆ z dopasowaniami: filtrować, sortować, zaznaczać, usuwać —
najwygodniej w Excelu. Raport z logiki jest zagnieżdżony (obiekt → fasety →
próbki), więc spłaszczamy go do JEDNEJ płaskiej tabeli: wiersz = jedno
dopasowanie z bazy. Kolumny: Obiekt · Faseta · Typ · Token · Sygnifikator ·
Rozwinięcie · Opis/efekt.

- `report_export.py`: `report_to_xlsx(report)` (openpyxl). Auto-filtr + zamrożony
  nagłówek = filtrowanie/sortowanie od razu; „Opis" zawijany. Bez ozdób — materiał
  roboczy. Plik składamy TU, w prezentacji (jak PDF idzie przez render): logika
  liczy, prezentacja formatuje wyjście.
- `/interpret` dostaje akcję `export` → pobranie `.xlsx` (nie strona). Przycisk
  „Pobierz Excel" obok „Szukaj interpretacji".
- Zależność: openpyxl (czysty Python).

Zero swissepha, zero walidacji krzyżowej, zero danych od użytkownika — pierwsza
z iteracji „czysto". Testy: +7 (round-trip pliku: nagłówek, spłaszczenie, auto-
filtr/zamrożenie, pusty-bezpieczny, %, wpięcie w UI). Prezentacja 180.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 00:48:33 +02:00
74 changed files with 424 additions and 6096 deletions
-33
View File
@@ -1,33 +0,0 @@
# 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
-59
View File
@@ -1,59 +0,0 @@
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
-79
View File
@@ -71,26 +71,6 @@ jobs:
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
@@ -138,68 +118,9 @@ jobs:
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.
Binary file not shown.
-58
View File
@@ -1,58 +0,0 @@
# 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.
-195
View File
@@ -1,195 +0,0 @@
# 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).
-65
View File
@@ -1,65 +0,0 @@
# 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`).
-82
View File
@@ -1,82 +0,0 @@
# 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.
-171
View File
@@ -1,171 +0,0 @@
# 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.
-66
View File
@@ -1,66 +0,0 @@
"""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
-70
View File
@@ -1,70 +0,0 @@
"""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}
+2 -19
View File
@@ -11,7 +11,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from app import canary, link_crypto, security
from app import link_crypto, security
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.factory import build_provider
@@ -34,24 +34,7 @@ link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych")
@app.post("/search", response_model=SearchResult)
def search(query: SearchQuery) -> SearchResult:
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)}
return provider.search(query)
@app.get("/health", response_model=HealthInfo)
-7
View File
@@ -25,10 +25,3 @@ 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 []
+1 -25
View File
@@ -91,31 +91,10 @@ 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()
# 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}"
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
if hit is not None:
@@ -126,9 +105,6 @@ 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:
-2
View File
@@ -1,2 +0,0 @@
-r requirements.txt
pytest>=8.0
-50
View File
@@ -1,50 +0,0 @@
"""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
-61
View File
@@ -1,61 +0,0 @@
"""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 -62
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
from datetime import datetime
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI
from pydantic import BaseModel
import swisseph as swe
@@ -69,67 +69,6 @@ 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"}
@@ -45,12 +45,6 @@ class DataClient:
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.
+4 -52
View File
@@ -15,16 +15,6 @@ 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",
@@ -71,54 +61,16 @@ 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,
minor: bool = False,
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
) -> list[dict]:
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
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°).
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
"""
checks = {**MAJOR, **MINOR} if minor else MAJOR
out: list[dict] = []
n = len(positions)
for i in range(n):
@@ -131,7 +83,7 @@ def find_aspects(
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 checks.items():
for asp, angle in MAJOR.items():
dev = abs(sep - angle)
if dev <= allowed:
applying = _is_applying(
+12 -57
View File
@@ -35,10 +35,7 @@ def _shift_pos(pdict: dict, off: float) -> None:
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:
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
from app.engine import glyphs as GL
from app.engine.aspects import find_aspects
from app.engine.houses import mean_obliquity
@@ -52,23 +49,13 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
positions = engine.positions(moment)
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
result["aspects"] = find_aspects( # aspekty (LOG-06), konfigurowalne (PRE-06)
result["positions"], orb=aspect_orb,
luminary_bonus=aspect_luminary_bonus, minor=aspect_minor)
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
# z surowych długości/szerokości silnika.
# ε 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"
eps = mean_obliquity(Z.julian_day(moment.when_utc))
result["obliquity"] = round(eps, 6)
for pdict, obj in zip(result["positions"], positions):
dec = declination(obj.longitude, obj.latitude, eps)
@@ -105,27 +92,9 @@ 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
# 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
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
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["house_system"] = system
result["angles"] = {
"Asc": _fmt("Asc", asc, off),
"MC": _fmt("MC", mc, off),
@@ -134,30 +103,16 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
}
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
result["cusps"] = [
{"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(cusp_list)
]
for pdict, obj in zip(result["positions"], positions):
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
+8 -518
View File
@@ -8,31 +8,13 @@ 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"
# 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)
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
def mean_obliquity(tt_jd: float) -> float:
@@ -49,27 +31,12 @@ 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 = norm360(math.degrees(math.atan2(
asc = math.atan2(
math.cos(r),
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
)))
mc = compute_mc(ramc_deg, eps_deg)
return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else asc
)
return norm360(math.degrees(asc))
def _trisect(a: float, b: float) -> tuple[float, float]:
@@ -78,29 +45,10 @@ 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(_snap_to_sign_boundary(asc)) * 30.0
start = sign_index(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)]
@@ -116,472 +64,14 @@ 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 0180°), 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 49 naprzeciw 103
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 (PolichPage).
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
# PolichPage: 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 611% 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.
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)."""
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
lon = norm360(lon)
forward = _runs_forward(cusp_list)
for i in range(12):
start = cusp_list[i]
end = cusp_list[(i + 1) % 12]
if forward:
span, offset = (end - start) % 360.0, (lon - start) % 360.0
else:
span, offset = (start - end) % 360.0, (start - lon) % 360.0
span = (end - start) % 360.0
offset = (lon - start) % 360.0
if offset < span:
return i + 1
return 12
+4 -20
View File
@@ -117,28 +117,12 @@ 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)
return ramc, true_obliquity(t)
eps = mean_obliquity(t.tt)
return ramc, eps
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
+2 -64
View File
@@ -43,11 +43,7 @@ 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 (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°)
house_system: str = "whole_sign" # whole_sign | equal | porphyry
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
@@ -71,11 +67,7 @@ def chart_positions(req: PositionsRequest) -> dict:
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
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)
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if req.stations:
@@ -92,60 +84,6 @@ def chart_positions(req: PositionsRequest) -> dict:
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.
-5
View File
@@ -200,11 +200,6 @@ def _chart_section(chart: dict, moment_label: str | None) -> str:
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"]:
+1 -58
View File
@@ -1,62 +1,5 @@
"""Testy aspektów (LOG-06) — czysta matematyka."""
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
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
def test_separation_wraparound():
-67
View File
@@ -2,9 +2,6 @@
Waliduje względem astro.com dla horoskopu referencyjnego.
"""
import pytest
from app.engine import houses as H
from app.engine.chart import build_chart
@@ -28,67 +25,3 @@ 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)
-307
View File
@@ -1,8 +1,4 @@
"""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)
@@ -43,306 +39,3 @@ def test_assign_house_whole_sign():
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90120°)
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,223,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
-187
View File
@@ -1,187 +0,0 @@
"""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()
class AccountsUnavailable(RuntimeError):
"""Pliku kont nie da się odczytać albo zapisać — problem z magazynem.
Osobny wyjątek, bo to NIE JEST błąd danych: konta mogą być w porządku, tylko
katalog jest niedostępny (złe prawa na udziale, nieprzemontowany wolumen,
brak miejsca). Ekran ma wtedy POWIEDZIEĆ, co i gdzie jest nie tak — gołe 500
zostawia administratora z niczym, a to jedyny ekran, z którego może to
naprawić."""
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:
path = store_path()
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except FileNotFoundError:
# Pierwsze uruchomienie: pliku jeszcze nie ma i to jest normalne.
return {"users": {}}
except json.JSONDecodeError:
# Plik jest, ale nieczytelny. NIE nadpisujemy go pustym zbiorem —
# to skasowałoby wszystkie konta przy pierwszym zapisie.
raise AccountsUnavailable(
f"Plik kont {path} jest uszkodzony (niepoprawny JSON). "
f"Nic nie zostało zmienione — napraw albo usuń plik ręcznie.") from None
except OSError as e:
raise AccountsUnavailable(
f"Nie mogę odczytać pliku kont {path}: {e.strerror or e}. "
f"Najczęstsza przyczyna: prawa do katalogu na udziale albo "
f"niezamontowany wolumen.") from e
users = data.get("users")
return {"users": users if isinstance(users, dict) else {}}
def _write(data: dict) -> None:
path = store_path()
try:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
except OSError as e:
raise AccountsUnavailable(
f"Nie mogę utworzyć katalogu na plik kont ({os.path.dirname(path)}): "
f"{e.strerror or e}.") from e
# Plik tymczasowy MUSI powstać w tym samym katalogu — os.replace jest
# niepodzielne tylko w obrębie jednego systemu plików.
# mkstemp też musi być w klamrze: przy katalogu tylko do odczytu wywala się
# ONO pierwsze, jeszcze zanim dojdzie do zapisu i podmiany.
try:
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".tmp")
except OSError as e:
raise AccountsUnavailable(
f"Nie mogę zapisać pliku kont {path}: {e.strerror or e}. "
f"Najczęstsza przyczyna: udział zamontowany tylko do odczytu albo "
f"prawa katalogu. Nic nie zostało zmienione.") from e
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 OSError as e:
try:
os.unlink(tmp)
except OSError:
pass
raise AccountsUnavailable(
f"Nie mogę zapisać pliku kont {path}: {e.strerror or e}. "
f"Najczęstsza przyczyna: udział zamontowany tylko do odczytu albo "
f"prawa katalogu. Nic nie zostało zmienione.") from e
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)
+27 -43
View File
@@ -114,15 +114,8 @@ def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
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))
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
phi = math.radians(180.0 - (lon - asc))
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
@@ -213,19 +206,12 @@ def _delta(a: float, b: float) -> float:
return abs(((a - b + 180.0) % 360.0) - 180.0)
def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> str:
def render(chart: dict, theme: str = "screen") -> 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)
@@ -237,9 +223,6 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
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
@@ -261,8 +244,8 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
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)
ax1, ay1 = _pt(la, asc, R_HUB)
bx1, by1 = _pt(lb, asc, 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))
@@ -270,17 +253,17 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
# 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)
x1, y1 = _pt(deg, asc, R_ZOD)
x2, y2 = _pt(deg, asc, 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)
x1, y1 = _pt(b, asc, R_ZOD)
x2, y2 = _pt(b, asc, R_OUT)
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
gx, gy = _pt(b + 15.0, ref, R_SIGN)
gx, gy = _pt(b + 15.0, asc, R_SIGN)
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
@@ -288,31 +271,31 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
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)
x1, y1 = _pt(lon, asc, R_ZOD)
x2, y2 = _pt(lon, asc, 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)
hx, hy = _pt(mid, asc, 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)
cx1, cy1 = _pt(lon, asc, R_ZOD - 9)
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
# osie: AscDsc i MCIC 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)
ax, ay = _pt(lon_a, asc, R_ZOD)
bx, by = _pt(lon_a + 180.0, asc, 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)
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
lbx, lby = _pt(lon_a + 180.0, asc, 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"]))
@@ -327,15 +310,15 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
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)
mx1, my1 = _pt(true_lon, asc, R_ZOD)
mx2, my2 = _pt(true_lon, asc, 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)
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
gx, gy = _pt(draw_lon, ref, R_PLANET)
gx, gy = _pt(draw_lon, asc, R_PLANET)
tip = obj.get("name") or ""
if obj.get("in_sign"):
tip += f" · {obj['in_sign']}"
@@ -349,7 +332,7 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
# 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)
dx, dy = _pt(draw_lon, asc, 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"]))
@@ -363,10 +346,10 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
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)
mx1, my1 = _pt(lon, asc, R_ZOD)
mx2, my2 = _pt(lon, asc, R_MARK)
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
lx, ly = _pt(lon, ref, R_LOT)
lx, ly = _pt(lon, asc, R_LOT)
tip = lot.get("name") or ""
if lot.get("in_sign"):
tip += f" · {lot['in_sign']}"
@@ -384,6 +367,7 @@ def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> s
# ── 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.
@@ -54,10 +54,6 @@ class LogicClient:
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 = {
@@ -66,10 +62,6 @@ 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,
@@ -78,18 +70,6 @@ class LogicClient:
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]:
@@ -152,12 +132,6 @@ class LogicClient:
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:
-123
View File
@@ -1,123 +0,0 @@
"""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)
@@ -1,39 +0,0 @@
"""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)
+19 -317
View File
@@ -10,106 +10,26 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
# 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, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
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
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 = FastAPI(title="astrololo · warstwa prezentacji")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
# ── 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]:
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
@@ -130,18 +50,6 @@ def _llm_catalog() -> dict:
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 (
@@ -154,16 +62,6 @@ def _logic_error(e: Exception) -> str:
# ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse)
def chart_form(request: Request):
# 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,
@@ -181,42 +79,26 @@ def chart_compute(
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 = {"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)
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
"zodiac": zodiac, "tables": tables}
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"],
house_system=house_system, stations=stations, zodiac=zodiac, tables=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
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
@@ -243,49 +125,30 @@ def compile_build(
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.
Dlaczego horoskop liczymy ponownie, zamiast go zapamiętywać: to czysta funkcja
danych wejściowych — tanio ją powtórzyć, a odpada trzymanie w przeglądarce
dużego wyniku, który mógłby się rozjechać z aktualnym formularzem.
"""
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)
"lat": lat, "lon": lon, "house_system": house_system, "zodiac": zodiac}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=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"],
house_system=house_system, zodiac=zodiac,
)
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
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
@@ -318,32 +181,20 @@ def compile_pdf(payload: dict):
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(chart, theme="print"), "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"),
@@ -358,7 +209,6 @@ def compile_pdf(payload: dict):
"person": payload.get("person") or "",
"data": {**data, "moment_utc": label},
"figures": figures,
"warnings": warnings,
"natal": payload.get("natal") or {},
"predictions": payload.get("predictions") or [],
}
@@ -373,66 +223,6 @@ def compile_pdf(payload: dict):
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):
@@ -451,8 +241,6 @@ 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)
@@ -498,9 +286,6 @@ def interpret_run(
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",
@@ -518,7 +303,6 @@ def interpret_run(
)
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:
@@ -658,88 +442,6 @@ def geocode_reverse(lat: float, lon: float):
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:
# Problem z magazynem NIE MOŻE dawać gołego 500: to jedyny ekran, z którego
# administrator może go naprawić, więc musi na nim przeczytać, co i gdzie
# jest nie tak.
try:
users = accounts_store.all_users()
except accounts_store.AccountsUnavailable as e:
users, error = {}, str(e)
return {
"users": 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, accounts_store.AccountsUnavailable) 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, accounts_store.AccountsUnavailable) 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, accounts_store.AccountsUnavailable) 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"}
+22 -207
View File
@@ -5,44 +5,27 @@ na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc
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.
Dwa mechanizmy:
* **HTTP Basic** — wejście do aplikacji; włącza się, gdy ustawiono APP_PASSWORD.
* **limit żądań** — hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań);
rozliczany per adres klienta, a za odwrotnym proxy — po TRUST_PROXY=true —
per adres z nagłówka, nie per adres proxy (patrz `client_ip`).
* **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.
Świadomie NIE logujemy treści żądań ani promptów — logi to kolejny nośnik wycieku.
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.
UWAGA: bez APP_PASSWORD ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev).
Wtedy przy starcie leci głośne ostrzeżenie — żeby nikt nie wdrożył tego w
przekonaniu, że jest chroniony.
"""
from __future__ import annotations
import base64
import binascii
import 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
@@ -62,54 +45,6 @@ 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"))
@@ -125,70 +60,25 @@ _hits: dict[str, deque[float]] = {}
def auth_enabled() -> bool:
return bool(accounts())
return bool(app_password())
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."""
def _authorized(header: str | None) -> bool:
if not header or not header.lower().startswith("basic "):
return None
return False
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
return False
# porównanie odporne na atak czasowy; oba pola muszą się zgadzać
ok_user = secrets.compare_digest(user, app_user())
ok_pass = secrets.compare_digest(password, app_password())
return ok_user and ok_pass
def client_ip(request: Request) -> str:
@@ -234,62 +124,12 @@ def _rate_limited(client: str) -> bool:
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."
"UWAGA: APP_PASSWORD nie ustawione — aplikacja jest OTWARTA dla każdego, "
"kto ma do niej dostęp sieciowy, wraz z treścią baz interpretacyjnych."
)
@app.middleware("http")
@@ -297,40 +137,15 @@ def install(app) -> None:
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
if auth_enabled() and not _authorized(request.headers.get("authorization")):
return HTMLResponse(
"<h1>401 — wymagane logowanie</h1>", status_code=401,
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
)
return await call_next(request)
@@ -93,25 +93,10 @@
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 || ''
};
}
+22 -52
View File
@@ -20,34 +20,18 @@
// 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' }
{ 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' }
];
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) || '{}') || {};
@@ -66,35 +50,21 @@
function apply(state) {
FIELDS.forEach(function (f) {
var els = elementsOf(f);
if (!els.length) return;
var el = document.querySelector(f.sel);
if (!el) 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;
}
if (v === undefined || v === null || v === '') return;
if (el.value !== v) el.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;
}
var el = document.querySelector(f.sel);
if (!el) return; // pola nieobecnego na tej zakładce nie kasujemy
if (el.value === '' || el.value === null) return;
state[f.key] = el.value;
});
return state;
}
@@ -102,12 +72,12 @@
// 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)
// 2) zapisuj każdą zmianę (input dla wpisywania, change dla list i kalendarzy)
FIELDS.forEach(function (f) {
elementsOf(f).forEach(function (el) {
['input', 'change'].forEach(function (ev) {
el.addEventListener(ev, function () { save(collect()); });
});
var el = document.querySelector(f.sel);
if (!el) return;
['input', 'change'].forEach(function (ev) {
el.addEventListener(ev, function () { save(collect()); });
});
});
-47
View File
@@ -23,21 +23,6 @@
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); };
@@ -68,28 +53,6 @@
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) {
@@ -167,16 +130,6 @@
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();
});
})();
@@ -81,20 +81,6 @@ svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
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; }
@@ -105,48 +91,11 @@ 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; }
@@ -228,22 +177,3 @@ textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
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);
}
@@ -1,97 +0,0 @@
// Dymki na kosmogramie — własne, zamiast natywnych z <title> (PRE-12/PRE-25).
//
// DLACZEGO NIE ZOSTAWIAMY NATYWNYCH. Rysunek niesie <title> przy każdym obiekcie
// i każdej linii aspektu i to wystarczało, dopóki koło było statyczne. Po dodaniu
// powiększania na pełne okno dymki przestały się pokazywać — a niezależnie od tego,
// która reguła przeglądarki je tłumi, natywny dymek i tak jest tu kiepskim
// narzędziem: pojawia się po sekundzie zwłoki, nie da się go stylować, nie działa
// na dotyku i nie ma go czym wywołać z klawiatury.
//
// Sprawdzone w przeglądarce (1280×900, komplet 13 systemów): elementy z <title>
// SĄ osiągalne kursorem — 19 z 20 trafień w hit-teście — więc nic ich nie zasłania
// i problem nie leży w geometrii ani w pointer-events.
//
// <title> ZOSTAJE w rysunku: to nazwa dostępna dla czytników ekranu, a PDF składa
// się po stronie serwera, więc ta ścieżka jest nietknięta. Na czas najechania
// odpinamy go i wieszamy z powrotem po zejściu kursora — dzięki temu nigdy nie
// widać dwóch dymków naraz, gdyby przeglądarka jednak swój pokazała.
(function () {
'use strict';
var OFFSET = 14; // odsunięcie od kursora, żeby dymek nie siedział pod nim
var stash = null; // { owner, node } — odpięty <title> do przywieszenia
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
var wheels = document.querySelectorAll('svg.wheel');
if (!wheels.length) return;
var tip = document.createElement('div');
tip.className = 'wheel-tip';
tip.setAttribute('role', 'tooltip');
tip.hidden = true;
document.body.appendChild(tip);
function restore() {
if (!stash) return;
stash.owner.appendChild(stash.node);
stash = null;
}
function hide() {
tip.hidden = true;
restore();
}
// Element z własnym <title>, licząc od celu zdarzenia w górę. Aspekty to
// cienkie linie, a glify siedzą w <text> — oba trzymają <title> jako dziecko.
function owner(el, root) {
while (el && el !== root) {
if (el.querySelector && el.querySelector(':scope > title')) return el;
el = el.parentNode;
}
return null;
}
function place(x, y) {
// Ustawiamy najpierw, potem mierzymy — inaczej nie znamy rozmiaru dymka.
tip.style.left = '0px';
tip.style.top = '0px';
var w = tip.offsetWidth, h = tip.offsetHeight;
var left = x + OFFSET, top = y + OFFSET;
if (left + w > window.innerWidth - 4) left = x - OFFSET - w; // odbij w lewo
if (top + h > window.innerHeight - 4) top = y - OFFSET - h; // odbij w górę
tip.style.left = Math.max(4, left) + 'px';
tip.style.top = Math.max(4, top) + 'px';
}
Array.prototype.forEach.call(wheels, function (svg) {
svg.addEventListener('mousemove', function (e) {
var el = owner(e.target, svg);
if (!el) { hide(); return; }
if (!stash || stash.owner !== el) {
restore();
var node = el.querySelector(':scope > title');
tip.textContent = node.textContent;
stash = { owner: el, node: node };
el.removeChild(node);
tip.hidden = false;
}
place(e.clientX, e.clientY);
});
svg.addEventListener('mouseleave', hide);
});
// Dymek nie może przeżyć przewinięcia ani powiększenia — zostałby wiszący
// w miejscu, w którym nic już nie ma.
window.addEventListener('scroll', hide, true);
window.addEventListener('resize', hide);
document.addEventListener('click', hide, true);
});
})();
@@ -1,38 +0,0 @@
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i „Skompiluj" (/compile). Jeden plik,
bo dodawaliśmy je już 3× w dwóch formularzach i groziło rozjechaniem (regresja
podsumowania). Wszystkie synchronizowane między zakładkami przez formsync.js. #}
{% if can(request, 'advanced_calc') %}
<div class="opts">
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
licz stacje planet (wolniejsze)</label>
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
</div>
{% endif %}
{% if can(request, 'houses_compare') %}
<div class="opts systems" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
{% set chosen = form.house_systems or [] %}
<span class="muted small opts-label">Porównaj systemy domów
<em class="hint">— gwiazdka: nie działa za kołem podbiegunowym (ok. 66,5°); tam policzymy Porphyry i wyraźnie o tym napiszemy</em>
</span>
{% for value, name in HOUSE_SYSTEMS %}
<label><input type="checkbox" name="house_systems" value="{{ value }}" {{ 'checked' if value in chosen else '' }}> {{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</label>
{% endfor %}
</div>
{% endif %}
{% if can(request, 'houses_compare') %}
<div class="opts" title="Zmienia wyłącznie OBRÓT rysunku — żadna liczba nie jest przeliczana. „Ascendent po lewej” to tradycja zachodnia: dom I zawsze zaczyna się w tym samym miejscu. „0° Barana po lewej” unieruchamia koło względem zodiaku, dzięki czemu dwa horoskopy da się porównywać na oko.">
{% set wo = form.wheel_orientation or 'asc' %}
<span class="muted small">Kosmogram — po lewej stronie:</span>
{% for value, name in WHEEL_ORIENTATIONS %}
<label><input type="radio" name="wheel_orientation" value="{{ value }}" {{ 'checked' if wo == value else '' }}> {{ name }}</label>
{% endfor %}
</div>
{% endif %}
{% if can(request, 'advanced_calc') %}
<div class="opts" title="Orb = dopuszczalne odchylenie od dokładnego kąta aspektu. „Bonus świateł" powiększa orb dla aspektów ze Słońcem/Księżycem. Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150° (PRE-06).">
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
<label><input type="checkbox" name="aspect_minor" value="true" {{ 'checked' if form.aspect_minor else '' }}> aspekty poboczne (30/45/150°)</label>
</div>
{% endif %}
@@ -1,7 +1,7 @@
{# Wyszukiwarka lokalizacji + interaktywna mapa (OpenStreetMap / Leaflet, bez klucza API).
Wklejana do formularzy z polami input[name=lat] / input[name=lon]. Samowystarczalna:
ładuje CSS/JS Leafleta (vendorowane lokalnie) oraz geo.js. Kafelki mapy lecą z OSM. #}
<link rel="stylesheet" href="{{ static('vendor/leaflet/leaflet.css') }}">
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css">
<div class="geo" id="geoPicker">
<div class="geo-search">
<input type="search" id="geoSearch" autocomplete="off"
@@ -16,5 +16,5 @@
contributors (Nominatim). Kliknij mapę lub przeciągnij pineskę, by ustawić współrzędne.
</p>
</div>
<script defer src="{{ static('vendor/leaflet/leaflet.js') }}"></script>
<script defer src="{{ static('geo.js') }}"></script>
<script defer src="/static/vendor/leaflet/leaflet.js"></script>
<script defer src="/static/geo.js"></script>
@@ -1,243 +0,0 @@
{# Tabele wyniku horoskopu — używane przez „Skompiluj" (/compile), żeby
podsumowanie pokazywało dokładnie to, co policzono (regresja 2026-07-28:
podsumowanie miało braki). Skopiowane 1:1 z sekcji tabel na „Horoskop" (/).
TODO: przełączyć też chart.html na ten include, żeby oba widoki nie mogły się
rozjechać — na razie pilnować ręcznie, że są zgodne. Rysunki (koło/aspektarian/
deklinacja/antyscja) renderuje rodzic; tu są same tabele. Każda sekcja pokazuje
się tylko, gdy jej dane są w wyniku (opcja policzona → sekcja jest). #}
{# Fallback systemu domów: system nie istniał dla tego miejsca i policzyliśmy
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
{% if result.house_warnings %}
{% for w in result.house_warnings %}
<p class="house-warning">{{ w }}</p>
{% endfor %}
{% endif %}
{% if result.angles %}
<table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
<tbody>
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
{% set a = result.angles[key] %}
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #}
{% if result.house_systems and result.house_systems | length > 1 %}
<div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div>
{# Przy komplecie systemów tabela jest szersza niż okno — przewija się SAMA,
zamiast rozpychać stronę (numer domu zostaje przyklejony do lewej). #}
<div class="table-scroll">
<table class="angles">
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ house_label(hs.system) }}</th>{% endfor %}</tr></thead>
<tbody>
{% for i in range(12) %}
<tr><td>{{ i + 1 }}</td>
{% for hs in result.house_systems %}
<td class="mono"><span class="glyph">{{ hs.cusps[i].sign_glyph }}</span> {{ hs.cusps[i].in_sign }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<table>
<thead>
<tr>
<th title="Symbol astrologiczny (LOG-22)">Sym.</th>
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
</tr>
</thead>
<tbody>
{% for p in result.positions %}
<tr>
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
<td>{{ p.name }}</td>
<td>{{ p.sign }}</td>
<td class="mono">{{ p.in_sign }}</td>
<td>{{ p.house if p.house is defined else '—' }}</td>
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
<td class="mono">{% if p.declination is defined %}{{ '%+.2f°' | format(p.declination) }}{% if p.out_of_bounds %} <span class="badge" title="Out of bounds — deklinacja poza zakresem Słońca">OOB</span>{% endif %}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if result.lots %}
<div class="meta">Lots hermetyczne ({{ result.lots | length }}) · sekta: <strong>{{ result.sect }}</strong></div>
<table class="angles">
<thead><tr><th>Lot</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Formuła</th></tr></thead>
<tbody>
{% for l in result.lots %}
<tr><td>{{ l.name }}</td><td>{{ l.sign }}</td><td class="mono">{{ l.in_sign }}</td><td>{{ l.house }}</td><td class="muted small">{{ l.formula }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
{% if with_stations %}
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
<table class="angles">
<thead><tr><th>Planeta</th><th>Poprzednia</th><th>Następna</th></tr></thead>
<tbody>
{% for p in with_stations %}
<tr>
<td>{{ p.name }}{% if p.stations.station_soon %} <span class="badge">blisko</span>{% endif %}</td>
<td class="mono">{% if p.stations.prev %}{{ p.stations.prev.type }} · {{ p.stations.prev.date }} · {{ p.stations.prev.degree }} ({{ p.stations.prev.days }} d){% else %}—{% endif %}</td>
<td class="mono">{% if p.stations.next %}{{ p.stations.next.type }} · {{ p.stations.next.date }} · {{ p.stations.next.degree }} (+{{ p.stations.next.days }} d){% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.aspects %}
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
<tbody>
{% for a in result.aspects %}
<tr><td>{{ a.obj1 }}</td><td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.parallels %}
<div class="meta" title="Ciała na tej samej (paralela) lub przeciwnej (kontrparalela) deklinacji — działają jak koniunkcja / opozycja poza ekliptyką">Paralele deklinacji ({{ result.parallels | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th><th>Dekl.</th></tr></thead>
<tbody>
{% for r in result.parallels %}
<tr>
<td>{{ r.obj1 }}</td>
<td>{{ 'paralela' if r.type == 'parallel' else 'kontrparalela' }}</td>
<td>{{ r.obj2 }}</td>
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
<td class="mono">{{ '%+.2f'|format(r.dec1) }} / {{ '%+.2f'|format(r.dec2) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.antiscia %}
<div class="meta" title="Odbicie względem osi przesileń (antyscja) lub równonocy (kontrantyscja) — punkty „dzielące” tę samą długość dnia">Antyscja ({{ result.antiscia | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
<tbody>
{% for r in result.antiscia %}
<tr>
<td>{{ r.obj1 }}</td>
<td>{{ 'antyscja' if r.type == 'antiscion' else 'kontrantyscja' }}</td>
<td>{{ r.obj2 }}</td>
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.tables %}
{% set tb = result.tables %}
<div class="meta">Tabele dodatkowe (LOG-23)</div>
{% set bal = tb.tally.with_modern_10_plus_asc %}
<table class="angles">
<thead><tr><th>Bilans (10 planet + Asc)</th><th colspan="4">Rozkład</th></tr></thead>
<tbody>
<tr><td>Żywioły</td>
{% for e, n in bal.elements.items() %}
<td>{{ tb.tally.labels.elements[e] }}: <strong>{{ n }}</strong></td>
{% endfor %}
</tr>
<tr><td>Jakości</td>
{% for q, n in bal.qualities.items() %}
<td>{{ tb.tally.labels.qualities[q] }}: <strong>{{ n }}</strong></td>
{% endfor %}
<td></td>
</tr>
</tbody>
</table>
{% if tb.tally.missing_elements or tb.tally.missing_qualities %}
<p class="muted small">Brak:
{% for e in tb.tally.missing_elements %}<strong>{{ tb.tally.labels.elements[e] }}</strong>{{ ", " if not loop.last }}{% endfor %}
{% for q in tb.tally.missing_qualities %}<strong>{{ tb.tally.labels.qualities[q] }}</strong>{{ ", " if not loop.last }}{% endfor %}
</p>
{% endif %}
{% if tb.moon_phase %}
<p class="muted small">
<strong>Faza Księżyca:</strong> {{ tb.moon_phase.phase_pl }} ·
elongacja {{ '%.2f'|format(tb.moon_phase.angle) }}° ·
oświetlenie {{ '%.1f'|format(tb.moon_phase.illumination * 100) }}% ·
{{ 'przybywa' if tb.moon_phase.waxing else 'ubywa' }}
</p>
{% endif %}
{% if tb.planetary_hours %}
{% set ph = tb.planetary_hours %}
<p class="muted small">
<strong>Godziny planetarne:</strong> władca dnia {{ ph.day_ruler }} ·
{{ ph.hour_number }}. godzina ({{ ph.period }}), władca {{ ph.hour_ruler }} ·
godzina trwa {{ ph.hour_length_minutes }} min
</p>
{% endif %}
{% if tb.prenatal_syzygy %}
{% set s = tb.prenatal_syzygy %}
<p class="muted small">
<strong>Syzygia prenatalna:</strong> {{ s.type_pl }} {{ s.when_utc }}
({{ s.days_before_birth }} dni przed) w {{ s.in_sign }}
</p>
{% endif %}
{% if tb.critical_degrees %}
<div class="meta">Stopnie krytyczne</div>
<table class="angles">
<thead><tr><th>Obiekt</th><th>Pozycja</th><th>Uwaga</th></tr></thead>
<tbody>
{% for c in tb.critical_degrees %}
<tr><td>{{ c.name }}</td><td class="mono">{{ c.in_sign }}</td>
<td class="muted small">{{ c.flags | join('; ') }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<details class="loc">
<summary>Podziały: dwunastniki (D12) i nawamsa (D9)</summary>
<table>
<thead><tr><th>Obiekt</th><th>D12</th><th>D9</th></tr></thead>
<tbody>
{% for d in tb.divisional %}
<tr><td>{{ d.name }}</td><td class="mono">{{ d.d12_in_sign }}</td>
<td class="mono">{{ d.d9_in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% if result.cusps %}
<details class="loc">
<summary>Cusps domów ({{ house_label(result.house_system) }})</summary>
<table>
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
<tbody>
{% for c in result.cusps %}
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
@@ -1,90 +0,0 @@
{% extends "base.html" %}
{% set nav_active = "accounts" %}
{% block title %}Konta{% endblock %}
{% block content %}
<p class="muted">
Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong>
pochodzi z konfiguracji środowiska, ma wszystkie uprawnienia i jest jedynym,
które może zarządzać pozostałymi — dlatego nie ma go na tej liście i nie da się
go stąd skasować.
</p>
<p class="muted small">
Osoba na koncie ograniczonym widzi wyłącznie przyznane jej zakładki i opcje.
Nic w programie nie pokazuje jej, że istnieje coś więcej — niedostępne adresy
odpowiadają tak samo jak nieistniejące.
</p>
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Konta ({{ users | length }})</div>
{% if not users %}
<p class="muted">Nie ma jeszcze żadnego konta poza administracyjnym.</p>
{% endif %}
{% for login, row in users | dictsort %}
<form method="post" action="/accounts/update" class="account-card">
<input type="hidden" name="login" value="{{ login }}">
<div class="account-head">
<strong>{{ login }}</strong>
<span class="muted small">{{ row.created[:10] }}</span>
</div>
<label class="account-note">Opis
<input type="text" name="note" value="{{ row.note }}" placeholder="np. do czego to konto służy">
</label>
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
<div class="opts systems" title="{{ 'Zakładki widoczne w nawigacji.' if group == 'Ekrany' else 'Poziomy złożoności wewnątrz ekranów.' }}">
<span class="muted small opts-label">{{ group }}</span>
{% for f in items %}
<label title="{{ f.hint }}">
<input type="checkbox" name="granted" value="{{ f.key }}"
{{ 'checked' if f.key in row.features else '' }}> {{ f.label }}
</label>
{% endfor %}
</div>
{% endfor %}
<div class="opts">
<label>Nowe hasło (puste = bez zmiany)
<input type="password" name="password" autocomplete="new-password" placeholder="••••••">
</label>
</div>
<div class="actions">
<button type="submit">Zapisz</button>
<button type="submit" formaction="/accounts/delete" class="danger"
onclick="return confirm('Skasować konto {{ login }}? Tej operacji nie da się cofnąć.')">
Skasuj konto
</button>
</div>
</form>
{% endfor %}
<div class="meta">Nowe konto</div>
<form method="post" action="/accounts/create" class="account-card">
<div class="grid">
<label>Login <input type="text" name="login" required autocomplete="off"></label>
<label>Hasło <input type="password" name="password" required autocomplete="new-password"></label>
<label>Opis <input type="text" name="note" placeholder="np. do czego to konto służy"></label>
</div>
{% for group, items in [("Ekrany", screens), ("Rozszerzenia", extras)] %}
<div class="opts systems">
<span class="muted small opts-label">{{ group }}</span>
{% for f in items %}
<label title="{{ f.hint }}"><input type="checkbox" name="granted" value="{{ f.key }}"> {{ f.label }}</label>
{% endfor %}
</div>
{% endfor %}
<div class="actions"><button type="submit">Załóż konto</button></div>
</form>
<details class="loc">
<summary>Gdzie to jest zapisane</summary>
<p class="muted small">
Konta trzymamy w pliku <code>{{ store_path }}</code> — hasła wyłącznie jako
hash scrypt, nigdy jawnie. Zapis jest atomowy, więc przerwanie w trakcie nie
obetnie pliku i nie skasuje wszystkich kont naraz.
</p>
</details>
{% endblock %}
+8 -13
View File
@@ -4,31 +4,26 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="{{ static('styles.css') }}">
<link rel="stylesheet" href="/static/styles.css">
{# Wspólne dane formularza między zakładkami (PRE-21). W <head> z `defer`
CELOWO: skrypty defer wykonują się w kolejności dokumentu, więc ten zdąży
odtworzyć współrzędne, ZANIM geo.js zbuduje mapę — mapa startuje od razu
we właściwym miejscu, bez przestawiania. #}
<script defer src="{{ static('formsync.js') }}"></script>
<script defer src="/static/formsync.js"></script>
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
<script defer src="{{ static('wheelzoom.js') }}"></script>
<script defer src="{{ static('wheeltip.js') }}"></script>
<script defer src="/static/wheelzoom.js"></script>
</head>
<body>
<main>
<header class="topbar">
<h1>astrololo</h1>
{# Zakładki WYŁĄCZNIE z uprawnień konta. Nie wyszarzamy niedostępnych —
ich po prostu nie ma, bo sama obecność wyszarzonej pozycji mówi, że
program umie więcej, niż pokazuje (PRE-26). #}
<nav>
{% for tab in nav_screens(request) %}
<a href="{{ tab.href }}" class="{{ 'active' if nav_active is defined and nav_active == tab.key else '' }}">{{ tab.label }}</a>
{% endfor %}
{% if can(request, 'admin') %}
<a href="/accounts" class="{{ 'active' if nav_active is defined and nav_active == 'accounts' else '' }}">Konta</a>
{% endif %}
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
</nav>
</header>
{% block content %}{% endblock %}
+219 -16
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Horoskop{% endblock %}
{% set nav_active = "chart" %}
{% block nav_chart %}active{% endblock %}
{% block content %}
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
@@ -31,17 +31,14 @@
<label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
{% if can(request, 'houses_compare') %}
<label>System domów
<select name="house_system">
{% set hs = form.house_system or 'whole_sign' %}
{% for value, name in HOUSE_SYSTEMS %}
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
{% endfor %}
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
</select>
</label>
{% endif %}
{% if can(request, 'advanced_calc') %}
<label>Zodiak
<select name="zodiac">
{% set zd = form.zodiac or 'tropical' %}
@@ -52,10 +49,14 @@
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select>
</label>
{% endif %}
</div>
{% include "_location_picker.html" %}
{% include "_form_options.html" %}
<div class="opts">
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
licz stacje planet (wolniejsze)</label>
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
</div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
@@ -69,14 +70,10 @@
{% endif %}
{% if result %}
{# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #}
{% for w in result.house_warnings or [] %}
<p class="house-warning">{{ w }}</p>
{% endfor %}
<div class="meta">
Silnik: <strong>{{ result.engine }}</strong> ·
obiektów: {{ result.positions | length }}
{% if result.house_system %}· domy: {{ house_label(result.house_system) }}{% endif %}
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
{% if result.zodiac %}· zodiak: {{ result.zodiac }}{% if result.ayanamsha is defined %} (ayanamsa {{ '%.4f'|format(result.ayanamsha) }}°){% endif %}{% endif %}
{% if moment %}· moment: {{ moment }}{% endif %}
</div>
@@ -95,6 +92,84 @@
</figure>
{% endif %}
{% if result.angles %}
<table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
<tbody>
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
{% set a = result.angles[key] %}
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<table>
<thead>
<tr>
<th title="Symbol astrologiczny (LOG-22)">Sym.</th>
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
</tr>
</thead>
<tbody>
{% for p in result.positions %}
<tr>
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
<td>{{ p.name }}</td>
<td>{{ p.sign }}</td>
<td class="mono">{{ p.in_sign }}</td>
<td>{{ p.house if p.house is defined else '—' }}</td>
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
<td class="mono">{% if p.declination is defined %}{{ '%+.2f°' | format(p.declination) }}{% if p.out_of_bounds %} <span class="badge" title="Out of bounds — deklinacja poza zakresem Słońca">OOB</span>{% endif %}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if result.lots %}
<div class="meta">Lots hermetyczne ({{ result.lots | length }}) · sekta: <strong>{{ result.sect }}</strong></div>
<table class="angles">
<thead><tr><th>Lot</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Formuła</th></tr></thead>
<tbody>
{% for l in result.lots %}
<tr><td>{{ l.name }}</td><td>{{ l.sign }}</td><td class="mono">{{ l.in_sign }}</td><td>{{ l.house }}</td><td class="muted small">{{ l.formula }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
{% if with_stations %}
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
<table class="angles">
<thead><tr><th>Planeta</th><th>Poprzednia</th><th>Następna</th></tr></thead>
<tbody>
{% for p in with_stations %}
<tr>
<td>{{ p.name }}{% if p.stations.station_soon %} <span class="badge">blisko</span>{% endif %}</td>
<td class="mono">{% if p.stations.prev %}{{ p.stations.prev.type }} · {{ p.stations.prev.date }} · {{ p.stations.prev.degree }} ({{ p.stations.prev.days }} d){% else %}—{% endif %}</td>
<td class="mono">{% if p.stations.next %}{{ p.stations.next.type }} · {{ p.stations.next.date }} · {{ p.stations.next.degree }} (+{{ p.stations.next.days }} d){% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.aspects %}
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
<tbody>
{% for a in result.aspects %}
<tr><td>{{ a.obj1 }}</td><td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja #}
{% if declination_svg %}
<figure class="declination-fig">
{{ declination_svg | safe }}
@@ -102,6 +177,24 @@
</figure>
{% endif %}
{% if result.parallels %}
<div class="meta" title="Ciała na tej samej (paralela) lub przeciwnej (kontrparalela) deklinacji — działają jak koniunkcja / opozycja poza ekliptyką">Paralele deklinacji ({{ result.parallels | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th><th>Dekl.</th></tr></thead>
<tbody>
{% for r in result.parallels %}
<tr>
<td>{{ r.obj1 }}</td>
<td>{{ 'paralela' if r.type == 'parallel' else 'kontrparalela' }}</td>
<td>{{ r.obj2 }}</td>
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
<td class="mono">{{ '%+.2f'|format(r.dec1) }} / {{ '%+.2f'|format(r.dec2) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if antiscia_svg %}
<figure class="antiscia-fig">
{{ antiscia_svg | safe }}
@@ -109,8 +202,118 @@
</figure>
{% endif %}
{% include "_result_tables.html" %}
{% if result.antiscia %}
<div class="meta" title="Odbicie względem osi przesileń (antyscja) lub równonocy (kontrantyscja) — punkty „dzielące” tę samą długość dnia">Antyscja ({{ result.antiscia | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
<tbody>
{% for r in result.antiscia %}
<tr>
<td>{{ r.obj1 }}</td>
<td>{{ 'antyscja' if r.type == 'antiscion' else 'kontrantyscja' }}</td>
<td>{{ r.obj2 }}</td>
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.tables %}
{% set tb = result.tables %}
<div class="meta">Tabele dodatkowe (LOG-23)</div>
{% set bal = tb.tally.with_modern_10_plus_asc %}
<table class="angles">
<thead><tr><th>Bilans (10 planet + Asc)</th><th colspan="4">Rozkład</th></tr></thead>
<tbody>
<tr><td>Żywioły</td>
{% for e, n in bal.elements.items() %}
<td>{{ tb.tally.labels.elements[e] }}: <strong>{{ n }}</strong></td>
{% endfor %}
</tr>
<tr><td>Jakości</td>
{% for q, n in bal.qualities.items() %}
<td>{{ tb.tally.labels.qualities[q] }}: <strong>{{ n }}</strong></td>
{% endfor %}
<td></td>
</tr>
</tbody>
</table>
{% if tb.tally.missing_elements or tb.tally.missing_qualities %}
<p class="muted small">Brak:
{% for e in tb.tally.missing_elements %}<strong>{{ tb.tally.labels.elements[e] }}</strong>{{ ", " if not loop.last }}{% endfor %}
{% for q in tb.tally.missing_qualities %}<strong>{{ tb.tally.labels.qualities[q] }}</strong>{{ ", " if not loop.last }}{% endfor %}
</p>
{% endif %}
{% if tb.moon_phase %}
<p class="muted small">
<strong>Faza Księżyca:</strong> {{ tb.moon_phase.phase_pl }} ·
elongacja {{ '%.2f'|format(tb.moon_phase.angle) }}° ·
oświetlenie {{ '%.1f'|format(tb.moon_phase.illumination * 100) }}% ·
{{ 'przybywa' if tb.moon_phase.waxing else 'ubywa' }}
</p>
{% endif %}
{% if tb.planetary_hours %}
{% set ph = tb.planetary_hours %}
<p class="muted small">
<strong>Godziny planetarne:</strong> władca dnia {{ ph.day_ruler }} ·
{{ ph.hour_number }}. godzina ({{ ph.period }}), władca {{ ph.hour_ruler }} ·
godzina trwa {{ ph.hour_length_minutes }} min
</p>
{% endif %}
{% if tb.prenatal_syzygy %}
{% set s = tb.prenatal_syzygy %}
<p class="muted small">
<strong>Syzygia prenatalna:</strong> {{ s.type_pl }} {{ s.when_utc }}
({{ s.days_before_birth }} dni przed) w {{ s.in_sign }}
</p>
{% endif %}
{% if tb.critical_degrees %}
<div class="meta">Stopnie krytyczne</div>
<table class="angles">
<thead><tr><th>Obiekt</th><th>Pozycja</th><th>Uwaga</th></tr></thead>
<tbody>
{% for c in tb.critical_degrees %}
<tr><td>{{ c.name }}</td><td class="mono">{{ c.in_sign }}</td>
<td class="muted small">{{ c.flags | join('; ') }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<details class="loc">
<summary>Podziały: dwunastniki (D12) i nawamsa (D9)</summary>
<table>
<thead><tr><th>Obiekt</th><th>D12</th><th>D9</th></tr></thead>
<tbody>
{% for d in tb.divisional %}
<tr><td>{{ d.name }}</td><td class="mono">{{ d.d12_in_sign }}</td>
<td class="mono">{{ d.d9_in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% if result.cusps %}
<details class="loc">
<summary>Cusps domów ({{ result.house_system }})</summary>
<table>
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
<tbody>
{% for c in result.cusps %}
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% endif %}
<script src="{{ static('now.js') }}"></script>
<script src="/static/now.js"></script>
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Skompiluj{% endblock %}
{% set nav_active = "compile" %}
{% block nav_compile %}active{% endblock %}
{% block content %}
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
@@ -36,17 +36,14 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
<label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
{% if can(request, 'houses_compare') %}
<label>System domów
<select name="house_system">
{% set hs = form.house_system or 'whole_sign' %}
{% for value, name in HOUSE_SYSTEMS %}
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
{% endfor %}
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
</select>
</label>
{% endif %}
{% if can(request, 'advanced_calc') %}
<label>Zodiak
<select name="zodiac">
{% set zd = form.zodiac or 'tropical' %}
@@ -57,16 +54,12 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select>
</label>
{% endif %}
</div>
{# Te same opcje co na „Horoskop" (wspólny plik) — synchronizowane między
zakładkami (formsync), żeby podsumowanie liczyło się z tym, co wybrano. #}
{% include "_form_options.html" %}
<div class="actions">
<button type="submit">Złóż podsumowanie</button>
{% if can(request, 'export') %}<button type="button" id="pdfBtn" class="ghost"
<button type="button" id="pdfBtn" class="ghost"
title="Generuje plik pośredni LaTeXa i kompiluje go do PDF (usługa render)">Pobierz PDF</button>
<span id="pdfNote" class="muted small"></span>{% endif %}
<span id="pdfNote" class="muted small"></span>
</div>
</form>
@@ -113,12 +106,34 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
{% endif %}
{# ── 3. Dane policzone ────────────────────────────────────────────── #}
{# WSPÓLNE tabele (ten sam plik co „Horoskop") — podsumowanie pokazuje dokładnie
to, co policzono: porównanie domów, stacje, żywioły itd., jeśli były wybrane. #}
<div class="meta">Horoskop · silnik {{ result.engine }}
{% if result.house_system %}· domy {{ result.house_system }}{% endif %}
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
{% include "_result_tables.html" %}
<table>
<thead><tr><th>Sym.</th><th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th></tr></thead>
<tbody>
{% for p in result.positions %}
<tr>
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
<td>{{ p.name }}</td><td>{{ p.sign }}</td><td class="mono">{{ p.in_sign }}</td>
<td>{{ p.house if p.house is defined else '—' }}</td>
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if result.angles %}
<table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
<tbody>
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
{% set a = result.angles[key] %}
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
<div id="reportNatal"></div>
@@ -127,7 +142,7 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #}
<script src="{{ static('natal.js') }}"></script>
<script src="{{ static('predictions.js') }}"></script>
<script src="{{ static('compile.js') }}"></script>
<script src="/static/natal.js"></script>
<script src="/static/predictions.js"></script>
<script src="/static/compile.js"></script>
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Interpretacje{% endblock %}
{% set nav_active = "interpret" %}
{% block nav_interp %}active{% endblock %}
{% block content %}
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
@@ -40,7 +40,7 @@
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
{% if can(request, 'export') %}<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>{% endif %}
<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
<span id="geoNote" class="muted small"></span>
@@ -97,10 +97,10 @@
{% endfor %}
{% endif %}
<script src="{{ static('now.js') }}"></script>
<script src="{{ static('copy.js') }}"></script>
<script src="{{ static('models.js') }}"></script>
<script src="{{ static('progress.js') }}"></script>
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
<script src="/static/models.js"></script>
<script src="/static/progress.js"></script>
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
<script src="{{ static('natal.js') }}"></script>
<script src="/static/natal.js"></script>
{% endblock %}
@@ -1,51 +0,0 @@
{% extends "base.html" %}
{% block title %}Ustawienia{% endblock %}
{% set nav_active = "settings" %}
{% block content %}
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
{% if result %}
<div class="meta">Bazy na udziale: <strong>{{ result.total }}</strong> ·
biorą udział w interpretacji: <strong>{{ result.enabled }}</strong>
{% if result.total and result.enabled < result.total %}
· wyłączonych: <strong>{{ result.total - result.enabled }}</strong>
{% endif %}
</div>
{% if result.bases %}
<table>
<thead><tr><th>Baza</th><th>Ścieżka</th><th>Rozmiar</th><th>Zmieniono</th><th>W interpretacji</th></tr></thead>
<tbody>
{% for b in result.bases %}
<tr>
<td>{{ b.name }}</td>
<td class="muted small">{{ b.path }}</td>
<td class="mono">{% if b.size_mb is not none %}{{ b.size_mb }} MB{% else %}—{% endif %}</td>
<td class="mono">{{ b.modified or '—' }}</td>
<td>{% if b.enabled %}tak{% else %}<span class="badge">wyłączona</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Na udziale nie widać żadnych baz (albo aktywny dostawca nie operuje na plikach).</p>
{% endif %}
<div class="meta">Jak wyłączyć bazę</div>
<p class="muted small">
Lista wyłączonych jest <strong>deklaratywna</strong> — trzyma ją zmienna
<span class="mono">DISABLED_BASES</span> usługi danych (nazwy plików po przecinku,
np. <span class="mono">stara_baza.xlsx,archiwum/testowa.xlsx</span>). Po zmianie
zrestartuj warstwę danych.
</p>
<p class="muted small">
Dlaczego nie klikaniem: udział z bazami jest montowany tylko do odczytu, a katalog
cache to <span class="mono">emptyDir</span> — zapisany przełącznik ginąłby przy
restarcie poda i po cichu włączał z powrotem wyłączoną bazę. Przełączanie z tego
ekranu wymagałoby dołożenia trwałego wolumenu.
</p>
{% endif %}
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Sygnifikatory{% endblock %}
{% set nav_active = "significators" %}
{% block nav_sig %}active{% endblock %}
{% block content %}
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
@@ -1,73 +0,0 @@
{% extends "base.html" %}
{% block title %}Synastria{% endblock %}
{% set nav_active = "synastry" %}
{% block content %}
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
<form method="post" action="/synastry">
<div class="grid">
{% for p in [{'k': 'a', 'label': 'Osoba A'}, {'k': 'b', 'label': 'Osoba B'}] %}
<fieldset style="flex:1; min-width:16rem; border:1px solid var(--line); border-radius:12px; padding:1rem;">
<legend class="muted small">{{ p.label }}</legend>
<label>Imię
<input type="text" name="{{ p.k }}_person" autocomplete="name" value="{{ form[p.k ~ '_person'] or '' }}">
</label>
<div class="grid">
<label>Data <input type="date" name="{{ p.k }}_date" value="{{ form[p.k ~ '_date'] or '' }}" required></label>
<label>Godzina <input type="time" name="{{ p.k }}_time" value="{{ form[p.k ~ '_time'] or '' }}" required></label>
</div>
<div class="grid">
<label title="Przesunięcie względem GMT, krok 15 min">Offset GMT (h)
<input type="number" name="{{ p.k }}_tz_offset" step="0.25" min="-12" max="14"
value="{{ form[p.k ~ '_tz_offset'] if form[p.k ~ '_tz_offset'] is not none else 0 }}"></label>
<label>Szer. (lat) <input type="number" name="{{ p.k }}_lat" step="0.0001" value="{{ form[p.k ~ '_lat'] if form[p.k ~ '_lat'] is not none else 0 }}"></label>
<label>Dług. (lon) <input type="number" name="{{ p.k }}_lon" step="0.0001" value="{{ form[p.k ~ '_lon'] if form[p.k ~ '_lon'] is not none else 0 }}"></label>
</div>
</fieldset>
{% endfor %}
</div>
<div class="opts" title="Wspólne dla obu osób. Orb i aspekty poboczne jak przy horoskopie (PRE-06).">
<label>Zodiak
<select name="zodiac">
{% set zd = form.zodiac or 'tropical' %}
<option value="tropical" {{ 'selected' if zd == 'tropical' else '' }}>Tropikalny</option>
<option value="sidereal_lahiri" {{ 'selected' if zd == 'sidereal_lahiri' else '' }}>Syderyczny (Lahiri)</option>
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select>
</label>
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
<label><input type="checkbox" name="aspect_minor" value="true" {{ 'checked' if form.aspect_minor else '' }}> aspekty poboczne (30/45/150°)</label>
</div>
<div class="actions">
<button type="submit">Policz synastrię</button>
</div>
</form>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
{% if result %}
{% set an = form.a_person or 'Osoba A' %}
{% set bn = form.b_person or 'Osoba B' %}
<div class="meta">Aspekty synastryczne ({{ result.aspects | length }}) · <strong>{{ an }}</strong><strong>{{ bn }}</strong> · silnik {{ result.engine }}</div>
{% if result.aspects %}
<table class="angles">
<thead><tr><th>{{ an }}</th><th>Aspekt</th><th>{{ bn }}</th><th>Orb</th></tr></thead>
<tbody>
{% for a in result.aspects %}
<tr>
<td>{{ a.obj1 }}</td>
<td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td>
<td>{{ a.obj2 }}</td>
<td class="mono">{{ '%.2f'|format(a.orb) }}°</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Brak aspektów w zadanym orbie. Spróbuj szerszego orbu albo aspektów pobocznych.</p>
{% endif %}
{% endif %}
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Kalendarz{% endblock %}
{% set nav_active = "timeline" %}
{% block nav_timeline %}active{% endblock %}
{% block content %}
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
@@ -91,10 +91,10 @@
<p class="muted small" id="predictionsNote"></p>
</section>
<script src="{{ static('now.js') }}"></script>
<script src="{{ static('copy.js') }}"></script>
<script src="{{ static('models.js') }}"></script>
<script src="{{ static('progress.js') }}"></script>
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
<script src="/static/models.js"></script>
<script src="/static/progress.js"></script>
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
<script src="{{ static('predictions.js') }}"></script>
<script src="/static/predictions.js"></script>
{% endblock %}
-68
View File
@@ -1,68 +0,0 @@
"""Strefa czasowa z lokalizacji (PRE-03) — „logika dwóch lokalizacji".
Cel po ludzku: żeby użytkownik nie musiał znać offsetu GMT ani pamiętać o czasie
letnim. Z dokładnych współrzędnych bierzemy strefę IANA (`tzfpy`, OFFLINE — bez
zapytań do sieci, więc działa też bez internetu), a z niej — offset DLA DATY
URODZENIA. `zoneinfo` zna reguły historyczne i DST, więc np. Kraków 1984 to +1h
zimą, +2h latem, a Katmandu to +5:45.
Sedno PRE-03: offset ustalamy RAZ i trzymamy jako stałą liczbę w formularzu —
przeliczenie dla dokładnej lokalizacji już nim nie rusza. Dzięki temu drobna
zmiana współrzędnych nie przerzuca strefy ani czasu letniego i nie „przeskakuje"
Ascendenta na sąsiedni znak. „Większa miejscowość" z wymagania jest zbędna:
strefa IANA jest i tak regionalna, więc wioska daje tę samą strefę co pobliskie
miasto.
"""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try: # zależność opcjonalna — brak biblioteki
from tzfpy import get_tz # nie może wywalić strony, tylko wyłącza podpowiedź
except Exception: # pragma: no cover
get_tz = None
def _fmt_offset(hours: float) -> str:
sign = "+" if hours >= 0 else ""
total = int(round(abs(hours) * 60))
return f"{sign}{total // 60}:{total % 60:02d}"
def resolve(lat: float, lon: float, date: str, time: str = "12:00") -> dict | None:
"""(lat, lon, data lokalna, godzina lokalna) → {tz, offset, dst, label}.
Zwraca None, gdy strefy nie da się ustalić (brak biblioteki, punkt bez strefy,
niepoprawna data) — wtedy zostaje ręczny offset, nic się nie psuje.
`offset` jest w godzinach (np. 5.75 dla +5:45), DST-świadomy dla podanej daty.
"""
if get_tz is None:
return None
try:
tz = get_tz(float(lon), float(lat)) # UWAGA: tzfpy przyjmuje (lon, lat)
except Exception:
return None
if not tz:
return None
try:
zone = ZoneInfo(tz)
except (ZoneInfoNotFoundError, ValueError):
return None
# Wall-time w tej strefie dla podanej daty → offset i czy czas letni jest aktywny.
try:
local = datetime.fromisoformat(f"{date}T{time or '12:00'}")
except ValueError:
try:
local = datetime.fromisoformat(date).replace(hour=12)
except ValueError:
return None
local = local.replace(tzinfo=zone)
off = local.utcoffset()
if off is None:
return None
hours = round(off.total_seconds() / 3600.0, 2)
dst = bool(local.dst())
label = f"{tz} · {_fmt_offset(hours)}" + (" (czas letni)" if dst else "")
return {"tz": tz, "offset": hours, "dst": dst, "label": label}
-5
View File
@@ -5,10 +5,5 @@ jinja2>=3.1
python-multipart>=0.0.20
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
# Strefa czasowa z lokalizacji (PRE-03): współrzędne → strefa IANA (offline, lekki
# wheel Rust), a offset/DST liczy stdlib zoneinfo. tzdata na wypadek slim-obrazu
# bez systemowej bazy stref.
tzfpy>=0.15
tzdata>=2024.1
# Eksport wyników do Excela — „tabela robocza" (DAN-23/PRE-10)
openpyxl>=3.1
@@ -1,45 +0,0 @@
"""Generator wpisu do APP_USERS — konta imienne (PRE-17).
Hasło podajesz interaktywnie (nie w argumencie), żeby nie zostało w historii
powłoki ani w liście procesów. Na wyjściu dostajesz gotowy wpis `login:hash`
do zmiennej APP_USERS — samo hasło nigdzie nie jest zapisywane.
python scripts/make_user.py alicja
Kilka kont sklejasz przecinkiem:
APP_USERS='alicja:scrypt$…,bartek:scrypt$…'
Odebranie dostępu jednej osobie = usunięcie jej wpisu (reszta nie zmienia haseł).
"""
import getpass
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from app.security import hash_password # noqa: E402
def main() -> int:
if len(sys.argv) != 2:
print(__doc__)
return 2
login = sys.argv[1].strip()
if not login or ":" in login or "," in login:
print("Login nie może być pusty ani zawierać ':' lub ',' (rozdzielają wpisy).")
return 2
first = getpass.getpass(f"Hasło dla '{login}': ")
if not first:
print("Puste hasło — przerwane.")
return 2
if first != getpass.getpass("Powtórz hasło: "):
print("Hasła się różnią — przerwane.")
return 2
print("\nDopisz do APP_USERS (wpisy po przecinku):\n")
print(f"{login}:{hash_password(first)}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,148 +0,0 @@
"""Konta imienne i dziennik audytowy (PRE-17).
Kod ochronny — testujemy zachowanie, nie kształt źródła. Dwie rzeczy są tu
krytyczne i CICHE, gdy się zepsują: (1) odebranie dostępu jednej osobie musi
faktycznie działać, (2) dziennik musi mówić KTO i ILE, ale NIGDY nie powielać
treści baz (byłby wtedy kolejnym nośnikiem wycieku).
"""
import base64
import logging
import pytest
from app import security
def _basic(user: str, password: str) -> dict:
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {raw}"}
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
for var in ("APP_USERS", "APP_PASSWORD", "APP_USER"):
monkeypatch.delenv(var, raising=False)
security._hits.clear() # limit żądań nie może przeciekać między testami
# ── hasła: hash zamiast jawnego tekstu ──
def test_hash_roundtrip_and_salt_is_random():
h = security.hash_password("tajne")
assert h.startswith("scrypt$")
assert security._secret_matches(h, "tajne")
assert not security._secret_matches(h, "inne")
assert security.hash_password("tajne") != h # inna sól za każdym razem
def test_broken_hash_denies_instead_of_crashing():
assert security._secret_matches("scrypt$niepoprawny", "cokolwiek") is False
def test_non_ascii_password_is_handled_not_crashing():
"""REGRESJA (błąd zastany): `secrets.compare_digest` rzuca TypeError na znakach
spoza ASCII, więc hasło z polskimi literami wywracało logowanie błędem 500
zamiast zwykłej odmowy. Porównujemy bajty."""
assert security._secret_matches("zażółć", "zażółć") is True
assert security._secret_matches("zażółć", "inne") is False
assert security._secret_matches(security.hash_password("gęślą"), "gęślą") is True
# ── konta imienne ──
def test_named_accounts_are_parsed(monkeypatch):
monkeypatch.setenv("APP_USERS", "alicja:tajne, bartek:inne")
assert set(security.accounts()) == {"alicja", "bartek"}
def test_login_with_hashed_password(monkeypatch):
monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}")
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
assert security.authenticate(_basic("alicja", "złe")["Authorization"]) is None
def test_unknown_user_is_denied(monkeypatch):
monkeypatch.setenv("APP_USERS", "alicja:tajne")
assert security.authenticate(_basic("obcy", "tajne")["Authorization"]) is None
def test_revoking_one_account_leaves_others_untouched(monkeypatch):
"""Sedno PRE-17: odcinamy jedną osobę BEZ zmiany hasła pozostałym."""
monkeypatch.setenv("APP_USERS", "alicja:tajne,bartek:inne")
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) == "bartek"
monkeypatch.setenv("APP_USERS", "alicja:tajne") # usunięto wpis Bartka
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) is None
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
def test_shared_password_still_works_alone(monkeypatch):
"""Zgodność wstecz — dotychczasowe wdrożenie nie może paść po aktualizacji."""
monkeypatch.setenv("APP_USER", "astrololo")
monkeypatch.setenv("APP_PASSWORD", "wspolne")
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) == "astrololo"
def test_named_accounts_disable_the_shared_password(monkeypatch):
"""Gdy są konta imienne, wspólne hasło MUSI przestać działać — inaczej
zostawiałoby tylne wejście bez śladu w dzienniku."""
monkeypatch.setenv("APP_USERS", "alicja:tajne")
monkeypatch.setenv("APP_USER", "astrololo")
monkeypatch.setenv("APP_PASSWORD", "wspolne")
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) is None
def test_no_accounts_means_auth_disabled(monkeypatch):
assert security.auth_enabled() is False
# ── dziennik audytowy ──
def _client():
from starlette.testclient import TestClient
from app.main import app
return TestClient(app)
def test_audit_logs_who_and_how_many(monkeypatch, caplog):
monkeypatch.setenv("APP_USERS", "alicja:tajne")
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
_client().get("/synastry", headers=_basic("alicja", "tajne"))
entry = "\n".join(r.getMessage() for r in caplog.records)
assert "user=alicja" in entry and "path=/synastry" in entry and "status=200" in entry
def test_audit_marks_failed_login_without_naming_the_account(monkeypatch, caplog):
monkeypatch.setenv("APP_USERS", "alicja:tajne")
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
_client().get("/synastry", headers=_basic("alicja", "złe"))
entry = "\n".join(r.getMessage() for r in caplog.records)
assert "status=401" in entry and "user=-" in entry
def test_audit_logger_actually_emits(monkeypatch):
"""REGRESJA: wpisy szły na INFO, ale domyślna konfiguracja uvicorna nie
obsługuje naszych loggerów — dziennik istniał w kodzie i był PUSTY na żywym
serwerze. Niewidoczny dziennik jest gorszy niż jego brak (złudzenie kontroli),
więc pilnujemy, że ma własny handler i poziom pozwalający na INFO."""
security._setup_audit_logging()
assert security.audit_log.handlers, "dziennik audytowy nie ma własnego handlera"
assert security.audit_log.isEnabledFor(logging.INFO)
def test_audit_never_carries_database_content():
"""Wpis niesie WYŁĄCZNIE metadane i liczby — logi nie mogą być nośnikiem wycieku."""
src = (__import__("pathlib").Path(security.__file__)).read_text(encoding="utf-8")
fmt = src[src.index('"AUDYT'):src.index("ms=%.0f") + 10]
for forbidden in ("rows", "samples", "effect", "significator", "text", "body"):
assert forbidden not in fmt, f"format wpisu audytowego dotyka treści: {forbidden}"
def test_handlers_report_record_counts():
"""Bez liczby rekordów dziennik pokazywałby „ktoś wchodził", a nie „ktoś
wyniósł 5000 rekordów" — czyli nie wykryłby powolnego wypompowywania bazy."""
import pathlib
main = (pathlib.Path(security.__file__).parent / "main.py").read_text(encoding="utf-8")
assert main.count("security.audit_records(") >= 3 # sygnifikatory, raport, eksport
@@ -1,53 +0,0 @@
"""Konfigurowalne aspekty i orby w UI (PRE-06).
Ustawienia muszą: (1) być na obu formularzach (Horoskop i Skompiluj), (2) wędrować
między zakładkami (formsync), (3) trafiać do logiki na ekran i do PDF-a — inaczej
podsumowanie liczyłoby aspekty innym orbem niż horoskop (ta sama klasa błędu co
regresja podsumowania).
"""
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje obu formularzy
MAIN = (APP / "main.py").read_text(encoding="utf-8")
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
SYNC = (APP / "static" / "formsync.js").read_text(encoding="utf-8")
COMPILE_JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
FIELDS = ("aspect_orb", "aspect_luminary_bonus", "aspect_minor")
def test_both_forms_have_aspect_settings():
"""Pola są we WSPÓLNYM pliku opcji, który oba formularze dołączają."""
for f in FIELDS:
assert f'name="{f}"' in OPTS, f"brak {f} we wspólnym pliku opcji"
assert '{% include "_form_options.html" %}' in CHART
assert '{% include "_form_options.html" %}' in COMPILE
def test_handlers_accept_and_pass_aspect_settings():
# co najmniej Horoskop i Skompiluj przekazują orb do logiki (synastria też — 3)
assert MAIN.count("aspect_orb=aspect_orb") >= 2
assert "aspect_minor: bool = Form(False)" in MAIN
def test_client_forwards_aspect_settings():
for f in FIELDS:
assert f'"{f}": {f}' in CLIENT, f"klient nie przekazuje {f}"
def test_settings_are_synced_between_tabs():
for f in FIELDS:
assert f"'{f}'" in SYNC, f"formsync nie synchronizuje {f}"
def test_pdf_payload_includes_aspect_settings():
assert "aspect_orb: v('aspect_orb')" in COMPILE_JS
assert "aspect_minor: checked('aspect_minor')" in COMPILE_JS
def test_pdf_handler_reads_aspect_settings():
assert 'aspect_orb=float(data.get("aspect_orb")' in MAIN
assert 'aspect_minor=bool(data.get("aspect_minor"))' in MAIN
@@ -1,41 +0,0 @@
"""Cache-busting plików statycznych (PRE-26).
Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL). Doklejamy
hash treści → zmiana pliku zmienia URL. Regresja byłaby CICHA (użytkownik widzi
stary interfejs), więc pilnujemy strukturalnie.
"""
import os
import pathlib
import re
os.environ.pop("APP_PASSWORD", None) # import app.main bez bramki logowania
from app.main import _asset_version, static_url
TEMPLATES = pathlib.Path(__file__).resolve().parents[1] / "app" / "templates"
def test_static_url_carries_content_hash():
assert re.fullmatch(r"/static/styles\.css\?v=[0-9a-f]{8}", static_url("styles.css"))
def test_version_is_content_based_so_differs_between_files():
assert _asset_version("styles.css") != _asset_version("formsync.js")
def test_missing_file_does_not_crash():
assert static_url("nie-ma-takiego.js").endswith("?v=0")
def test_no_template_serves_raw_static_paths():
"""Żaden href/src nie może iść na surowe /static/... — omijałoby cache-busting."""
for f in TEMPLATES.glob("*.html"):
txt = f.read_text(encoding="utf-8")
assert 'src="/static/' not in txt, f"{f.name}: surowy src bez cache-bustingu"
assert 'href="/static/' not in txt, f"{f.name}: surowy href bez cache-bustingu"
def test_base_uses_helper_for_stylesheet_and_scripts():
base = (TEMPLATES / "base.html").read_text(encoding="utf-8")
assert "static('styles.css')" in base
assert "static('formsync.js')" in base
+13 -101
View File
@@ -31,16 +31,7 @@ def _client():
# ───────────────────────────────── nawigacja i trasa ─────────────────────
def test_tab_is_in_the_menu():
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
from app import features
tab = features.BY_KEY["compile"]
assert tab in features.SCREENS
assert tab.href and tab.label
assert 'href="/compile"' in BASE and "Skompiluj" in BASE
def test_get_renders_empty_form():
@@ -85,45 +76,16 @@ def test_compile_page_renders_all_figures():
assert var in MAIN, f"/compile nie ustawia {var}"
def test_pdf_bundles_all_figures_in_print_theme(monkeypatch):
"""compile_pdf składa KOMPLET rysunków w motywie DRUKU.
Sprawdzamy przez WYWOŁANIE trasy, nie przez szukanie tekstu w main.py:
poprzednia wersja greppowała `render(chart, theme="print")` i pękała przy
każdym dopisaniu argumentu, mimo że zachowanie zostawało poprawne."""
seen: dict[str, str] = {}
from app import chartwheel
from app.clients.render_client import RenderClient
from app.main import logic
for name in ("render", "render_aspectarian", "render_declination", "render_antiscia"):
original = getattr(chartwheel, name)
def spy(chart, theme="screen", _n=name, _o=original, **kw):
seen[_n] = theme
return _o(chart, theme=theme, **kw)
monkeypatch.setattr(chartwheel, name, spy)
monkeypatch.setattr(logic, "positions", lambda **kw: _pdf_sample_chart())
monkeypatch.setattr(RenderClient, "pdf", lambda self, report: b"%PDF-1.4 stub")
r = _client().post("/compile/pdf", json={"person": "Jan", "data": {
"date": "1984-04-30", "time": "11:20", "tz_offset": 2, "lat": 50.06, "lon": 19.94}})
assert r.status_code == 200, r.text[:300]
assert set(seen) == {"render", "render_aspectarian",
"render_declination", "render_antiscia"}, seen
assert set(seen.values()) == {"print"}, seen
def _pdf_sample_chart() -> dict:
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0", "decimal": float(i * 30),
"sign_glyph": ""} for i in range(12)]
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0", "decimal": 0.0,
"sign_glyph": ""} for k in ("Asc", "MC", "Dsc", "IC")}
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
"sign_glyphs": [{"sign": "Aries", "glyph": ""}], "house_system": "equal"}
def test_pdf_bundles_all_figures_in_print_theme():
"""compile_pdf składa KOMPLET rysunków (motyw druku) i wysyła jako `figures`."""
assert '"figures": figures' in MAIN
for call in (
'render(chart, theme="print")',
'render_aspectarian(chart, theme="print")',
'render_declination(chart, theme="print")',
'render_antiscia(chart, theme="print")',
):
assert call in MAIN, f"PDF nie składa: {call}"
# ──────────────────────── zbieranie materiału z magazynów ────────────────
@@ -144,10 +106,9 @@ def test_stores_are_loaded_on_the_tab():
def test_stores_load_before_the_assembler():
"""Szukamy TAGÓW skryptów, nie samych nazw — te padają też w komentarzach.
Statyka jedzie przez helper cache-bustingu (PRE-26): src="{{ static('X') }}"."""
"""Szukamy TAGÓW skryptów, nie samych nazw — te padają też w komentarzach."""
def tag(name):
return TPL.index("static('" + name + "')")
return TPL.index('src="/static/' + name + '"')
assert tag("natal.js") < tag("compile.js")
assert tag("predictions.js") < tag("compile.js")
@@ -198,52 +159,3 @@ def test_ai_text_is_escaped_before_injection():
def test_does_nothing_on_other_tabs():
assert "if (!document.getElementById('readiness')) return;" in JS
# ─────── podsumowanie NIE gubi opcji policzonych przy horoskopie ──────────
# Regresja 2026-07-28: „Skompiluj" przeliczało horoskop bez stacji/tabel/
# porównania domów, więc podsumowanie miało braki względem tego, co policzono.
def test_compile_form_carries_the_options():
"""Te same opcje co „Horoskop" — z WSPÓLNEGO pliku (formsync je synchronizuje)."""
assert '{% include "_form_options.html" %}' in TPL
opts = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8")
for f in ('name="stations"', 'name="tables"', 'name="house_systems"'):
assert f in opts, f"brak opcji we wspólnym pliku: {f}"
def test_compile_handler_recomputes_with_all_options(monkeypatch):
"""„Skompiluj" przelicza horoskop z KOMPLETEM opcji z formularza.
To jest ta regresja z 2026-07-28: podsumowanie liczyło się z okrojonym
zestawem i miało braki względem tego, co widać na „Horoskopie". Sprawdzamy
przez wywołanie trasy, nie przez szukanie napisów w main.py."""
seen = {}
from app.main import app, logic
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _pdf_sample_chart())[1])
r = _client().post("/compile", data={
"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94", "house_system": "koch",
"house_systems": ["koch", "equal"], "stations": "true", "tables": "true",
"zodiac": "sidereal_lahiri", "aspect_minor": "true"})
assert r.status_code == 200, r.text[:300]
assert seen["house_systems"] == ["koch", "equal"]
assert seen["stations"] is True and seen["tables"] is True
assert seen["zodiac"] == "sidereal_lahiri" and seen["aspect_minor"] is True
def test_compile_pdf_recomputes_with_all_options():
assert 'stations=bool(data.get("stations"))' in MAIN
assert 'house_systems=[s for s in (data.get("house_systems")' in MAIN
def test_compile_pdf_payload_includes_options():
assert "house_systems: multi('house_systems')" in JS
assert "stations: checked('stations')" in JS
def test_summary_uses_shared_result_tables():
"""Podsumowanie pokazuje te same tabele co horoskop — jeden wspólny plik,
żeby nie mogły znowu się rozjechać."""
assert '{% include "_result_tables.html" %}' in TPL
+6 -15
View File
@@ -20,15 +20,7 @@ WITH_FORM = ("chart.html", "interpret.html", "timeline.html")
# Pola, które mają wędrować między zakładkami. Gdy dojdzie nowe wspólne pole,
# ten wykaz ma się nie rozjechać z formsync.js.
SHARED = ("person", "date", "time", "tz_offset", "lat", "lon",
"house_system", "zodiac", "place",
"stations", "tables", "house_systems") # opcje też wędrują (regresja: podsumowanie liczyło się bez nich)
def test_option_checkboxes_are_synced_by_type():
"""Stacje/tabele to checkboxy (.checked), a porównanie domów to wiele checkboxów
tej samej nazwy (lista) — formsync musi umieć oba, nie tylko .value."""
assert "'check'" in SYNC_JS and "'multi'" in SYNC_JS
assert "el.checked" in SYNC_JS
"house_system", "zodiac", "place")
# ─────────────────────────────── pole imienia ────────────────────────────
@@ -50,11 +42,10 @@ def test_person_value_survives_submit(name):
def test_handlers_accept_and_echo_person():
"""Każdy handler renderujący WSPÓLNY formularz przyjmuje `person` i wkłada go
"""Każdy handler renderujący formularz przyjmuje `person` i wkłada go
z powrotem do `form` — inaczej imię znikałoby po przeliczeniu.
Cztery: Horoskop, Interpretacje, Kalendarz, Skompiluj (PRE-23). Synastria ma
własne `a_person`/`b_person` (dwie osoby), więc echo `"person": person` = 4."""
assert MAIN_PY.count(" person: str = Form") == 4 # spacja z przodu wyklucza a_/b_person
Cztery: Horoskop, Interpretacje, Kalendarz, Skompiluj (PRE-23)."""
assert MAIN_PY.count("person: str = Form") == 4
assert MAIN_PY.count('"person": person') == 4
@@ -99,5 +90,5 @@ def test_map_changes_are_reported_to_sync():
def test_sync_does_not_wipe_fields_missing_on_a_tab():
"""Zakładka bez danego pola (np. Sygnifikatory) nie może wyczyścić wartości
zapamiętanej z innej zakładki. `collect` pomija pole, którego nie ma na stronie."""
assert "if (!els.length) return;" in SYNC_JS
zapamiętanej z innej zakładki."""
assert "if (!el) return;" in SYNC_JS
@@ -1,231 +0,0 @@
"""Porównanie systemów domów naraz (PRE-05).
JS-a tu nie ma — sprawdzamy strukturalnie: formularz pozwala zaznaczyć kilka
systemów, klient przekazuje je do logiki, a szablon pokazuje kuspy obok siebie.
"""
import pathlib
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje
TABLES = (APP / "templates" / "_result_tables.html").read_text(encoding="utf-8") # wspólne tabele
MAIN = (APP / "main.py").read_text(encoding="utf-8")
from app.house_systems import HOUSE_SYSTEMS, LIMITED, label as house_label # noqa: E402
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
def test_both_forms_use_shared_option_partial():
"""Opcje w jednym pliku — oba formularze go dołączają (koniec duplikacji)."""
assert '{% include "_form_options.html" %}' in CHART
assert '{% include "_form_options.html" %}' in COMPILE
def _render(name: str, **ctx) -> str:
"""Renderuje szablon z tymi samymi globalsami, co produkcja.
Szablony budują listę systemów PĘTLĄ po katalogu, więc sprawdzanie ich
surowego tekstu nic już nie mówi — trzeba je wykonać. Przy okazji test
łapie literówki w Jinja, których grep po źródle nigdy nie wyłapał."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True)
env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED,
house_label=house_label, static=lambda p: p,
# Domyślnie renderujemy jak dla konta z PEŁNYMI uprawnieniami —
# inaczej testy opcji sprawdzałyby pusty formularz. Ograniczenia
# mają własne testy w test_kontrola_dostepu.py.
can=lambda request, key: ctx.pop("_denied", None) != key,
nav_screens=lambda request: [])
return env.get_template(name).render(**ctx)
def test_form_offers_every_system_the_engine_advertises():
"""Każdy system z katalogu ma checkbox. Katalog jest jednym źródłem nazw,
więc dopisanie systemu bez odświeżenia UI zapali się tutaj."""
html = _render("_form_options.html", form={})
for value, name in HOUSE_SYSTEMS:
assert f'name="house_systems" value="{value}"' in html, f"brak checkboxa {value}"
assert name in html, f"brak nazwy {name}"
def test_form_marks_systems_with_a_domain_limit():
"""Placidus i Koch nie istnieją za kołem podbiegunowym — UI ma uprzedzać
ZANIM ktoś policzy horoskop dla Tromsø, nie dopiero w ostrzeżeniu wyniku."""
html = _render("_form_options.html", form={})
assert LIMITED, "katalog nie oznacza żadnego systemu jako ograniczonego"
for value, name in HOUSE_SYSTEMS:
starred = f"{name} *" in html
assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}"
def test_handler_accepts_and_passes_house_systems(monkeypatch):
"""Zaznaczone systemy DOCHODZĄ do warstwy logicznej.
Poprzednia wersja szukała napisu `house_systems=house_systems` w main.py
i pękała, gdy argument zaczął przechodzić przez słownik opcji — mimo że
zachowanie zostało poprawne. Sprawdzamy więc, co naprawdę wyszło na dół."""
seen = {}
def spy(**kw):
seen.update(kw)
return _minimal_chart()
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
from app.main import logic
monkeypatch.setattr(logic, "positions", spy)
r = c.post("/", data={**FORM, "house_systems": ["koch", "campanus"]})
assert r.status_code == 200, r.text[:300]
assert seen["house_systems"] == ["koch", "campanus"]
assert seen["house_system"] == "placidus"
def test_client_forwards_house_systems_to_logic():
assert '"house_systems": house_systems' in CLIENT
def test_comparison_table_renders_only_for_multiple_systems():
"""Bez dodatkowych systemów nie pokazujemy pustej tabeli porównania."""
assert "result.house_systems | length > 1" in TABLES
assert "Porównanie systemów domów" in TABLES
def test_comparison_table_shows_cusp_per_system():
"""Tabela iteruje po systemach i po 12 domach — kusp w każdej komórce."""
assert "for hs in result.house_systems" in TABLES
assert "hs.cusps[i].in_sign" in TABLES
def test_checkbox_state_survives_submit():
"""Zaznaczone systemy zostają zaznaczone po przeliczeniu."""
html = _render("_form_options.html", form={"house_systems": ["koch", "campanus"]})
for value, _ in HOUSE_SYSTEMS:
marker = f'value="{value}" checked'
assert (marker in html) == (value in ("koch", "campanus")), value
def test_fallback_notice_is_shown_prominently():
"""Po fallbacku kosmogram wygląda bezbłędnie — ostrzeżenie jest jedynym
sygnałem, że podział domów jest z innego systemu."""
html = _render("_result_tables.html",
result={"house_warnings": ["UWAGA: system domów nie ma definicji"]})
assert "house-warning" in html
assert "UWAGA: system domów nie ma definicji" in html
def test_no_notice_when_nothing_was_substituted():
html = _render("_result_tables.html", result={"house_warnings": []})
assert "house-warning" not in html
# ── ścieżka PRZEZ handlery, nie obok nich ───────────────────────────────
# Dwa błędy przeszły przez komplet testów szablonowych, bo żaden nie wywołał
# POST-a: odwołanie do nieistniejącej zmiennej w handlerze strony głównej oraz
# użycie parametru formularza, którego w sygnaturze nie było. Oba dają 500 na
# żywo i oba łapie dopiero prawdziwe żądanie.
def _client_with_stub_logic(monkeypatch, chart):
import os
os.environ.pop("APP_PASSWORD", None)
from starlette.testclient import TestClient
from app.main import app, logic
monkeypatch.setattr(logic, "positions", lambda **kw: chart)
return TestClient(app)
def _minimal_chart():
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0°00'00''",
"decimal": float(i * 30), "sign_glyph": ""} for i in range(12)]
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0°00'00''", "decimal": 0.0,
"sign_glyph": ""} for k in ("Asc", "MC", "Dsc", "IC")}
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
"sign_glyphs": [{"sign": "Aries", "glyph": ""}],
"house_system": "porphyry", "house_system_requested": "placidus",
"house_warnings": ["UWAGA: system domów „placidus” nie ma definicji"]}
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94", "house_system": "placidus"}
def test_chart_page_survives_a_real_post(monkeypatch):
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
r = c.post("/", data=FORM)
assert r.status_code == 200, r.text[:400]
assert "nie ma definicji" in r.text, "ostrzeżenie o fallbacku nie dotarło na stronę"
def test_compile_page_survives_a_real_post(monkeypatch):
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
r = c.post("/compile", data=FORM)
assert r.status_code == 200, r.text[:400]
@pytest.mark.parametrize("orientation", ("asc", "aries"))
def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
"""Wybór obrotu koła musi dojść z formularza do rysunku — i wrócić
zaznaczony, żeby nie gubił się przy przeliczeniu."""
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
r = c.post("/", data={**FORM, "wheel_orientation": orientation})
assert r.status_code == 200, r.text[:400]
assert f'value="{orientation}" checked' in r.text
# ── układ strony przy komplecie systemów ────────────────────────────────
# Regresja z pomiaru na żywej stronie: przy 13 zaznaczonych systemach tabela
# porównania miała 14 kolumn i 1863 px szerokości, a że stała w rodzicu bez
# overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli
# poziomy pasek przewijania na body i rozjechany układ.
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
def test_comparison_table_scrolls_instead_of_widening_the_page():
"""Tabela ma własny kontener przewijany — przewija się ona, nie strona."""
html = _render("_result_tables.html", result={
"house_systems": [{"system": v, "cusps": [
{"sign_glyph": "", "in_sign": "0°00'00''"} for _ in range(12)]}
for v, _ in HOUSE_SYSTEMS],
"house_warnings": []})
assert '<div class="table-scroll">' in html
before = html.index('class="table-scroll"')
assert html.index("<table", before) < html.index("</div>", before), \
"tabela musi być WEWNĄTRZ kontenera przewijanego"
def test_scroll_container_actually_clips_and_pins_the_house_number():
"""Sam znacznik nie wystarczy — reguła musi istnieć w arkuszu.
Geometrii nie da się sprawdzić bez przeglądarki, więc sprawdzamy kontrakt:
kontener przewija się w poziomie, a pierwsza kolumna zostaje przyklejona
(bez tego po przewinięciu w prawo nie wiadomo, który to dom)."""
assert ".table-scroll { overflow-x: auto;" in CSS
assert ".table-scroll th:first-child, .table-scroll td:first-child {" in CSS
assert "position: sticky" in CSS
def test_system_checkboxes_use_a_grid_not_a_ragged_flex_wrap():
"""13 pozycji na flexie zawijało się w poszarpane wiersze o różnej długości."""
html = _render("_form_options.html", form={})
assert 'class="opts systems"' in html
assert ".opts.systems { display: grid;" in CSS
def test_polar_circle_note_is_explained_once_next_to_the_asterisks():
"""Przypis siedział WEWNĄTRZ <label> selektora i łamał się na dwie linie,
przez co wiersz siatki rósł z 66 do 108 px i rozjeżdżał się z sąsiednim
polem. Wyjaśnienie stoi teraz raz, przy checkboxach z gwiazdkami."""
opts = _render("_form_options.html", form={})
assert "kołem podbiegunowym" in opts
for name in ("chart.html", "compile.html"):
tpl = (APP / "templates" / name).read_text(encoding="utf-8")
select = tpl[tpl.index("<select name=\"house_system\""):]
assert "podbiegunow" not in select[:select.index("</label>")], \
f"{name}: przypis wrócił do etykiety selektora"
@@ -1,370 +0,0 @@
"""Kontrola dostępu: konta, uprawnienia i niewidzialność funkcji (PRE-27).
DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK
1. Konto widzi dokładnie to, co mu przyznano — ani mniej, ani więcej. Sprawdzane
przez PRAWDZIWE żądania, nie przez czytanie szablonów: ukrycie pola w formularzu
nie chroni przed kimś, kto zna nazwy pól.
2. Konto ograniczone nie ma SKĄD wiedzieć, że program umie więcej. To własność
negatywna — łatwo ją zepsuć przez dobre intencje („dodajmy czytelny komunikat
o braku uprawnień"), więc testy są tu wprost o tym: 404 zamiast 403, brak
pozycji w menu, brak rysunków w źródle strony.
"""
import json
import pathlib
import pytest
from app import accounts as store
from app import features, security
@pytest.fixture()
def env(tmp_path, monkeypatch):
"""Świeży plik kont + konto administracyjne ze środowiska."""
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
monkeypatch.setenv("APP_USER", "szef")
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
monkeypatch.delenv("APP_USERS", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") # limit nie jest tu przedmiotem badania
return tmp_path
def _client(monkeypatch, chart=None):
from starlette.testclient import TestClient
from app.main import app, logic
monkeypatch.setattr(logic, "positions", lambda **kw: chart or _chart())
return TestClient(app)
def _auth(user, password):
import base64
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {raw}"}
def _chart():
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0", "decimal": float(i * 30),
"sign_glyph": ""} for i in range(12)]
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0", "decimal": 0.0,
"sign_glyph": ""} for k in ("Asc", "MC", "Dsc", "IC")}
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
"sign_glyphs": [{"sign": "Aries", "glyph": ""}], "house_system": "whole_sign",
"aspects": [], "house_systems": []}
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
"lat": "50.06", "lon": "19.94"}
# ── katalog i mapa tras ──────────────────────────────────────────────────
def test_every_route_declares_the_permission_it_needs():
"""Trasa bez wpisu w ROUTES to dziura, której nie widać.
Idziemy po TRASACH APLIKACJI, nie po wpisach mapy — inaczej test
potwierdzałby tylko sam siebie i nie zauważyłby nowej, nieopisanej trasy."""
from app.main import app
missing = []
for route in app.routes:
path = getattr(route, "path", None)
methods = getattr(route, "methods", None) or set()
if not path or path.startswith("/static"):
continue
for method in methods & {"GET", "POST"}:
if (method, path) not in features.ROUTES:
missing.append(f"{method} {path}")
assert not missing, f"trasy bez zadeklarowanego uprawnienia: {missing}"
def test_unknown_route_defaults_to_the_narrowest_permission():
"""Przeoczenie ma ZAMYKAĆ, nie otwierać."""
assert features.required("GET", "/cokolwiek-nowego") == features.ADMIN
def test_admin_permission_cannot_be_granted_from_a_form():
"""Nawet spreparowane żądanie nie nada uprawnień administracyjnych —
konto administracyjne pochodzi wyłącznie ze środowiska."""
assert features.ADMIN not in features.normalise([features.ADMIN, "chart"])
assert features.normalise(["chart", "wymyślone"]) == frozenset({"chart"})
# ── magazyn kont ─────────────────────────────────────────────────────────
def test_password_is_stored_only_as_a_hash(env):
store.create("ala", "hasło-ali", ["chart"])
raw = pathlib.Path(store.store_path()).read_text(encoding="utf-8")
assert "hasło-ali" not in raw
assert json.loads(raw)["users"]["ala"]["secret"].startswith("scrypt$")
def test_listing_accounts_never_exposes_secrets(env):
store.create("ala", "hasło-ali", ["chart"])
assert "secret" not in store.all_users()["ala"]
def test_create_update_delete(env):
store.create("ala", "x", ["chart"], note="do testów")
assert store.permissions_of("ala") == frozenset({"chart"})
store.update("ala", granted=["chart", "ai"])
assert store.permissions_of("ala") == frozenset({"chart", "ai"})
with pytest.raises(ValueError):
store.create("ala", "y", []) # login zajęty
store.delete("ala")
assert not store.exists("ala")
def test_empty_password_on_update_keeps_the_old_one(env):
store.create("ala", "stare", ["chart"])
before = store.secret_of("ala")
store.update("ala", granted=["chart"], password="")
assert store.secret_of("ala") == before
# ── logowanie i rozpoznanie konta ────────────────────────────────────────
def test_environment_account_is_the_administrator(env):
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
assert who and who.is_admin
assert features.ADMIN in who.permissions
assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko"
def test_managed_account_gets_exactly_its_own_permissions(env):
store.create("ala", "hasło-ali", ["chart", "ai"])
who = security.principal(_auth("ala", "hasło-ali")["Authorization"])
assert who and not who.is_admin
assert who.permissions == frozenset({"chart", "ai"})
def test_managed_account_cannot_shadow_the_administrator(env):
"""Konto założone w pliku o loginie administratora NIE MOŻE go przesłonić —
inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać."""
store.create("szef", "podszywam-się", [])
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
assert who and who.is_admin and features.ADMIN in who.permissions
def test_wrong_password_is_refused_the_same_way_as_unknown_login(env):
store.create("ala", "hasło-ali", ["chart"])
assert security.principal(_auth("ala", "złe")["Authorization"]) is None
assert security.principal(_auth("nie-ma-takiego", "cokolwiek")["Authorization"]) is None
# ── niewidzialność funkcji ───────────────────────────────────────────────
def test_forbidden_screen_answers_404_not_403(env, monkeypatch):
"""403 samo w sobie mówi „tu coś jest". Ma być nie do odróżnienia od
adresu, którego nie ma."""
store.create("ala", "x", ["chart"])
c = _client(monkeypatch)
for path in ("/significators", "/interpret", "/timeline", "/synastry",
"/compile", "/settings", "/accounts"):
r = c.get(path, headers=_auth("ala", "x"))
assert r.status_code == 404, f"{path}{r.status_code}"
assert c.get("/nie-ma-takiego-adresu", headers=_auth("ala", "x")).status_code == 404
def test_menu_shows_only_granted_screens(env, monkeypatch):
store.create("ala", "x", ["chart", "significators"])
c = _client(monkeypatch)
html = c.get("/", headers=_auth("ala", "x")).text
assert 'href="/significators"' in html
for absent in ('href="/interpret"', 'href="/timeline"', 'href="/synastry"',
'href="/compile"', 'href="/settings"', 'href="/accounts"'):
assert absent not in html, f"menu zdradza {absent}"
for word in ("Interpretacje", "Kalendarz", "Synastria", "Skompiluj", "Konta"):
assert word not in html, f"nazwa „{word}” nie powinna paść"
def test_administrator_sees_everything_including_the_accounts_tab(env, monkeypatch):
c = _client(monkeypatch)
html = c.get("/", headers=_auth("szef", "tajne-szefa")).text
for tab in features.SCREENS:
assert f'href="{tab.href}"' in html, tab.key
assert 'href="/accounts"' in html
def test_root_lands_on_the_first_granted_screen(env, monkeypatch):
"""Konto bez „Horoskopu" nie może zobaczyć 404 pod adresem głównym —
wyglądałoby to na zepsuty program, a nie na węższy zestaw funkcji."""
store.create("ala", "x", ["significators"])
c = _client(monkeypatch)
r = c.get("/", headers=_auth("ala", "x"), follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/significators"
# ── ograniczenia opcji egzekwowane NA SERWERZE ──────────────────────────
def test_crafted_request_cannot_buy_options_the_account_lacks(env, monkeypatch):
"""Ukrycie pola w formularzu chroni przed przypadkiem, nie przed kimś,
kto zna nazwy pól. Granicą jest handler."""
store.create("ala", "x", ["chart"])
seen = {}
from app.main import app, logic
from starlette.testclient import TestClient
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _chart())[1])
r = TestClient(app).post("/", headers=_auth("ala", "x"), data={
**FORM, "stations": "true", "tables": "true", "aspect_minor": "true",
"zodiac": "sidereal_lahiri", "house_system": "koch",
"house_systems": ["koch", "campanus"]})
assert r.status_code == 200, r.text[:300]
assert seen["stations"] is False and seen["tables"] is False
assert seen["aspect_minor"] is False and seen["zodiac"] == "tropical"
assert seen["house_system"] == "whole_sign" and seen["house_systems"] == []
def test_extra_charts_are_not_even_in_the_page_source(env, monkeypatch):
"""Nie chodzi o ukrycie rysunków stylem — nie mają w ogóle powstać."""
store.create("ala", "x", ["chart"])
c = _client(monkeypatch)
html = c.post("/", headers=_auth("ala", "x"), data=FORM).text
for word in ("Aspektarian", "deklinacj", "ntyscj"):
assert word not in html, f"źródło strony zdradza „{word}"
def test_the_same_account_with_the_extra_gets_them(env, monkeypatch):
"""Kontrola pozytywna: bez niej powyższy test przechodziłby też wtedy,
gdyby rysunki były zepsute dla wszystkich."""
store.create("ola", "x", ["chart", "extra_charts"])
c = _client(monkeypatch, chart=_chart_with_objects())
html = c.post("/", headers=_auth("ola", "x"), data=FORM).text
assert "Aspektarian" in html
def _chart_with_objects():
"""Horoskop na tyle bogaty, żeby rysunki dodatkowe w ogóle powstały."""
base = _chart()
base["positions"] = [
{"name": n, "glyph": g, "sign": "Aries", "sign_glyph": "", "in_sign": "0",
"decimal": float(i * 30), "direction": "D", "speed": 1.0,
"declination": 10.0, "house": i + 1}
for i, (n, g) in enumerate((("Sun", ""), ("Moon", ""), ("Mars", "")))]
base["aspects"] = [{"obj1": "Sun", "obj2": "Moon", "aspect": "trine",
"orb": 1.0, "allowed": 8.0}]
return base
def test_only_the_administrator_can_manage_accounts(env, monkeypatch):
store.create("ala", "x", ["chart", "ai", "export", "extra_charts"])
c = _client(monkeypatch)
for method, path in (("get", "/accounts"), ("post", "/accounts/create"),
("post", "/accounts/update"), ("post", "/accounts/delete")):
kwargs = {"headers": _auth("ala", "x")}
if method == "post":
kwargs["data"] = {"login": "ktoś"}
r = getattr(c, method)(path, **kwargs)
assert r.status_code == 404, f"{path}{r.status_code}"
assert c.get("/accounts", headers=_auth("szef", "tajne-szefa")).status_code == 200
# ── pełny obieg: założenie konta z ekranu i zalogowanie się na nie ───────
def test_administrator_creates_an_account_and_it_works_immediately(env, monkeypatch):
"""Nagłówek całej funkcji: administrator zakłada konto z ekranu, a osoba na
tym koncie loguje się i dostaje DOKŁADNIE przyznany zestaw — bez restartu
aplikacji i bez dotykania konfiguracji środowiska."""
c = _client(monkeypatch)
admin = _auth("szef", "tajne-szefa")
r = c.post("/accounts/create", headers=admin, follow_redirects=False, data={
"login": "nowa", "password": "jej-hasło", "note": "praktykantka",
"granted": ["chart", "significators", "extra_charts"]})
assert r.status_code == 303
listing = c.get("/accounts", headers=admin).text
assert "nowa" in listing and "praktykantka" in listing
assert "jej-hasło" not in listing, "hasło nie ma prawa trafić na ekran"
her = _auth("nowa", "jej-hasło")
html = c.get("/", headers=her).text
assert 'href="/significators"' in html
assert 'href="/accounts"' not in html and 'href="/compile"' not in html
assert c.get("/compile", headers=her).status_code == 404
# odebranie uprawnienia działa od razu
c.post("/accounts/update", headers=admin, follow_redirects=False,
data={"login": "nowa", "granted": ["chart"]})
assert c.get("/significators", headers=her).status_code == 404
# skasowanie konta odcina logowanie
c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"})
assert c.get("/", headers=her).status_code == 401
def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch):
"""Konto administracyjne nie leży w pliku, więc nie ma czego skasować —
ale próba nie może też wywalić aplikacji ani skasować czegoś innego."""
c = _client(monkeypatch)
admin = _auth("szef", "tajne-szefa")
store.create("ala", "x", ["chart"])
r = c.post("/accounts/delete", headers=admin, follow_redirects=False,
data={"login": "szef"})
assert r.status_code == 303
assert store.exists("ala"), "kasowanie nieistniejącego konta ruszyło inne"
assert c.get("/", headers=admin).status_code == 200
# ── awaria magazynu kont ─────────────────────────────────────────────────
# Ekran kont to JEDYNE miejsce, z którego administrator może naprawić problem
# z magazynem — więc musi na nim przeczytać, co i gdzie jest nie tak. Gołe 500
# (tak było na pierwszym wdrożeniu) zostawia go z niczym.
def _unreadable(tmp_path, monkeypatch, mode):
d = tmp_path / "stan"
d.mkdir()
monkeypatch.setenv("ACCOUNTS_FILE", str(d / "accounts.json"))
d.chmod(mode)
return d
def test_unreadable_store_explains_itself_instead_of_500(env, tmp_path, monkeypatch):
d = _unreadable(tmp_path, monkeypatch, 0o000)
try:
c = _client(monkeypatch)
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
assert r.status_code == 200, "problem z magazynem nie może wywalać strony"
assert "Nie mogę odczytać pliku kont" in r.text
assert str(d / "accounts.json") in r.text, "komunikat ma podać ŚCIEŻKĘ"
finally:
d.chmod(0o755)
def test_read_only_store_refuses_to_save_with_a_reason(env, tmp_path, monkeypatch):
d = _unreadable(tmp_path, monkeypatch, 0o555)
try:
c = _client(monkeypatch)
r = c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
follow_redirects=False,
data={"login": "ala", "password": "x", "granted": ["chart"]})
assert r.status_code == 303
# Komunikat jedzie w parametrze zapytania, więc jest zakodowany —
# porównanie na surowym nagłówku sprawdzałoby procenty, nie treść.
from urllib.parse import unquote_plus
assert "Nie mogę zapisać pliku kont" in unquote_plus(r.headers["location"])
finally:
d.chmod(0o755)
def test_a_corrupt_file_is_never_silently_overwritten(env, tmp_path, monkeypatch):
"""Nadpisanie uszkodzonego pliku pustym zbiorem skasowałoby WSZYSTKIE konta.
Lepiej odmówić i powiedzieć, co jest nie tak."""
path = tmp_path / "accounts.json"
path.write_text("{to nie jest json", encoding="utf-8")
monkeypatch.setenv("ACCOUNTS_FILE", str(path))
c = _client(monkeypatch)
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
assert r.status_code == 200 and "uszkodzony" in r.text
c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
follow_redirects=False, data={"login": "ala", "password": "x"})
assert path.read_text(encoding="utf-8") == "{to nie jest json", \
"uszkodzony plik został nadpisany — konta by zniknęły"
@@ -66,12 +66,8 @@ def test_key_is_read_lazily():
# ──────────────────────── kosmogram w wariancie do druku ─────────────────
def test_pdf_route_uses_print_theme():
"""Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów.
Sprawdzamy, że trasa PDF-a w ogóle podaje motyw druku — bez wiązania się
z dokładnym kształtem wywołania (dochodzą do niego kolejne argumenty).
Pełny sprawdzian, że KAŻDY rysunek dostaje 'print', jest w test_compile.py."""
assert 'theme="print"' in MAIN
"""Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów."""
assert 'chartwheel.render(chart, theme="print")' in MAIN
def test_print_theme_has_no_css_variables():
@@ -1,45 +0,0 @@
"""Ekran ustawień: bazy na udziale (PRE-09)."""
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TPL = (APP / "templates" / "settings.html").read_text(encoding="utf-8")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
def test_tab_is_in_the_menu():
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
from app import features
tab = features.BY_KEY["settings"]
assert tab in features.SCREENS
assert tab.href and tab.label
def test_route_and_client_exist():
assert '@app.get("/settings"' in MAIN and "logic.bases()" in MAIN
assert 'f"{self.base_url}/bases"' in CLIENT
def test_listing_shows_name_and_metadata():
for col in ("b.name", "b.path", "b.size_mb", "b.modified"):
assert col in TPL, f"brak kolumny {col}"
def test_listing_shows_whether_base_counts_in_interpretation():
assert "b.enabled" in TPL and "wyłączona" in TPL
def test_page_explains_how_to_disable_and_why_not_clickable():
"""Ekran ma mówić, JAK wyłączyć bazę — inaczej podgląd bez sprawczości myli."""
assert "DISABLED_BASES" in TPL
assert "emptyDir" in TPL # uzasadnienie: brak trwałego zapisu
def test_error_from_logic_does_not_break_the_page():
assert "_logic_error(e)" in MAIN[MAIN.index("def settings_view"):MAIN.index("def settings_view") + 900]
@@ -86,39 +86,3 @@ def test_now_button_still_syncs_map():
"""Odświeżenie nazwy nie może zastąpić przesunięcia pineski i widoku mapy."""
body = _handler_body()
assert "marker.setLatLng" in body and "map.setView" in body
# ---- strefa czasowa z lokalizacji (PRE-03) ----
def test_setcoords_resolves_timezone():
"""Nowe współrzędne (mapa/wyszukiwarka) mają odświeżyć strefę i offset."""
src = _geo_source()
body = src[src.index("function setCoords"):src.index("\n }", src.index("function setCoords"))]
assert "resolveTz()" in body
def test_now_button_resolves_timezone():
"""„Tu i teraz" to nowe miejsce → nowa strefa."""
assert "resolveTz()" in _handler_body()
def test_date_change_recomputes_timezone():
"""Czas letni zależy od daty — zmiana daty musi przeliczyć offset."""
src = _geo_source()
assert "dateEl.addEventListener('change', resolveTz)" in src
def test_timezone_hint_does_not_clobber_manual_offset_on_load():
"""Na wejściu podpowiadamy strefę TYLKO gdy offset wygląda na nieustawiony —
inaczej nadpisalibyśmy wartość ręcznie wpisaną i wysłaną przez użytkownika."""
src = _geo_source()
assert "parseFloat(tzEl.value) === 0" in src
def test_timezone_fills_offset_field_and_shows_zone():
src = _geo_source()
start = src.index("function resolveTz")
body = src[start:src.index("\n }", start)]
assert "/timezone?lat=" in body
assert "setVal(tzEl, d.offset)" in body # wypełnia pole offsetu
assert "d.label" in body # pokazuje wykrytą strefę
@@ -1,69 +0,0 @@
"""Synastria — technika relacyjna (PRE-04)."""
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TPL = (APP / "templates" / "synastry.html").read_text(encoding="utf-8")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
def _client():
import os
os.environ.pop("APP_PASSWORD", None)
from starlette.testclient import TestClient
from app.main import app
return TestClient(app)
def test_tab_is_in_the_menu():
"""Zakładka jest w KATALOGU FUNKCJI — stamtąd buduje się nawigacja.
Menu nie jest już listą wpisaną w base.html: powstaje z uprawnień konta
(PRE-26), więc szukanie w szablonie sztywnego linku nic by nie mówiło.
Że nawigacja faktycznie renderuje katalog, sprawdza test_kontrola_dostepu."""
from app import features
tab = features.BY_KEY["synastry"]
assert tab in features.SCREENS
assert tab.href and tab.label
def test_routes_exist():
assert '@app.get("/synastry"' in MAIN and '@app.post("/synastry"' in MAIN
def test_get_renders_two_person_form():
r = _client().get("/synastry")
assert r.status_code == 200
# dwie osoby: pola z prefiksami a_ i b_
for pfx in ("a_", "b_"):
assert f'name="{pfx}date"' in r.text and f'name="{pfx}time"' in r.text
def test_form_has_both_people_and_shared_aspect_settings():
"""Pola dwóch osób generuje pętla — sprawdzamy WYRENDEROWANY formularz."""
html = _client().get("/synastry").text
for pfx in ("a_", "b_"):
assert f'name="{pfx}person"' in html
assert f'name="{pfx}lat"' in html
assert 'name="aspect_orb"' in html and 'name="aspect_minor"' in html
def test_handler_calls_synastry_with_both_moments():
assert "logic.synastry(" in MAIN
assert '"when_utc": iso_a' in MAIN and '"when_utc": iso_b' in MAIN
def test_client_posts_to_synastry_endpoint():
assert 'self._post("/chart/synastry"' in CLIENT
assert '"person_a": person_a' in CLIENT and '"person_b": person_b' in CLIENT
def test_result_table_shows_cross_aspects():
"""Wynik: kolumny osoba A · aspekt · osoba B · orb."""
assert "Aspekty synastryczne" in TPL
assert "a.obj1" in TPL and "a.obj2" in TPL and "a.glyph" in TPL
@@ -1,93 +0,0 @@
"""Strefa czasowa z lokalizacji (PRE-03).
Najważniejsze: offset ma być DST-świadomy i liczony DLA DATY URODZENIA (reguły
historyczne), a nie „na sztywno" — inaczej wróciłby dokładnie ten błąd, który
PRE-03 ma usunąć (pomylona godzina / czas letni → przeskok Ascendenta).
"""
import pytest
from app import timezone as tz
requires_tzfpy = pytest.mark.skipif(tz.get_tz is None, reason="brak tzfpy w środowisku")
@requires_tzfpy
def test_krakow_summer_is_plus_two_with_dst():
r = tz.resolve(50.0647, 19.945, "1984-06-01", "11:20")
assert r["tz"] == "Europe/Warsaw"
assert r["offset"] == 2.0
assert r["dst"] is True
assert "czas letni" in r["label"]
@requires_tzfpy
def test_krakow_winter_is_plus_one_without_dst():
r = tz.resolve(50.0647, 19.945, "1984-01-15", "10:00")
assert r["offset"] == 1.0
assert r["dst"] is False
assert "czas letni" not in r["label"]
@requires_tzfpy
def test_kathmandu_is_five_forty_five():
"""Katmandu +5:45 — sztandarowy przypadek offsetu w 15-minutowym kroku."""
r = tz.resolve(27.7172, 85.3240, "2000-05-01", "08:00")
assert r["tz"] == "Asia/Kathmandu"
assert r["offset"] == 5.75
assert "+5:45" in r["label"]
@requires_tzfpy
def test_new_york_dst_differs_by_season():
summer = tz.resolve(40.7128, -74.0060, "2020-07-01", "12:00")
winter = tz.resolve(40.7128, -74.0060, "2020-01-01", "12:00")
assert summer["offset"] == -4.0 and summer["dst"] is True # EDT
assert winter["offset"] == -5.0 and winter["dst"] is False # EST
@requires_tzfpy
def test_missing_time_defaults_to_noon_not_crash():
r = tz.resolve(50.0647, 19.945, "1984-06-01", "")
assert r and r["offset"] == 2.0
@requires_tzfpy
def test_bad_date_returns_none_not_exception():
assert tz.resolve(50.0, 19.9, "nonsense", "12:00") is None
def test_no_library_degrades_to_none(monkeypatch):
"""Brak tzfpy nie może wywalić — po prostu bez podpowiedzi (ręczny offset zostaje)."""
monkeypatch.setattr(tz, "get_tz", None)
assert tz.resolve(50.0, 19.9, "1984-06-01", "11:20") is None
# ---- endpoint /timezone ----
def _client():
import os
os.environ.pop("APP_PASSWORD", None) # bez bramki logowania w teście
from starlette.testclient import TestClient
from app.main import app
return TestClient(app)
@requires_tzfpy
def test_endpoint_returns_offset_for_a_point():
r = _client().get("/timezone", params={"lat": 50.0647, "lon": 19.945,
"date": "1984-06-01", "time": "11:20"})
assert r.status_code == 200
body = r.json()
assert body["offset"] == 2.0 and body["tz"] == "Europe/Warsaw"
def test_endpoint_404_when_zone_cannot_be_resolved(monkeypatch):
"""Gdy strefy nie da się ustalić → 404, front zostawia ręczny offset."""
from app import timezone as tzmod
monkeypatch.setattr(tzmod, "get_tz", None)
r = _client().get("/timezone", params={"lat": 0.0, "lon": 0.0, "date": "2000-01-01"})
assert r.status_code == 404
@@ -1,72 +0,0 @@
"""Dymki na kosmogramie (PRE-12/PRE-25).
Rysunek niesie <title> przy każdym obiekcie i każdej linii aspektu, ale natywne
dymki przeglądarki przestały się pokazywać po dodaniu powiększania na pełne okno.
Sprawdzone w przeglądarce: elementy z <title> SĄ osiągalne kursorem (19 z 20 trafień
w hit-teście), więc nic ich nie zasłaniało — problem był w samym natywnym dymku.
Test strukturalny, jak przy wheelzoom: bez uruchamiania przeglądarki nie zbadamy
zachowania JS-a. Pilnujemy więc tego, co da się zepsuć po cichu i co ma znaczenie:
kolejności warstw, zdejmowania i ODWIESZANIA <title> oraz wpięcia skryptu.
Zachowanie sprawdzone ręcznie w przeglądarce przy 1280×900, w obu stanach koła.
"""
import pathlib
import re
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
JS = (APP / "static" / "wheeltip.js").read_text(encoding="utf-8")
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
def test_script_is_loaded_globally():
"""W base.html, bo koło jest też na zakładce „Skompiluj"."""
assert "wheeltip.js" in BASE
def test_tooltip_sits_above_the_zoom_overlay_but_below_the_progress_window():
"""Warstwy: nakładka powiększonego koła < dymek < okno postępu.
Dymek MUSI być nad nakładką, inaczej po powiększeniu — czyli dokładnie tam,
gdzie zgłoszono problem — byłby niewidoczny. I POD oknem postępu, bo gdy trwa
pisanie horoskopu, log operacji ma zostać na wierzchu."""
tip = re.search(r"\.wheel-tip\s*\{[^}]*z-index:\s*(\d+)", CSS)
zoom = re.search(r"\.wheel-fig\.zoomed\s*\{[^}]*z-index:\s*(\d+)", CSS)
progress = re.search(r"\.progress-overlay\s*\{[^}]*z-index:\s*(\d+)", CSS)
assert tip and zoom and progress, "brakuje którejś reguły z-index"
assert int(zoom.group(1)) < int(tip.group(1)) < int(progress.group(1)), \
f"warstwy: nakładka {zoom.group(1)}, dymek {tip.group(1)}, postęp {progress.group(1)}"
def test_tooltip_never_swallows_the_pointer():
"""Dymek chodzi za kursorem, więc bez pointer-events:none zasłaniałby sam
sobie element, nad którym stoi — i migotał."""
rule = re.search(r"\.wheel-tip\s*\{([^}]*)\}", CSS)
assert rule and "pointer-events: none" in rule.group(1)
def test_title_is_detached_only_for_the_hover_and_always_put_back():
"""<title> zostaje w rysunku — to nazwa dostępna dla czytników ekranu.
Na czas najechania odpinamy go, żeby nie było dwóch dymków naraz, ale MUSI
wrócić. Brak odwieszania okradałby czytniki ekranu po pierwszym najechaniu,
i to po cichu — na ekranie nic by nie było widać."""
assert "removeChild" in JS, "dymek natywny nie jest tłumiony"
assert "appendChild(stash.node)" in JS, "brak odwieszania <title>"
for leaving in ("mouseleave", "scroll", "resize"):
assert leaving in JS, f"brak sprzątania przy: {leaving}"
def test_tooltip_reads_the_title_that_is_already_in_the_drawing():
"""Źródłem tekstu jest <title> z SVG, nie druga kopia opisów w JS —
inaczej rozjechałyby się przy pierwszej zmianie treści dymka."""
assert ":scope > title" in JS
assert "textContent" in JS
def test_pdf_path_is_untouched():
"""PDF składa się po stronie serwera, więc żaden dymek go nie dotyczy —
<title> zostają w rysunku i nic w chartwheel.py się nie zmienia."""
wheel = (APP / "chartwheel.py").read_text(encoding="utf-8")
assert "<title>" in wheel
assert "wheeltip" not in wheel
-16
View File
@@ -210,22 +210,6 @@ def build(report: dict, wheel_pdf: str | None = None,
parts.append(r"\end{tabular}")
parts.append(r"\vspace{1em}")
# ── 2a. ostrzeżenia o systemie domów ────────────────────────────────
# Muszą stać PRZED rysunkami: kosmogram narysowany po fallbacku wygląda
# bezbłędnie, więc jedyną informacją, że podział domów jest z innego systemu,
# jest ten akapit. W ramce, żeby nie dało się go wziąć za podpis.
warnings = [w for w in (report.get("warnings") or []) if str(w).strip()]
if warnings:
parts.append(r"\begin{center}")
parts.append(r"\fbox{\begin{minipage}{0.92\textwidth}\small")
for i, w in enumerate(warnings):
if i:
parts.append(r"\par\medskip")
parts.append(esc(str(w)))
parts.append(r"\end{minipage}}")
parts.append(r"\end{center}")
parts.append(r"\vspace{1em}")
# ── 3. rysunki kosmogramu ───────────────────────────────────────────
# Jedna reguła na wszystkie: keepaspectratio z limitem SZEROKOŚCI i WYSOKOŚCI.
# Koło i aspektarian są kwadratowe (ogranicza je wysokość), deklinacja i
-21
View File
@@ -307,24 +307,3 @@ def test_full_ai_markdown_leaves_no_visible_markers():
"###Podsekcja bez spacji\n\n- punkt **ważny**\n- i ***kluczowy***\n\n"
"#### Zamknięty ####")
assert _no_visible_markers(md(src))
def test_house_fallback_warning_lands_in_the_pdf():
"""Zasada projektu: co pokazujemy na stronie, trafia do raportu i PDF-a.
Po fallbacku systemu domów kosmogram wygląda bezbłędnie, więc ten akapit
jest JEDYNĄ informacją, że podział domów pochodzi z innego systemu, niż
zamówiono. Musi też stać PRZED rysunkami, nie po nich."""
tex = build({"person": "Jan Kowalski",
"data": {"date": "1984-04-30", "house_system": "porphyry"},
"warnings": ["UWAGA: system domów „placidus” nie ma definicji"]},
figures=[{"pdf": "wheel.pdf", "caption": "Kosmogram"}])
assert "fbox" in tex
assert "nie ma definicji" in tex
assert tex.index("nie ma definicji") < tex.index("wheel.pdf"), \
"ostrzeżenie musi stać przed rysunkami"
def test_no_warning_box_when_nothing_was_substituted():
tex = build({"person": "Jan Kowalski", "data": {}, "warnings": []})
assert "fbox" not in tex
-153
View File
@@ -1,153 +0,0 @@
# Porównanie domów z wyrocznią (Swiss Ephemeris)
Framework testowy do systemów domów. Liczenie cuspów traktujemy jako **funkcję**
parametrów `(RAMC, ε, φ)`, a **Swiss Ephemeris jako wyrocznię** — czyli niezależną
implementację, wobec której sprawdzamy naszą.
Powstał pod implementację systemów egzotycznych (Placidus, Koch, Regiomontanus,
Campanus…), gdzie błąd jest **cichy**: wykres wygląda poprawnie, tylko planety
siedzą w złych domach. Pojedyncze cuspy przepisane z internetu tego nie wyłapią —
tysiące porównań na całej dziedzinie tak.
## Dlaczego akurat tak
**Izolacja jednostki.** Obu implementacjom podajemy TE SAME wejścia przez
`swe_houses_armc`. Gdybyśmy porównywali „nasz horoskop" z „horoskopem swissepha",
różnica mogłaby wynikać z innego czasu gwiazdowego albo innego ε — czyli z czegoś,
co nie jest testowaną funkcją, i utonęlibyśmy w fałszywych alarmach.
**Czego ten framework NIE sprawdza — i trzeba o tym pamiętać.** Skoro ε podajemy
obu stronom, to **samo ε nie jest tu testowane**. Test `asc/mc` sprawdza wyłącznie
wzory na Ascendent i MC przy zadanym ε, a nie to, czy ε jest właściwe. Kosztowało
to realny błąd: silnik liczył RAMC z GAST (czas gwiazdowy POZORNY, od równonocy
PRAWDZIWEJ), ale parował go z ε ŚREDNIM, bez nutacji. Dwa różne układy odniesienia,
~3,2″ przesunięcia na cuspach — i komplet zielonych przebiegów wyroczni, bo błąd
siedział w danych WEJŚCIOWYCH, nie w testowanej funkcji.
Wejście ma więc własny sprawdzian, poza tym frameworkiem:
`test_sidereal_returns_true_obliquity_not_mean` w `services/logic/tests/`, ze
Skyfieldem jako niezależnym autorytetem (bez swissepha, więc działa wszędzie).
Uwaga pokrewna: porównanie „cały horoskop nasz vs cały horoskop swissepha" zostawia
resztę ~3″ nawet po naprawie ε — to UT1 kontra UTC. Skyfield konwertuje UTC na UT1
z tablic IERS, swisseph przyjmuje podany JD jako UT1 (w 1984-04-30 różnica wynosiła
0,181 s = 2,7″ RAMC). Nasza strona jest tu dokładniejsza. Dokładnie po to porównujemy
przy identycznym RAMC.
**Kryterium, nie procent.** Raportujemy liczbę przypadków **powyżej tolerancji
(1″)**, **maksymalne odchylenie** i **miejsce** jego wystąpienia oraz histogram.
Sam procent zgodności ukrywa kształt błędu: „97%" nie odróżnia szumu
zmiennoprzecinkowego od jednego rogu dziedziny, w którym mylimy się o 30°.
**Granice liczone per data.** Koło podbiegunowe — granica dziedziny Placidusa
i Kocha — **nie jest stałą 66,56°**. Zależy od ε, które zmienia się z datą (23,75°
w 370 p.n.e., 23,44° dziś), więc w zakresie programu przesuwa się o ~0,3°.
**Zgodność co do dziedziny.** Gdy wyrocznia odmawia liczenia (poza dziedziną),
a my uważamy, że liczymy — to też jest błąd, zgłaszany osobno. Wyrocznia mówi nam
zatem nie tylko *jakie są wartości*, ale i *gdzie kończy się dziedzina*.
## Uruchomienie
```bash
LOGIC_PATH=../../services/logic python run.py --mode build
```
| Tryb | Co robi | Gdzie działa |
|---|---|---|
| `--mode build` | zestaw **brzegowy** + tyle samo przypadków z wnętrza | CI, **blokuje** build |
| `--mode sweep --count N --seed S` | wielki **losowy** przemiał | Job w klastrze, nie blokuje |
Kod wyjścia: `0` = zgodność w granicach tolerancji, `1` = przekroczenia albo
niezgodność dziedziny. Ziarno jest zawsze raportowane — każdy błąd da się
**odtworzyć** komendą wypisaną w raporcie.
## Co ten framework już znalazł
Uruchomiony na kodzie, który uchodził za poprawny (whole sign / equal / porphyry),
wykrył **dwa realne błędy** w pierwszym przebiegu:
1. **Ascendent o 180° za kołem podbiegunowym.** `atan2` wybierał niewłaściwy punkt
przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent. Planety lądowały
w przeciwnych domach dla całej północnej Skandynawii (Tromsø, Rovaniemi,
Murmańsk). Dotyczyło ~11% przypadków na tych szerokościach.
2. **Niedeterminizm whole sign na granicy znaku.** Ascendent o włos od granicy
(359,999999999976 vs 1e-10 — ta sama wartość, różne strony) przerzucał dom I
o 30°. Ten sam horoskop na innej maszynie mógł dać inny wynik.
Oba mają teraz testy regresji w `services/logic/tests/test_houses.py`, więc są
łapane także bez swissepha.
## Stan systemów domów
Etap 1 — systemy o **zamkniętym wzorze** (bez iteracji). Każdy poniższy przeszedł
zarówno zestaw brzegowy (2520 porównań), jak i losowy przemiał 20 000 przypadków
(240 000 porównań na system):
| System | Konstrukcja | Maks. odchylenie |
|---|---|---|
| whole sign | podział ekliptyki | 0,000000000° |
| whole sign (od Barana) | dom I zawsze na 0° Barana | 0,000000000° |
| equal | podział ekliptyki od Ascendentu | 0,000000000° |
| equal (od MC) | dom X dokładnie na MC | 0,000000000° |
| porphyry | podział kwadrantów po ekliptyce | 0,000000000° |
| vehlow | equal z Ascendentem w środku domu I | 0,000000000° |
| morinus | równik rzutowany wprost na ekliptykę | 0,000000000° |
| regiomontanus | podział równika, koła przez punkty N/S horyzontu | 0,000000000° |
| campanus | podział wertykału pierwszego | 0,000000000° |
| alcabitus | podział łuków dobowych po równiku | 0,000000000° |
| topocentric | ascendenty pod biegunami tan(P) = tan(φ)·k/3 | 0,000000000° |
Etap 2 — systemy **łuku dobowego**, jedyne z realną granicą dziedziny:
| System | Konstrukcja | Maks. odchylenie |
|---|---|---|
| placidus | trójdzielenie półłuków, iteracyjnie | 0,000001686° |
| koch | trójdzielenie CZASU od wschodu stopnia MC | 0,000000000° |
Placidus jest jedynym systemem bez wzoru zamkniętego: cusp jest zdefiniowany
warunkiem na samego siebie, więc szukamy go iteracyjnie do 1e-11°. Pozostałe
1,7e-6° to **próg zbieżności wyroczni**, nie nasz błąd — nasz cusp spełnia
definicję Placidusa z dokładnością 1e-12° (test `test_placidus_cusps_satisfy_
their_own_definition` sprawdza to bez swissepha).
Powyżej koła podbiegunowego oba **odmawiają liczenia** — i wyrocznia odmawia
dokładnie tych samych przypadków (5245 na 20 000 losowych, zero rozjazdów).
Warstwa aplikacyjna łapie tę odmowę i podstawia Porphyry'ego z **głośnym
komunikatem**, który idzie na ekran, w prompt do modelu i do PDF-a: po podmianie
kosmogram wygląda bezbłędnie, więc jest to jedyny sygnał dla astrologa.
### Topocentric — i czego nauczył
Topocentric był początkowo **wycofany** z powodu rozjazdu przy |φ| ≈ 89,9°.
Ta diagnoza była **błędna**, bo opierała się wyłącznie na zestawie brzegowym,
który próbkuje tylko wybrane szerokości. Przemiał losowy pokazał prawdziwą skalę:
błędy zaczynały się już od ~70° i sięgały 50% przypadków blisko biegunów —
**6,5% całości**, nie dwa przypadki brzegowe.
Przyczyną nie była jednak konstrukcja, tylko wybór gałęzi. Dwa koła wielkie
przecinają się w dwóch punktach antypodycznych i trzeba wskazać właściwy.
Kolejno zawiodły: „po której stronie MC", „w łuku kwadrantu", „wschodnia połowa
horyzontu" oraz śledzenie ciągłości krokami. Każda z nich rozstrzyga **lokalnie**,
a przy dużych szerokościach kolejność domów potrafi się odwrócić — przy φ = 79,55°
MC wypada na 306,8°, a dom 11 na 291,4°, czyli domy biegną wstecz. To jest poprawne
i wyrocznia zwraca dokładnie to samo.
Rozwiązaniem było **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`. Zostając w wektorach i kotwicząc rodzinę
raz — na MC górującym — dostajemy zgodność **co do zera na całej dziedzinie**,
bez iteracji i bez zawężania szerokości.
Morał praktyczny: zestaw brzegowy mówi, **czy** system się psuje; dopiero przemiał
losowy mówi **jak bardzo**. Wniosek o skali wyciągnięty z samych brzegów był tu
zaniżony o trzy rzędy wielkości.
Większe jądro efemeryd (de441) — **odłożone, „nice to have"**: rozszerza tylko
zakres dat, nie poprawia niczego w obecnym.
## Licencja
Swiss Ephemeris jest na AGPL i jest tu **wyłącznie wyrocznią testową** — nie wchodzi
do ścieżki produkcyjnej ani do zależności produktu. Izolacja z LOG-27 zostaje
nienaruszona.
-153
View File
@@ -1,153 +0,0 @@
"""Dziedzina wejścia funkcji liczącej domy — klasy równoważności i wartości brzegowe.
Liczenie cuspów traktujemy jako FUNKCJĘ trzech parametrów: (RAMC, ε, φ). ε nie jest
niezależne wynika z daty, więc realnymi parametrami (data, φ) plus RAMC, który
zależy od czasu gwiazdowego i długości geograficznej.
Zakres dat programu: 370 p.n.e. 2270 n.e. (rok astronomiczny -369 2270).
PUŁAPKA, o którą łatwo się potknąć: granica dziedziny dla Placidusa i Kocha
koło podbiegunowe NIE jest stałą 66,56°. Zależy od ε, a ε zmienia się z datą.
W zakresie programu przesuwa się o ~0,3°. Wartości brzegowe MUSZĄ więc być liczone
per data, inaczej testy brzegowe celują obok granicy.
"""
from __future__ import annotations
import random
from dataclasses import dataclass
# Zakres dat programu jako dni juliańskie (rok astronomiczny: 370 p.n.e. = -369).
# Wyznacza go jądro efemeryd de421, którego używa silnik. Większe jądro (de441,
# ~13 000 lat) jest DECYZJĄ ODŁOŻONĄ — świadomie „nice to have", nie „must":
# rozszerza wyłącznie zakres dat, a nie poprawność czegokolwiek w obecnym
# zakresie. Gdy kiedyś dojdzie, tu jest jedno miejsce do zmiany — i wtedy
# wartości brzegowe (ε, koło podbiegunowe) trzeba przeliczyć dla nowych krańców.
JD_MIN = 1_566_839.5 # -369-01-01
JD_MAX = 2_695_000.5 # ~2270-12-31
JD_J2000 = 2_451_545.0 # punkt odniesienia — środek dziedziny
# Systemy oparte na łuku dobowym: tracą definicję za kołem podbiegunowym.
DIURNAL_ARC_SYSTEMS = frozenset({"placidus", "koch"})
# Systemy, w których NASZA dziedzina jest WĘŻSZA niż wyroczni — świadomie, nie przez
# rozjazd. Topocentric: swisseph zwraca powyżej koła podbiegunowego cuspy, które nie
# dzielą okręgu (domy nachodzą na siebie), i sam sobie tam przeczy — swe_house_pos
# nie zgadza się z jego własnymi cuspami. Wolimy odmówić niż podać liczbę bez
# znaczenia, więc harness ma tego NIE zgłaszać jako niezgodności dziedziny.
NARROWER_THAN_ORACLE = frozenset({"topocentric"})
# Jak blisko granicy stawiamy wartości brzegowe (po obu stronach).
EPSILON_NUDGE = 0.01
@dataclass(frozen=True)
class Case:
"""Pojedynczy zestaw danych wejściowych + skąd się wziął (do raportu)."""
ramc: float
lat: float
jd: float
label: str # "brzeg: równik" / "wnętrze" / "losowy" — grupowanie w raporcie
def key(self) -> str:
return f"ramc={self.ramc:.4f} lat={self.lat:.4f} jd={self.jd:.1f}"
def obliquity(jd: float) -> float:
"""ε dla daty — z NASZEJ implementacji, żeby granice liczyć tak samo jak silnik."""
from app.engine.houses import mean_obliquity
return mean_obliquity(jd)
def polar_circle(jd: float) -> float:
from app.engine.houses import polar_circle as pc
return pc(obliquity(jd))
def in_domain(system: str, lat: float, jd: float) -> bool:
"""Czy zestaw należy do dziedziny danego systemu.
Poza dziedziną wyłącznie systemy łuku dobowego powyżej koła podbiegunowego
(i same bieguny, gdzie degeneruje się cała geometria)."""
if abs(lat) >= 90.0:
return False
if system in DIURNAL_ARC_SYSTEMS or system in NARROWER_THAN_ORACLE:
return abs(lat) < polar_circle(jd)
return True
# ── wartości brzegowe ────────────────────────────────────────────────────
def _boundary_lats(jd: float) -> list[tuple[float, str]]:
"""Szerokości brzegowe DLA DANEJ DATY (koło podbiegunowe zależy od ε)."""
pc = polar_circle(jd)
out: list[tuple[float, str]] = [
(0.0, "brzeg: równik"),
(pc - EPSILON_NUDGE, "brzeg: tuż POD kołem podbieg."),
(pc + EPSILON_NUDGE, "brzeg: tuż ZA kołem podbieg."),
(-(pc - EPSILON_NUDGE), "brzeg: tuż pod kołem (płd.)"),
(-(pc + EPSILON_NUDGE), "brzeg: tuż za kołem (płd.)"),
(89.9, "brzeg: prawie biegun N"),
(-89.9, "brzeg: prawie biegun S"),
]
return out
def _boundary_ramcs() -> list[tuple[float, str]]:
"""RAMC na krańcach i w punktach kardynalnych — tam wzory mają osobliwości."""
return [
(0.0, "brzeg: RAMC 0°"),
(90.0, "brzeg: RAMC 90°"),
(180.0, "brzeg: RAMC 180°"),
(270.0, "brzeg: RAMC 270°"),
(359.99, "brzeg: RAMC ~360°"),
]
def _boundary_jds() -> list[tuple[float, str]]:
return [
(JD_MIN, "brzeg: początek zakresu dat"),
(JD_J2000, "środek zakresu (J2000)"),
(JD_MAX, "brzeg: koniec zakresu dat"),
]
def boundary_cases() -> list[Case]:
"""Iloczyn wartości brzegowych — pełne pokrycie kombinacji granicznych."""
out: list[Case] = []
for jd, jl in _boundary_jds():
for lat, ll in _boundary_lats(jd):
for ramc, rl in _boundary_ramcs():
out.append(Case(ramc, lat, jd, f"{ll} | {rl} | {jl}"))
return out
def interior_cases(count: int, seed: int = 0) -> list[Case]:
"""Tyle samo przypadków z WNĘTRZA klas równoważności, ile brzegowych.
Deterministyczne przy danym ziarnie ten sam build daje ten sam zestaw.
"""
rnd = random.Random(seed)
out: list[Case] = []
for _ in range(count):
jd = rnd.uniform(JD_MIN, JD_MAX)
pc = polar_circle(jd)
# wnętrze = z dala od granic, żeby nie dublować przypadków brzegowych
lat = rnd.uniform(-pc + 1.0, pc - 1.0)
out.append(Case(rnd.uniform(0.0, 360.0), lat, jd, "wnętrze"))
return out
def random_cases(count: int, seed: int) -> list[Case]:
"""Wielki losowy przemiał — CAŁA dziedzina, także za kołem podbiegunowym.
Ziarno jest wymagane i raportowane: losowy test, którego nie da się odtworzyć,
jest bezużyteczny, bo czerwień raz na tydzień nie daje się zdiagnozować.
"""
rnd = random.Random(seed)
return [
Case(rnd.uniform(0.0, 360.0), rnd.uniform(-89.9, 89.9),
rnd.uniform(JD_MIN, JD_MAX), "losowy")
for _ in range(count)
]
-236
View File
@@ -1,236 +0,0 @@
"""Porównanie naszych domów z wyrocznią (Swiss Ephemeris) i raport zgodności.
ZASADA IZOLACJI: obu implementacjom podajemy TE SAME wejścia (RAMC, ε, φ) przez
`swe.houses_armc`. Gdybyśmy porównywali nasz horoskop" z „horoskopem swissepha",
różnica w cuspie mogłaby wynikać z innego czasu gwiazdowego albo innego ε czyli
z czegoś, co nie jest testowaną funkcją. Wtedy przy tysiącach przypadków utonęlibyśmy
w fałszywych alarmach. Czas gwiazdowy i ε testujemy OSOBNO (`compare_asc_mc`).
KRYTERIUM: nie procent zgodności, tylko liczba przypadków POWYŻEJ tolerancji plus
maksymalne odchylenie i MIEJSCE jego wystąpienia. Procent ukrywa kształt błędu:
97%" nie odróżnia szumu zmiennoprzecinkowego od jednego rogu dziedziny, w którym
mylimy się o 30°.
LICENCJA: Swiss Ephemeris (AGPL) jest tu WYŁĄCZNIE wyrocznią testową. Nie wchodzi
do ścieżki produkcyjnej ani do zależności produktu izolacja z LOG-27 zostaje.
"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
import swisseph as swe
from domain import NARROWER_THAN_ORACLE, Case, in_domain, obliquity
# Litery systemów w Swiss Ephemeris.
SWE_CODE = {
"whole_sign": b"W", "equal": b"E",
"equal_mc": b"D",
"whole_sign_aries": b"N", "porphyry": b"O",
"placidus": b"P", "koch": b"K", "regiomontanus": b"R", "campanus": b"C",
"morinus": b"M", "alcabitus": b"B", "vehlow": b"V", "topocentric": b"T",
}
TOLERANCE_DEG = 1.0 / 3600.0 # 1″ — uzgodniony próg
def angular_diff(a: float, b: float) -> float:
"""Najmniejsza różnica kątowa [0,180] — 359,9° i 0,1° dzieli 0,2°, nie 359,8°."""
d = abs(a - b) % 360.0
return min(d, 360.0 - d)
@dataclass
class Result:
system: str
checked: int = 0
over_tolerance: int = 0
skipped_out_of_domain: int = 0
max_dev: float = 0.0
max_where: str = ""
domain_mismatch: list[str] = field(default_factory=list)
deviations: list[float] = field(default_factory=list)
@property
def passed(self) -> bool:
return self.over_tolerance == 0 and not self.domain_mismatch
def oracle_cusps(case: Case, eps: float, system: str) -> list[float] | None:
"""Cuspy z wyroczni albo None, gdy wyrocznia odmawia (poza dziedziną).
pyswisseph zgłasza wyjątek dla Placidusa/Kocha powyżej koła podbiegunowego
to sygnał, że zestaw jest poza dziedziną, i sam w sobie jest użyteczny: pozwala
sprawdzić, czy NASZA detekcja granicy pokrywa się z wyrocznią."""
try:
cusps, _ = swe.houses_armc(case.ramc % 360.0, case.lat, eps, SWE_CODE[system])
return list(cusps)
except Exception:
return None
def compare_asc_mc(cases: list[Case]) -> Result:
"""Test funkcji WEJŚCIOWEJ: nasze Asc/MC vs wyrocznia, przy tym samym (RAMC, ε, φ).
Osobno, bo błąd tutaj kaskadowałby na wszystkie systemy i wyglądał jak błąd domów.
"""
from app.engine.houses import compute_asc, compute_mc
r = Result(system="asc/mc")
for c in cases:
eps = obliquity(c.jd)
try:
_, ascmc = swe.houses_armc(c.ramc % 360.0, c.lat, eps, b"O")
except Exception:
r.skipped_out_of_domain += 1
continue
for ours, theirs, what in (
(compute_asc(c.ramc, eps, c.lat), ascmc[0], "Asc"),
(compute_mc(c.ramc, eps), ascmc[1], "MC"),
):
d = angular_diff(ours, theirs)
r.checked += 1
r.deviations.append(d)
if d > r.max_dev:
r.max_dev, r.max_where = d, f"{what} @ {c.key()} [{c.label}]"
if d > TOLERANCE_DEG:
r.over_tolerance += 1
return r
def compare_system(system: str, cases: list[Case]) -> Result:
"""Porównanie jednego systemu domów z wyrocznią na zadanym zestawie przypadków."""
from app.engine.houses import cusps_for
r = Result(system=system)
for c in cases:
eps = obliquity(c.jd)
ours_in_domain = in_domain(system, c.lat, c.jd)
theirs = oracle_cusps(c, eps, system)
# Zgodność co do DZIEDZINY: jeśli wyrocznia odmawia, a my uważamy, że
# liczymy — nasza detekcja granicy jest zepsuta (albo odwrotnie).
# Węższa dziedzina po naszej stronie jest świadoma (patrz NARROWER_THAN_ORACLE),
# więc nie jest rozjazdem. Odwrotność — my liczymy, wyrocznia odmawia — nadal jest.
narrower_on_purpose = (system in NARROWER_THAN_ORACLE
and theirs is not None and not ours_in_domain)
if not narrower_on_purpose and (theirs is None) != (not ours_in_domain):
r.domain_mismatch.append(
f"{c.key()} [{c.label}] — my:{'w dziedzinie' if ours_in_domain else 'poza'}, "
f"wyrocznia:{'odmawia' if theirs is None else 'liczy'}")
continue
if theirs is None or not ours_in_domain:
r.skipped_out_of_domain += 1
continue
try:
ours = cusps_for(c.ramc, eps, c.lat, system)
except Exception as e: # nasz kod NIE MOŻE się wywalić
r.domain_mismatch.append(f"{c.key()} [{c.label}] — nasz kod rzucił: {e}")
continue
for i in range(12):
d = angular_diff(ours[i], theirs[i])
r.checked += 1
r.deviations.append(d)
if d > r.max_dev:
r.max_dev, r.max_where = d, f"dom {i + 1} @ {c.key()} [{c.label}]"
if d > TOLERANCE_DEG:
r.over_tolerance += 1
return r
def histogram(devs: list[float]) -> str:
"""Rozkład odchyleń — pokazuje KSZTAŁT zgodności, nie tylko sam maksimum."""
if not devs:
return " (brak danych)"
buckets = [
("= 0", lambda d: d == 0.0),
("< 1e-9°", lambda d: 0 < d < 1e-9),
("< 1e-6°", lambda d: 1e-9 <= d < 1e-6),
("< 1″", lambda d: 1e-6 <= d < TOLERANCE_DEG),
("≥ 1″ (BŁĄD)", lambda d: d >= TOLERANCE_DEG),
]
out = []
for name, pred in buckets:
n = sum(1 for d in devs if pred(d))
if n:
out.append(f" {name:14} {n:7} {'' * min(40, 1 + n * 40 // len(devs))}")
return "\n".join(out)
# Ile punktów ekliptyki sprawdzamy na jeden zestaw (RAMC, ε, φ).
_PROBES_PER_CASE = 6
def compare_house_assignment(system: str, cases: list[Case]) -> Result:
"""Nasze przypisanie obiektu do domu vs swe_house_pos.
To OSOBNA funkcja od liczenia cuspów i osobny rodzaj błędu: cuspy mogą się
zgadzać co do zera, a planeta i tak wylądować w złym domu. Tak właśnie było
przy |φ| powyżej koła podbiegunowego systemy dzielące koła wielkie mają
kolejność domów ODWRÓCONĄ, a nasze assign_house szło zawsze do przodu.
WYROCZNIA MA TU GRANICĘ WIARYGODNOŚCI. Dla topocentrica swe_house_pos
przestaje być zgodne z WŁASNYMI cuspami swissepha powyżej koła podbiegunowego
(zmierzone: 100% zgodności do 62°, 83,9% przy 66°, ok. 50% przy 72°; dla
regiomontanusa w tych samych punktach 100%). Skoro wyrocznia przeczy sama
sobie, nie może rozstrzygać nie porównujemy tam, zamiast dopisywać wyjątek
bo topocentric". Nasze przypisanie jest w tym obszarze sprawdzane inaczej:
testem samospójności z cuspami (services/logic/tests/test_houses.py).
"""
from app.engine.houses import assign_house, cusps_for, polar_circle
r = Result(system=f"dom:{system}")
rng = random.Random(20260806)
for c in cases:
eps = obliquity(c.jd)
if system == "topocentric" and abs(c.lat) >= polar_circle(eps):
r.skipped_out_of_domain += 1
continue
try:
ours_cusps = cusps_for(c.ramc, eps, c.lat, system)
except Exception:
r.skipped_out_of_domain += 1
continue
for _ in range(_PROBES_PER_CASE):
lon = rng.uniform(0.0, 360.0)
try:
theirs = int(swe.house_pos(c.ramc % 360.0, c.lat, eps,
(lon, 0.0), SWE_CODE[system]))
except Exception:
r.skipped_out_of_domain += 1
continue
r.checked += 1
ours = assign_house(lon, ours_cusps)
if ours != theirs:
r.over_tolerance += 1
r.max_dev = max(r.max_dev, float(abs(ours - theirs)))
if len(r.domain_mismatch) < 5:
r.domain_mismatch.append(
f"{c.key()} lon={lon:.4f} — nasz dom {ours}, wyrocznia {theirs}")
return r
def format_report(results: list[Result], seed: int | None = None) -> str:
lines = ["", "=" * 78,
"ZGODNOŚĆ Z WYROCZNIĄ (Swiss Ephemeris) — domy astrologiczne",
f"tolerancja: {TOLERANCE_DEG:.8f}° (1″)" + (f" ziarno: {seed}" if seed is not None else ""),
"=" * 78,
f"{'SYSTEM':<18}{'SPRAWDZONYCH':>13}{'>TOL':>7}{'POZA DZIEDZ.':>14}{'MAX ODCH.':>14} WYNIK"]
for r in results:
lines.append(f"{r.system:<18}{r.checked:>13}{r.over_tolerance:>7}"
f"{r.skipped_out_of_domain:>14}{r.max_dev:>14.9f} "
f"{'OK' if r.passed else 'BŁĄD'}")
for r in results:
if r.max_dev > 0 or not r.passed:
lines += ["", f" [{r.system}] największe odchylenie: {r.max_dev:.9f}° — {r.max_where}"]
if r.domain_mismatch:
lines.append(f" [{r.system}] NIEZGODNOŚĆ DZIEDZINY ({len(r.domain_mismatch)}):")
lines += [f" {m}" for m in r.domain_mismatch[:5]]
if len(r.domain_mismatch) > 5:
lines.append(f" … i {len(r.domain_mismatch) - 5} więcej")
lines += ["", " Rozkład odchyleń (wszystkie systemy):", histogram(
[d for r in results for d in r.deviations]), "=" * 78]
return "\n".join(lines)
-78
View File
@@ -1,78 +0,0 @@
"""Uruchomienie porównania z wyrocznią. Punkt wejścia dla CI i dla Joba w klastrze.
Dwa tryby:
--mode build (domyślny) zestaw BRZEGOWY + tyle samo przypadków z wnętrza.
Szybki, deterministyczny, BLOKUJE build przy błędzie.
--mode sweep wielki losowy przemiał, o rząd wielkości większy.
Do Joba w klastrze; ziarno raportowane, więc każdy
błąd da się odtworzyć komendą z raportu.
Kod wyjścia: 0 = zgodność w granicach tolerancji, 1 = przekroczenia albo
niezgodność dziedziny. Dzięki temu CI może na tym polegać bez parsowania tekstu.
"""
from __future__ import annotations
import argparse
import os
import sys
# Ścieżka do warstwy logicznej: w CI montujemy ją do kontenera swissepha.
sys.path.insert(0, os.environ.get("LOGIC_PATH", "/logic"))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from domain import boundary_cases, interior_cases, random_cases # noqa: E402
from harness import ( # noqa: E402
SWE_CODE, compare_asc_mc, compare_house_assignment, compare_system,
format_report,
)
# Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) —
# dopisanie nazwy tutaj wystarcza, żeby weszła do każdego builda.
SYSTEMS = ["whole_sign", "whole_sign_aries", "equal", "equal_mc", "porphyry", "vehlow", "morinus",
"regiomontanus", "campanus", "alcabitus", "topocentric", "placidus", "koch"]
def main() -> int:
p = argparse.ArgumentParser(description="Zgodność domów z wyrocznią (Swiss Ephemeris)")
p.add_argument("--mode", choices=("build", "sweep"), default="build")
p.add_argument("--seed", type=int, default=0, help="ziarno losowania (do odtworzenia błędu)")
p.add_argument("--count", type=int, default=20000, help="liczba przypadków w trybie sweep")
p.add_argument("--systems", default=",".join(SYSTEMS))
args = p.parse_args()
systems = [s.strip() for s in args.systems.split(",") if s.strip()]
unknown = [s for s in systems if s not in SWE_CODE]
if unknown:
print(f"Nieznane systemy: {unknown}", file=sys.stderr)
return 2
if args.mode == "build":
boundary = boundary_cases()
# Tyle samo z wnętrza, ile brzegowych — brzegi łapią osobliwości,
# wnętrze pilnuje, że nie zepsuliśmy zwykłych przypadków.
cases = boundary + interior_cases(len(boundary), seed=args.seed)
seed_note = args.seed
else:
cases = random_cases(args.count, seed=args.seed)
seed_note = args.seed
results = [compare_asc_mc(cases)]
results += [compare_system(s, cases) for s in systems]
# Przypisanie obiektu do domu — osobny rodzaj błędu niż same cuspy.
results += [compare_house_assignment(s, cases) for s in systems]
print(f"\nprzypadków wejściowych: {len(cases)} (tryb: {args.mode})")
print(format_report(results, seed=seed_note))
failed = [r for r in results if not r.passed]
if failed:
print("\nBŁĄD: " + ", ".join(r.system for r in failed))
print(f"Odtworzenie: python run.py --mode {args.mode} --seed {args.seed}"
+ (f" --count {args.count}" if args.mode == "sweep" else ""))
return 1
print("\nWszystkie systemy zgodne z wyrocznią w granicach tolerancji.")
return 0
if __name__ == "__main__":
raise SystemExit(main())