Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d1daab35a | |||
| aec3f84331 | |||
| 7266f671a4 | |||
| 548d9301f3 | |||
| 1be57a47d8 | |||
| 40f5e459e0 | |||
| 86a0f16f9e | |||
| e3114f3e7c | |||
| bc80745a94 | |||
| 6e7cfcbbd7 | |||
| 70c83cfc0c | |||
| 4c1e7f8808 | |||
| d3d9b365fe | |||
| dd32f7e82f | |||
| 78af6d4755 | |||
| 7b435d4263 | |||
| a9f2a038fa | |||
| b36b3bee19 | |||
| a0d1135db1 | |||
| 81e09aa6df |
@@ -0,0 +1,33 @@
|
||||
# Kontekst builda dla obrazów budowanych z KORZENIA repo (dziś: pomocniczy obraz
|
||||
# wyroczni w CI — patrz .gitea/workflows/tests.yml). Obrazy usług mają własne
|
||||
# konteksty (services/<usługa>), więc ten plik ich nie dotyczy.
|
||||
#
|
||||
# UWAGA: docker NIE czyta .gitignore. Bez tego pliku do demona poleciałby m.in.
|
||||
# lokalny wirtualenv (~344 MB) i jądra efemeryd — a runner miał już incydent
|
||||
# „no space left on device".
|
||||
|
||||
# środowiska lokalne
|
||||
.env/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# cache Pythona i narzędzi
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# dane generowane/pobierane, odtwarzalne
|
||||
**/.ephemeris/
|
||||
**/.cache/
|
||||
|
||||
# historia i metadane repo
|
||||
.git/
|
||||
.gitea/
|
||||
.claude/
|
||||
|
||||
# rzeczy nieużywane w obrazach
|
||||
docs/
|
||||
*.md
|
||||
@@ -71,6 +71,26 @@ 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
|
||||
@@ -121,6 +141,34 @@ jobs:
|
||||
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.
@@ -0,0 +1,58 @@
|
||||
# Rekordy-pułapki (canary) — instrukcja (DAN-26)
|
||||
|
||||
Zabezpieczenie **detekcyjne**: nie zapobiega wyciekowi baz, ale pozwala go
|
||||
**wykryć** i wskazać **z której kopii** wyciekł. Bazy są kupione i są rdzeniem
|
||||
produktu — jeśli krążą gdzie indziej, chcemy to udowodnić.
|
||||
|
||||
Mechanizm żyje w warstwie danych (`services/data/app/canary.py`) i działa na
|
||||
wyjściu z `/search`, więc pułapki **nie docierają** ani do użytkownika, ani do
|
||||
promptu LLM (wymóg LOG-30) — niezależnie od dostawcy (Excel/SQL).
|
||||
|
||||
## Jak to działa
|
||||
|
||||
1. **Marker** — unikalny ciąg, który nie występuje w realnych danych, wpleciony
|
||||
w kilka wiarygodnie wyglądających rekordów-pułapek w bazach (np. w polu
|
||||
znaczącym: `Ve Tau ASTROLOLO-CANARY-7f3a9`).
|
||||
2. **Odsiewanie** — warstwa danych wykrywa rekord z markerem i usuwa go z wyników,
|
||||
zanim opuszczą usługę. Interpretacje i prompty są czyste (log `info`).
|
||||
3. **Tripwire** — jeśli zapytanie **celuje wprost** w marker (ktoś enumeruje bazę,
|
||||
a nie liczy horoskopu), leci `warning` — sygnał podejrzanego zachowania.
|
||||
|
||||
## Konfiguracja (per wdrożenie)
|
||||
|
||||
Zmienne środowiskowe usługi `data`:
|
||||
|
||||
| Zmienna | Znaczenie |
|
||||
|---|---|
|
||||
| `CANARY_MARKERS` | markery oddzielone przecinkami (kilka na wariant) |
|
||||
| `CANARY_VARIANT` | etykieta wariantu tego wdrożenia (np. `prod-2026`, `partnerX`) |
|
||||
|
||||
Bez `CANARY_MARKERS` mechanizm jest **przezroczysty** (zero kosztu). Markery są
|
||||
sekretem — trzymaj je jak `astrololo-auth` (poza repo GitOps), przez `secretKeyRef`.
|
||||
|
||||
## Rejestr wariant → kopia (traitor tracing)
|
||||
|
||||
Sedno atrybucji: **każda dystrybuowana kopia baz dostaje inny zestaw pułapek**,
|
||||
a Ty trzymasz mapę, który wariant trafił dokąd. Gdy zobaczysz pułapkę w cudzej
|
||||
kopii → sprawdzasz marker w rejestrze → wiesz, skąd wyciekła.
|
||||
|
||||
Rejestr trzymaj **poza kodem i repo** (arkusz/menedżer sekretów po stronie ops),
|
||||
np.:
|
||||
|
||||
| Wariant | Markery | Wdrożenie / odbiorca | Data |
|
||||
|---|---|---|---|
|
||||
| `prod-2026` | `…7f3a9`, `…b12c` | produkcja czernobog | 2026-08 |
|
||||
| `partnerX` | `…9de4`, `…0a1b` | kopia dla partnera X | 2026-08 |
|
||||
|
||||
## Wstrzyknięcie pułapek do baz (krok właściciela)
|
||||
|
||||
To robi właściciel na **realnych** plikach (kod tego nie robi — nie ruszamy
|
||||
kupionych baz automatycznie): dodać kilka rekordów-pułapek z markerem danego
|
||||
wariantu, w stylu nieodróżnialnym od prawdziwych wpisów. Kilka, wtopionych —
|
||||
łatwiej, gdy ktoś zna mechanizm, wyciąć jeden oczywisty niż wszystkie.
|
||||
|
||||
## Granice
|
||||
|
||||
Canary dowodzi pochodzenia tylko, gdy wyciek **zawiera** treść pułapki (pełna
|
||||
kopia — tak; parafraza — niekoniecznie). Nie wykrywa retencji *prawdziwej* treści
|
||||
u dostawcy LLM — od tego jest bramka LOG-32 i wniosek o Zero Data Retention.
|
||||
@@ -0,0 +1,195 @@
|
||||
# DAN-25 — zamknięcie dostępu do baz na NFS (TrueNAS SCALE)
|
||||
|
||||
Bazy interpretacyjne leżą na `192.168.1.34:/mnt/Tank1/astrololo`. Dziś udział jest
|
||||
osiągalny z całej sieci, więc **kto ma dostęp do LAN, bierze kompletne bazy
|
||||
w oryginale — z pominięciem logowania, limitów, audytu i canary**. Żadne
|
||||
zabezpieczenie w kodzie tego nie zamyka: to najkrótsza droga do wycieku.
|
||||
|
||||
Cel: udział `astrololo` widoczny **tylko dla trzech węzłów k3s**, **tylko do
|
||||
odczytu**, z **root_squash**.
|
||||
|
||||
## ⚠️ Zasada nadrzędna: ruszamy WYŁĄCZNIE udział astrololo
|
||||
|
||||
Tank1 obsługuje cały homelab — Proxmox (`proxmox-NFS`), conjurera (`conjurer_swap`,
|
||||
z **zapisem**), media, LXC-e, stację roboczą. **Nie dotykamy globalnych ustawień
|
||||
usługi NFS ani innych udziałów** — inaczej wywalimy VM-y, bota i bibliotekę mediów.
|
||||
Każda komenda niżej celuje w jeden konkretny udział.
|
||||
|
||||
Druga zasada: **w TrueNAS SCALE nie edytuje się `/etc/exports` ręcznie.** Plik
|
||||
generuje middleware i nadpisze każdą ręczną zmianę. Wszystko robimy przez `midclt`
|
||||
(albo GUI: *Shares → Unix (NFS) Shares*).
|
||||
|
||||
## Ustalone dane
|
||||
|
||||
| Co | Wartość |
|
||||
|---|---|
|
||||
| NAS | `192.168.1.34` (TrueNAS SCALE / Community Edition) |
|
||||
| Udział do zamknięcia | `/mnt/Tank1/astrololo` |
|
||||
| Węzły k3s (jedyni uprawnieni) | `192.168.1.73` (server), `192.168.1.80` (agent2), `192.168.1.81` (agent1) |
|
||||
| Kto montuje astrololo | wyłącznie pod `data` w ns `astrololo`, **read-only** |
|
||||
|
||||
---
|
||||
|
||||
## Faza 0 — rozpoznanie (nic nie zmienia)
|
||||
|
||||
```bash
|
||||
ssh admin@192.168.1.34
|
||||
```
|
||||
|
||||
Wersja systemu (potwierdza, że komendy niżej pasują):
|
||||
|
||||
```bash
|
||||
midclt call system.version
|
||||
```
|
||||
|
||||
Lista udziałów NFS z ich obecnymi ustawieniami — **stąd bierzemy ID udziału astrololo**:
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.query | python3 -m json.tool
|
||||
```
|
||||
|
||||
> W wyniku poszukaj wpisu ze ścieżką `/mnt/Tank1/astrololo` i zapamiętaj jego `id`.
|
||||
> **Sprawdź też, jak nazywają się pola** (`path` vs `paths`, `hosts`, `networks`,
|
||||
> `ro`, `maproot_user`, `mapall_user`) — middleware zmieniało ich nazwy między
|
||||
> wersjami SCALE. Dalsze komendy używają nazw z Twojego wyniku.
|
||||
|
||||
Kto jest teraz podłączony (żeby nie odciąć czegoś w trakcie pracy):
|
||||
|
||||
```bash
|
||||
ss -tn state established '( sport = :2049 )'
|
||||
```
|
||||
|
||||
## Faza 1 — dowód dziury (zrób PRZED zmianą)
|
||||
|
||||
Na **stacji roboczej** (Mac mini, czyli host spoza klastra):
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/nfs-test && sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
|
||||
```
|
||||
|
||||
```bash
|
||||
ls -la /tmp/nfs-test | head
|
||||
```
|
||||
|
||||
Jeśli widzisz pliki baz — **to jest dokładnie problem, który zamykamy**. Odmontuj:
|
||||
|
||||
```bash
|
||||
sudo umount /tmp/nfs-test
|
||||
```
|
||||
|
||||
## Faza 2 — kopia obecnej konfiguracji (możliwość cofnięcia)
|
||||
|
||||
Na NAS-ie, podstaw `<ID>` z Fazy 0:
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.query '[["id","=",<ID>]]' > /root/astrololo-nfs-share.backup.json && cat /root/astrololo-nfs-share.backup.json
|
||||
```
|
||||
|
||||
## Faza 3 — zawężenie udziału
|
||||
|
||||
Jedna komenda ustawia wszystkie trzy zabezpieczenia naraz: listę hostów, tylko
|
||||
odczyt i root_squash. Podstaw `<ID>`:
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.update <ID> '{"hosts": ["192.168.1.73", "192.168.1.80", "192.168.1.81"], "ro": true, "maproot_user": null, "maproot_group": null, "mapall_user": null, "mapall_group": null}'
|
||||
```
|
||||
|
||||
Co robi każdy element:
|
||||
|
||||
| Ustawienie | Znaczenie |
|
||||
|---|---|
|
||||
| `hosts` | eksport **tylko** dla trzech węzłów k3s — reszta LAN przestaje widzieć udział |
|
||||
| `ro: true` | tylko odczyt; aplikacja i tak montuje read-only, więc niczego nie łamie |
|
||||
| `maproot_*`, `mapall_*` = `null` | **root_squash**: root z klienta nie jest rootem na udziale |
|
||||
|
||||
Zastosuj i sprawdź, że middleware przepisał eksporty:
|
||||
|
||||
```bash
|
||||
midclt call service.reload nfs && exportfs -v | grep -A1 astrololo
|
||||
```
|
||||
|
||||
> Jeśli Twoja wersja nie ma `service.reload`, użyj GUI (*Shares → NFS → zapisz*),
|
||||
> co wymusi to samo.
|
||||
|
||||
## Faza 4 — weryfikacja (wszystkie cztery testy)
|
||||
|
||||
**1. Spoza klastra ma NIE działać.** Na Macu:
|
||||
|
||||
```bash
|
||||
sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
|
||||
```
|
||||
|
||||
Oczekiwane: `access denied` / `Operation not permitted`. **Jeśli montuje się dalej —
|
||||
zmiana nie zadziałała, nie idź dalej.**
|
||||
|
||||
**2. Z węzła klastra ma działać.**
|
||||
|
||||
```bash
|
||||
ssh 192.168.1.73 'sudo mount -t nfs -o ro 192.168.1.34:/mnt/Tank1/astrololo /mnt/test && ls /mnt/test | head -3 && sudo umount /mnt/test'
|
||||
```
|
||||
|
||||
**3. Aplikacja żyje.** Pody muszą wstać i realnie czytać bazy:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout restart deploy/data && kubectl -n astrololo rollout status deploy/data
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo exec deploy/data -- ls /app/data_files | head -3
|
||||
```
|
||||
|
||||
**4. Reszta homelabu nietknięta** — conjurer (zapis!) i pozostałe udziały:
|
||||
|
||||
```bash
|
||||
kubectl -n conjurer get pods
|
||||
```
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.query | python3 -c "import sys,json;[print(s.get('path') or s.get('paths'), '| hosts:', s.get('hosts'), '| ro:', s.get('ro')) for s in json.load(sys.stdin)]"
|
||||
```
|
||||
|
||||
Oczekiwane: **tylko** astrololo ma zawężone `hosts` i `ro: true`; reszta bez zmian.
|
||||
|
||||
## Faza 5 — wycofanie (gdyby coś padło)
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.update <ID> '{"hosts": [], "ro": false}'
|
||||
```
|
||||
|
||||
```bash
|
||||
midclt call service.reload nfs
|
||||
```
|
||||
|
||||
To przywraca poprzedni stan (pełna kopia w `/root/astrololo-nfs-share.backup.json`).
|
||||
|
||||
---
|
||||
|
||||
## Pułapki, o których warto wiedzieć
|
||||
|
||||
**Hookscript Proxmoxa.** Na Proxmoxie działa `wait-truenas.sh`, który przed startem
|
||||
VM czeka w pętli na `showmount -e 192.168.1.34`. Zawężamy tylko udział astrololo,
|
||||
więc `showmount` nadal zwróci pozostałe eksporty i pętla przejdzie. Mimo to sprawdź
|
||||
po zmianie:
|
||||
|
||||
```bash
|
||||
ssh root@pve2 'showmount -e 192.168.1.34'
|
||||
```
|
||||
|
||||
**Aktualizacja baz przestanie działać przez NFS.** Po `ro: true` nikt nie wgra
|
||||
nowych plików baz przez ten udział — również Ty. Do wgrywania użyj GUI TrueNAS,
|
||||
SMB albo SSH bezpośrednio na NAS-ie. To celowe: udział ma być drogą tylko do
|
||||
czytania przez aplikację.
|
||||
|
||||
**`hosts` przyjmuje adresy IP, nie nazwy** — świadomie, żeby dostęp nie zależał od
|
||||
DNS-u (AdGuard na `.57`). Gdyby doszedł czwarty węzeł k3s, trzeba dopisać jego IP,
|
||||
inaczej pod `data` na nim nie wstanie.
|
||||
|
||||
**To nie jest uwierzytelnianie.** Lista IP zatrzymuje przypadkowy i oportunistyczny
|
||||
dostęp, ale adres da się podszyć w tej samej sieci. Docelowo (poza zakresem tego
|
||||
kroku): NFSv4 + Kerberos albo przeniesienie plików na wolumen nieosiągalny poza
|
||||
klastrem — tak mówi samo wymaganie DAN-25.
|
||||
|
||||
## Po wykonaniu
|
||||
|
||||
Zaktualizuj status DAN-25 w `docs/astrololo_wymagania.xlsx` na **Zrobione** (albo
|
||||
**W trakcie**, jeśli zostawiasz Kerberosa jako etap docelowy).
|
||||
@@ -0,0 +1,65 @@
|
||||
# Konta imienne i dziennik audytowy (PRE-17)
|
||||
|
||||
Zamiast jednego wspólnego hasła: **konta imienne**, bo przy bazach o realnej
|
||||
wartości handlowej trzeba wiedzieć **kto** sięgał do treści — i móc odciąć jedną
|
||||
osobę bez zmiany hasła całej reszcie.
|
||||
|
||||
## Zakładanie konta
|
||||
|
||||
Hasło podajesz interaktywnie (nie trafia do historii powłoki ani do listy procesów);
|
||||
na wyjściu dostajesz **hash**, nie hasło:
|
||||
|
||||
```bash
|
||||
cd services/presentation && python scripts/make_user.py alicja
|
||||
```
|
||||
|
||||
Wynik wklejasz do `APP_USERS` (wpisy po przecinku):
|
||||
|
||||
```
|
||||
APP_USERS='alicja:scrypt$…,bartek:scrypt$…'
|
||||
```
|
||||
|
||||
Hash liczy `scrypt` ze stdlib — **bez nowych zależności**. Sekret ustawiasz jak
|
||||
resztę (`kubectl create secret …`, `secretKeyRef`), nigdy w repo GitOps.
|
||||
|
||||
## Odebranie dostępu jednej osobie
|
||||
|
||||
Usuń jej wpis z `APP_USERS` i zrestartuj `presentation`. **Pozostali nie zmieniają
|
||||
haseł** — to była główna bolączka wspólnego hasła.
|
||||
|
||||
## Uwaga: wspólne hasło przestaje działać
|
||||
|
||||
Gdy `APP_USERS` jest ustawione, stare `APP_PASSWORD` **nie działa** (aplikacja
|
||||
zgłasza to ostrzeżeniem przy starcie). Celowo: działające obok kont wspólne hasło
|
||||
byłoby tylnym wejściem bez śladu w dzienniku, czyli dokładnie problemem, który to
|
||||
wymaganie zamyka. Po migracji usuń `APP_PASSWORD` z konfiguracji.
|
||||
|
||||
Zgodność wstecz: dopóki `APP_USERS` **nie** jest ustawione, `APP_USER`/`APP_PASSWORD`
|
||||
działa jak dotąd — aktualizacja nie wywraca istniejącego wdrożenia.
|
||||
|
||||
## Dziennik audytowy
|
||||
|
||||
Każde żądanie do chronionej ścieżki zostawia wpis na stdout (w k8s zbierany
|
||||
standardowo):
|
||||
|
||||
```
|
||||
2026-08-03 21:45:44 INFO AUDYT user=alicja ip=10.1.2.3 method=POST path=/interpret status=200 records=428 ms=1530
|
||||
```
|
||||
|
||||
| Pole | Znaczenie |
|
||||
|---|---|
|
||||
| `user` | kto (`-` przy nieudanym logowaniu — nie podpowiadamy, które konto istnieje) |
|
||||
| `path`, `method`, `status` | co robił i z jakim skutkiem |
|
||||
| `records` | **ile rekordów baz** oddaliśmy (`-` gdy żądanie nie dotyka baz) |
|
||||
| `ms` | czas obsługi |
|
||||
|
||||
`records` jest tu najważniejsze: pojedyncze zapytanie wygląda niewinnie, ale suma
|
||||
pokazuje **powolne wypompowywanie bazy** przez osobę uprawnioną — czego żadne
|
||||
uwierzytelnienie nie wykryje. Liczone m.in. dla wyszukiwarki sygnifikatorów,
|
||||
raportu interpretacji i **eksportu do Excela** (ten wynosi najwięcej naraz).
|
||||
|
||||
**W dzienniku nie ma treści** — ani rekordów, ani promptów, ani danych
|
||||
urodzeniowych. Logi byłyby kolejnym nośnikiem wycieku; do wykrycia nadużycia
|
||||
wystarczą metadane i liczby.
|
||||
|
||||
Poziom sterujesz przez `AUDIT_LEVEL` (domyślnie `INFO`).
|
||||
@@ -0,0 +1,171 @@
|
||||
# LOG-33 — sekrety w spoczynku i procedura rotacji
|
||||
|
||||
Sekrety (`APP_PASSWORD`/`APP_USERS`, `INTERNAL_TOKEN`, klucze łącz AES, klucze API
|
||||
do dostawców LLM) trafiają do obiektów Secret w Kubernetesie, gdzie domyślnie są
|
||||
**tylko zakodowane base64** — jawne dla każdego, kto przeczyta magazyn stanu k3s
|
||||
albo ma prawo odczytu sekretów w namespace.
|
||||
|
||||
Ten dokument opisuje: **macierz rotacji** (co restartować przy zmianie czego),
|
||||
**procedury rotacji per sekret** i **kroki hartowania**, które wymagają dostępu do
|
||||
węzła.
|
||||
|
||||
---
|
||||
|
||||
## Macierz zależności — kto używa którego sekretu
|
||||
|
||||
Wyliczona z żywych deploymentów, nie z założeń:
|
||||
|
||||
| Sekret / klucz | Usługi, które go czytają | Restart obejmuje |
|
||||
|---|---|---|
|
||||
| `astrololo-auth` / **`INTERNAL_TOKEN`** | data, logic, presentation, **render** | **wszystkie cztery, równocześnie** |
|
||||
| `astrololo-auth` / `APP_PASSWORD`, `APP_USERS` | presentation | tylko presentation |
|
||||
| `astrololo-link` / `LINK_KEY_LOGIC_DATA` | logic, data | **para**: logic + data |
|
||||
| `astrololo-link` / `LINK_KEY_PRESENTATION_LOGIC` | presentation, logic | **para**: presentation + logic |
|
||||
| `astrololo-link` / `LINK_KEY_PRESENTATION_RENDER` | presentation, render | **para**: presentation + render |
|
||||
| `astrololo-llm` / `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | logic | tylko logic |
|
||||
|
||||
> **UWAGA — częsty błąd.** Wcześniejsza wersja tego wymagania mówiła o „wszystkich
|
||||
> **trzech** usługach" przy `INTERNAL_TOKEN`. To już nieprawda: `render` (PRE-24)
|
||||
> również go używa. Restart trzech zostawi render ze starym tokenem i **usługa po
|
||||
> cichu przestanie się dogadywać** — dokładnie ta awaria, przed którą wymaganie
|
||||
> ostrzega.
|
||||
|
||||
Klucze łącz są **parami** — rotacja jednego wymaga restartu tylko dwóch usług, nie
|
||||
całej czwórki. W trakcie wymiany para chwilowo się nie dogaduje (klucze muszą być
|
||||
zgodne po obu stronach łącza), dlatego restart obu naraz.
|
||||
|
||||
---
|
||||
|
||||
## Procedury rotacji
|
||||
|
||||
### A. Klucze LLM (najbezpieczniejsze do przećwiczenia)
|
||||
|
||||
Dotykają wyłącznie logiki, a awaria jest widoczna od razu i nieszkodliwa
|
||||
(niedostępna chmura, model lokalny działa dalej). **Zacznij ćwiczenie od tego.**
|
||||
|
||||
```bash
|
||||
read -rs -p "Nowy ANTHROPIC_API_KEY: " NEW; echo
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-llm \
|
||||
--from-literal=OPENAI_API_KEY="$(kubectl -n astrololo get secret astrololo-llm -o jsonpath='{.data.OPENAI_API_KEY}' | base64 -d)" \
|
||||
--from-literal=ANTHROPIC_API_KEY="$NEW" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
```bash
|
||||
unset NEW && kubectl -n astrololo rollout restart deploy/logic && kubectl -n astrololo rollout status deploy/logic
|
||||
```
|
||||
|
||||
### B. Klucz łącza (para usług)
|
||||
|
||||
Przykład dla `LINK_KEY_PRESENTATION_RENDER`. Pozostałe klucze zachowujemy bez zmian,
|
||||
odczytując je z istniejącego sekretu — inaczej skasowalibyśmy pozostałe łącza.
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-link \
|
||||
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
|
||||
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
|
||||
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout restart deploy/presentation deploy/render
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
|
||||
```
|
||||
|
||||
### C. `INTERNAL_TOKEN` (wszystkie cztery naraz)
|
||||
|
||||
Najbardziej wrażliwa rotacja: w trakcie usługi z różnymi tokenami **odrzucają się
|
||||
nawzajem**, więc restart musi objąć całą czwórkę.
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-auth \
|
||||
--from-literal=APP_PASSWORD="$(kubectl -n astrololo get secret astrololo-auth -o jsonpath='{.data.APP_PASSWORD}' | base64 -d)" \
|
||||
--from-literal=INTERNAL_TOKEN="$(openssl rand -hex 32)" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout restart deploy/data deploy/logic deploy/presentation deploy/render
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout status deploy/data && kubectl -n astrololo rollout status deploy/logic && kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
|
||||
```
|
||||
|
||||
> Jeśli używasz kont imiennych (PRE-17), zamiast `APP_PASSWORD` zachowaj `APP_USERS`
|
||||
> — patrz [`konta-i-audyt.md`](konta-i-audyt.md).
|
||||
|
||||
### Weryfikacja po KAŻDEJ rotacji
|
||||
|
||||
Sam `Running` nie wystarczy — pody wstaną nawet, gdy warstwy się nie dogadują.
|
||||
Trzeba sprawdzić **realny przelot przez wszystkie łącza**:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo get pods
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "aplikacja: %{http_code}\n" -u "<login>:<hasło>" https://astrololo.czernobog.pl/
|
||||
```
|
||||
|
||||
Policz horoskop w przeglądarce (dotyka presentation→logic→data) i wygeneruj PDF
|
||||
(dotyka presentation→render). Dopiero to potwierdza, że wszystkie cztery klucze
|
||||
i token są spójne.
|
||||
|
||||
---
|
||||
|
||||
## Hartowanie — kroki wymagające dostępu do węzła
|
||||
|
||||
### 1. Szyfrowanie sekretów w spoczynku (najważniejsze)
|
||||
|
||||
k3s z jednym serwerem trzyma stan w **SQLite**, nie w etcd, więc „szyfrowanie etcd"
|
||||
sprowadza się do wbudowanej funkcji k3s. Kroki są w sekcji „Co musisz zrobić sam"
|
||||
poniżej. Efekt: kopia pliku stanu albo snapshot VM przestaje być wyciekiem haseł
|
||||
i kluczy API.
|
||||
|
||||
**Granica:** klucz szyfrujący leży na tym samym serwerze. Chroni przed kradzieżą
|
||||
pliku/snapshotu — nie przed kimś, kto ma roota na węźle.
|
||||
|
||||
### 2. Ograniczenie tokenów kont serwisowych ✅
|
||||
|
||||
Zrobione: `automountServiceAccountToken: false` we wszystkich czterech usługach
|
||||
(deploy #13). Żadna nie rozmawia z API Kubernetesa — sekrety wstrzykuje kubelet,
|
||||
nie pod — więc token był zbędny, a stanowił gotowy punkt wyjścia do klastra.
|
||||
|
||||
### 3. RBAC
|
||||
|
||||
Stan sprawdzony: **zero RoleBindings** w `astrololo`, `cluster-admin` tylko dla
|
||||
`system:masters` i dwóch kont Helma w `kube-system`. Nie ma rozdanych nadmiarowych
|
||||
uprawnień do cofania.
|
||||
|
||||
Realna ekspozycja to **kubeconfig admina**. Sensowny krok: osobny, ograniczony
|
||||
kubeconfig do codziennej pracy, a admin tylko wtedy, gdy naprawdę potrzebny.
|
||||
|
||||
### 4. Sealed Secrets / SOPS — świadomie ODŁOŻONE
|
||||
|
||||
Dziś sekrety tworzone są ręcznie i **nie ma ich w repo GitOps** — czyli zasada
|
||||
„nie wpisywać sekretów do repozytorium" **jest już spełniona**. Kosztem jest
|
||||
odtwarzalność: po utracie klastra nikt nie wie, co tam było.
|
||||
|
||||
Sealed Secrets pozwoliłoby trzymać je w gicie w postaci zaszyfrowanej, ale to
|
||||
zmiana filozofii i **nowy pojedynczy punkt awarii**: utrata klucza kontrolera =
|
||||
utrata wszystkich sekretów. Rekomendacja: dopiero po punkcie 1, i tylko jeśli
|
||||
zależy Ci na odtwarzalności klastra z gita.
|
||||
|
||||
---
|
||||
|
||||
## Czego NIE robimy
|
||||
|
||||
- **Nie wpisujemy sekretów do logów.** Dziennik audytowy (PRE-17) niesie wyłącznie
|
||||
metadane i liczby.
|
||||
- **Nie wpisujemy sekretów do repo GitOps.** Manifesty odwołują się do sekretów
|
||||
przez `secretKeyRef` i celowo nie zawierają wartości.
|
||||
- **Nie zostawiamy haseł w historii powłoki** — stąd `read -rs` i odczyt istniejących
|
||||
wartości przez `kubectl … | base64 -d` zamiast wpisywania ich ponownie.
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Przegląd baz na udziale i ich globalne włączanie/wyłączanie (DAN-15 / PRE-09).
|
||||
|
||||
Wymaganie przedefiniowane pod model serwerowy: pliki baz leżą na stałym NFS, więc
|
||||
nie wybiera się folderu — potrzeba za to WIDZIEĆ, jakie bazy są dostępne (nazwa +
|
||||
metaopis) i móc globalnie zdecydować, które biorą udział w interpretacji.
|
||||
|
||||
Stan przełączników trzymamy DEKLARATYWNIE w zmiennej `DISABLED_BASES`, a nie w
|
||||
pliku, bo warstwa danych nie ma gdzie trwale zapisywać: udział z bazami jest
|
||||
montowany read-only, a katalog cache to `emptyDir` (ginie przy restarcie poda).
|
||||
Zapis do pliku po cichu wracałby więc do stanu sprzed restartu — a ciche
|
||||
przywrócenie wyłączonej bazy jest gorsze niż konieczność edycji konfiguracji.
|
||||
|
||||
Dopasowanie jest tolerancyjne: wpis pasuje po nazwie pliku ALBO po ścieżce
|
||||
względnej — żeby dało się wyłączyć zarówno „stara_baza.xlsx", jak i
|
||||
„archiwum/stara_baza.xlsx".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def disabled_entries() -> list[str]:
|
||||
return [e.strip() for e in os.getenv("DISABLED_BASES", "").split(",") if e.strip()]
|
||||
|
||||
|
||||
def _relative(path: Path, root: Path) -> str:
|
||||
try:
|
||||
return str(path.relative_to(root))
|
||||
except ValueError:
|
||||
return path.name
|
||||
|
||||
|
||||
def is_enabled(path: str | Path, root: str | Path, entries: list[str] | None = None) -> bool:
|
||||
"""Czy baza bierze udział w wyszukiwaniu interpretacji."""
|
||||
entries = disabled_entries() if entries is None else entries
|
||||
if not entries:
|
||||
return True
|
||||
p = Path(path)
|
||||
rel = _relative(p, Path(root))
|
||||
return not any(e == p.name or e == rel for e in entries)
|
||||
|
||||
|
||||
def list_bases(root: str | Path, paths: list[str]) -> list[dict]:
|
||||
"""Bazy dostępne na udziale + metaopis. Celowo TANI opis (dane z systemu
|
||||
plików): przy setkach plików liczenie rekordów oznaczałoby wczytanie każdego."""
|
||||
root = Path(root)
|
||||
entries = disabled_entries()
|
||||
out: list[dict] = []
|
||||
for raw in paths:
|
||||
p = Path(raw)
|
||||
try:
|
||||
st = p.stat()
|
||||
size_mb, modified = round(st.st_size / (1024 * 1024), 2), st.st_mtime
|
||||
except OSError:
|
||||
size_mb, modified = None, None
|
||||
out.append({
|
||||
"name": p.name,
|
||||
"path": _relative(p, root),
|
||||
"size_mb": size_mb,
|
||||
"modified": (datetime.fromtimestamp(modified, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
if modified else None),
|
||||
"enabled": is_enabled(p, root, entries),
|
||||
})
|
||||
return out
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Rekordy-pułapki (canary) — wykrywanie wycieku baz (DAN-26).
|
||||
|
||||
Zabezpieczenie DETEKCYJNE, nie prewencyjne. Kilka unikalnych, wiarygodnie
|
||||
wyglądających rekordów wplecionych w bazy: nie zmieniają interpretacji (są
|
||||
ODSIEWANE z wyników, więc nie trafiają ani do użytkownika, ani do promptu LLM —
|
||||
wymóg LOG-30), ale jeśli kiedyś pojawią się w cudzej kopii, są dowodem pochodzenia.
|
||||
|
||||
Pułapkę rozpoznajemy po MARKERZE: unikalny ciąg, który nie występuje w realnych
|
||||
danych (wpleciony np. w pole znaczące). Markery i WARIANT tego wdrożenia biorą się
|
||||
z konfiguracji (ENV `CANARY_MARKERS`, `CANARY_VARIANT`) — każda kopia może dostać
|
||||
swój zestaw. Który wariant trafił do którego wdrożenia trzyma rejestr po stronie
|
||||
ops (osobno, poza kodem — patrz docs/canary-registry.md).
|
||||
|
||||
Dwa sygnały:
|
||||
* ODSIEWANIE — pułapka w wynikach znika, zanim opuści warstwę danych (log info);
|
||||
* TRIPWIRE — zapytanie CELUJE wprost w marker (ktoś enumeruje bazę, a nie liczy
|
||||
realny horoskop) → log warning, bo to podejrzane zachowanie.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
log = logging.getLogger("astrololo.data.canary")
|
||||
|
||||
|
||||
def _load_markers() -> list[str]:
|
||||
return [m.strip() for m in os.getenv("CANARY_MARKERS", "").split(",") if m.strip()]
|
||||
|
||||
|
||||
MARKERS = _load_markers()
|
||||
VARIANT = os.getenv("CANARY_VARIANT", "")
|
||||
|
||||
|
||||
def _row_is_canary(row: dict, markers: list[str]) -> bool:
|
||||
"""Czy KTÓRAKOLWIEK tekstowa wartość wiersza zawiera marker pułapki."""
|
||||
for v in row.values():
|
||||
if isinstance(v, str):
|
||||
for m in markers:
|
||||
if m in v:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def screen(
|
||||
rows: list[dict], query_value: str = "",
|
||||
markers: list[str] | None = None, variant: str | None = None,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""Zwraca (widoczne_wiersze, raport).
|
||||
|
||||
Odsiewa pułapki z wyników (nie opuszczą warstwy danych). Gdy zapytanie celuje
|
||||
wprost w marker — podnosi TRIPWIRE (możliwa enumeracja bazy). Bez skonfigurowanych
|
||||
markerów: przezroczyste (`active=False`), zero kosztu dla normalnego ruchu.
|
||||
"""
|
||||
markers = MARKERS if markers is None else markers
|
||||
variant = VARIANT if variant is None else variant
|
||||
if not markers:
|
||||
return rows, {"active": False, "removed": 0, "tripwire": False}
|
||||
|
||||
visible = [r for r in rows if not _row_is_canary(r, markers)]
|
||||
removed = len(rows) - len(visible)
|
||||
tripwire = any(m in (query_value or "") for m in markers)
|
||||
|
||||
if tripwire:
|
||||
log.warning(
|
||||
"CANARY TRIPWIRE: zapytanie celuje wprost w rekord-pułapkę (wariant %s) "
|
||||
"— możliwa enumeracja bazy", variant or "?")
|
||||
elif removed:
|
||||
log.info("Odsiano %d rekord(ów)-pułapek z wyników (wariant %s)", removed, variant or "?")
|
||||
return visible, {"active": True, "removed": removed, "tripwire": tripwire}
|
||||
@@ -11,7 +11,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app import link_crypto, security
|
||||
from app import canary, link_crypto, security
|
||||
from app.config import settings
|
||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||
from app.providers.factory import build_provider
|
||||
@@ -34,7 +34,24 @@ link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych")
|
||||
|
||||
@app.post("/search", response_model=SearchResult)
|
||||
def search(query: SearchQuery) -> SearchResult:
|
||||
return provider.search(query)
|
||||
result = provider.search(query)
|
||||
# Rekordy-pułapki (DAN-26) odsiewamy TU, na wyjściu z warstwy danych — dzięki
|
||||
# temu nie dotrą ani wyżej, ani do promptu LLM (LOG-30), niezależnie od dostawcy.
|
||||
visible, report = canary.screen(result.rows, query.value)
|
||||
if report["active"]:
|
||||
result.rows = visible
|
||||
result.total = len(visible)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/bases")
|
||||
def bases() -> dict:
|
||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||
|
||||
Same METADANE — nazwy plików, rozmiar, data. Żadnej treści baz, więc podgląd
|
||||
listy nie jest kolejną drogą do ich wyniesienia."""
|
||||
items = provider.list_bases()
|
||||
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthInfo)
|
||||
|
||||
@@ -25,3 +25,10 @@ class DataProvider(ABC):
|
||||
def warmup(self) -> None:
|
||||
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
|
||||
return None
|
||||
|
||||
def list_bases(self) -> list[dict]:
|
||||
"""Bazy widoczne dla dostawcy + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||
|
||||
Opcjonalne: dostawca SQL nie operuje na plikach, więc domyślnie pusto —
|
||||
UI pokaże wtedy, że nie ma czego przełączać, zamiast się wywrócić."""
|
||||
return []
|
||||
|
||||
@@ -91,10 +91,31 @@ class ExcelDataProvider(DataProvider):
|
||||
base = Path(self.s.excel_dir)
|
||||
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
|
||||
|
||||
def _enabled_files(self, paths: list[str]) -> list[str]:
|
||||
"""Odsiewa bazy WYŁĄCZONE globalnie (DAN-15) — nie biorą udziału
|
||||
w interpretacji, choć fizycznie leżą na udziale."""
|
||||
from app import bases
|
||||
|
||||
entries = bases.disabled_entries()
|
||||
if not entries:
|
||||
return paths
|
||||
return [p for p in paths if bases.is_enabled(p, self.s.excel_dir, entries)]
|
||||
|
||||
def list_bases(self) -> list[dict]:
|
||||
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
|
||||
from app import bases
|
||||
|
||||
return bases.list_bases(self.s.excel_dir, self._excel_files())
|
||||
|
||||
# ---- publiczne API ----
|
||||
def search(self, query: SearchQuery) -> SearchResult:
|
||||
t0 = time.perf_counter()
|
||||
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
|
||||
# Lista wyłączonych baz wchodzi do klucza cache: bez tego zmiana ustawień
|
||||
# oddawałaby wynik sprzed zmiany, czyli treść bazy uznanej za wyłączoną.
|
||||
from app import bases
|
||||
|
||||
disabled = ",".join(bases.disabled_entries())
|
||||
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}|{disabled}"
|
||||
|
||||
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
|
||||
if hit is not None:
|
||||
@@ -105,6 +126,9 @@ class ExcelDataProvider(DataProvider):
|
||||
if not candidates:
|
||||
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
|
||||
candidates = [(p, "0") for p in self._excel_files()]
|
||||
# bazy wyłączone globalnie (DAN-15) pomijamy niezależnie od źródła kandydatów
|
||||
allowed = set(self._enabled_files([p for p, _ in candidates]))
|
||||
candidates = [(p, s) for p, s in candidates if p in allowed]
|
||||
|
||||
rows: list[dict] = []
|
||||
for path, _sheet in candidates:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Przegląd baz na udziale i ich globalne wyłączanie (DAN-15 / PRE-09)."""
|
||||
import pytest
|
||||
|
||||
from app import bases
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
monkeypatch.delenv("DISABLED_BASES", raising=False)
|
||||
|
||||
|
||||
ROOT = "/dane"
|
||||
PATHS = [f"{ROOT}/main_base.xlsx", f"{ROOT}/archiwum/stara.xlsx"]
|
||||
|
||||
|
||||
def test_everything_enabled_by_default():
|
||||
for b in bases.list_bases(ROOT, PATHS):
|
||||
assert b["enabled"] is True
|
||||
|
||||
|
||||
def test_disable_by_file_name(monkeypatch):
|
||||
monkeypatch.setenv("DISABLED_BASES", "stara.xlsx")
|
||||
state = {b["name"]: b["enabled"] for b in bases.list_bases(ROOT, PATHS)}
|
||||
assert state == {"main_base.xlsx": True, "stara.xlsx": False}
|
||||
|
||||
|
||||
def test_disable_by_relative_path(monkeypatch):
|
||||
"""Wpis może wskazywać ścieżkę względną, nie tylko samą nazwę."""
|
||||
monkeypatch.setenv("DISABLED_BASES", "archiwum/stara.xlsx")
|
||||
assert bases.is_enabled(f"{ROOT}/archiwum/stara.xlsx", ROOT) is False
|
||||
assert bases.is_enabled(f"{ROOT}/main_base.xlsx", ROOT) is True
|
||||
|
||||
|
||||
def test_entries_are_trimmed_and_multiple(monkeypatch):
|
||||
monkeypatch.setenv("DISABLED_BASES", " stara.xlsx , main_base.xlsx ")
|
||||
assert [b["enabled"] for b in bases.list_bases(ROOT, PATHS)] == [False, False]
|
||||
|
||||
|
||||
def test_listing_carries_metadata_not_content(tmp_path):
|
||||
"""Metaopis: nazwa, ścieżka, rozmiar, data — ŻADNEJ treści bazy."""
|
||||
f = tmp_path / "baza.xlsx"
|
||||
f.write_bytes(b"x" * 2048)
|
||||
item = bases.list_bases(tmp_path, [str(f)])[0]
|
||||
assert set(item) == {"name", "path", "size_mb", "modified", "enabled"}
|
||||
assert item["name"] == "baza.xlsx" and item["size_mb"] is not None and item["modified"]
|
||||
|
||||
|
||||
def test_missing_file_does_not_crash_the_listing():
|
||||
item = bases.list_bases(ROOT, [f"{ROOT}/nie-ma.xlsx"])[0]
|
||||
assert item["size_mb"] is None and item["modified"] is None
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Rekordy-pułapki (canary) — DAN-26.
|
||||
|
||||
Testujemy sam MECHANIZM na syntetycznych pułapkach: prawdziwe markery i injekcja
|
||||
do baz przychodzą od właściciela produktu. Regresja byłaby CICHA i podwójnie zła:
|
||||
albo pułapka wycieka do interpretacji/LLM (zdradza się i psuje wynik), albo
|
||||
przestaje odsiewać i nie wiadomo o tym.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
from app import canary
|
||||
|
||||
MAIN = (pathlib.Path(__file__).resolve().parents[1] / "app" / "main.py").read_text(encoding="utf-8")
|
||||
|
||||
MARK = "ASTROLOLO-CANARY-7f3a9" # unikalny — nie wystąpi w realnych danych
|
||||
ROWS = [
|
||||
{"significator": "Ma Ari", "effect": "odważny, impulsywny"},
|
||||
{"significator": "Ve Tau " + MARK, "effect": "pułapka — nie dotknie interpretacji"},
|
||||
{"significator": "Su Leo", "effect": "dumny, twórczy"},
|
||||
]
|
||||
|
||||
|
||||
def test_no_markers_is_transparent():
|
||||
"""Bez skonfigurowanych markerów — zero ingerencji, zero kosztu."""
|
||||
out, rep = canary.screen(ROWS, "cokolwiek", markers=[])
|
||||
assert out == ROWS and rep["active"] is False
|
||||
|
||||
|
||||
def test_canary_row_is_fenced_from_results():
|
||||
"""Pułapka znika z wyników — nie opuści warstwy danych (a więc i promptu LLM)."""
|
||||
out, rep = canary.screen(ROWS, "Ve Tau", markers=[MARK])
|
||||
assert rep["active"] and rep["removed"] == 1
|
||||
assert all(MARK not in str(r) for r in out) # nigdzie nie ma markera
|
||||
assert len(out) == 2 and {"significator": "Su Leo", "effect": "dumny, twórczy"} in out
|
||||
|
||||
|
||||
def test_real_results_pass_through_untouched():
|
||||
out, _ = canary.screen(ROWS, "Ari", markers=[MARK])
|
||||
assert {"significator": "Ma Ari", "effect": "odważny, impulsywny"} in out
|
||||
|
||||
|
||||
def test_tripwire_when_query_targets_a_marker():
|
||||
"""Zapytanie CELUJĄCE w marker = ktoś enumeruje bazę, nie liczy horoskopu."""
|
||||
_, rep = canary.screen(ROWS, "Ve Tau " + MARK, markers=[MARK])
|
||||
assert rep["tripwire"] is True
|
||||
|
||||
|
||||
def test_normal_query_does_not_trip():
|
||||
_, rep = canary.screen(ROWS, "Ma Ari", markers=[MARK])
|
||||
assert rep["tripwire"] is False
|
||||
|
||||
|
||||
def test_marker_matched_in_any_string_field():
|
||||
rows = [{"significator": "X", "effect": "opis " + MARK, "extra": 5}]
|
||||
out, rep = canary.screen(rows, "X", markers=[MARK])
|
||||
assert out == [] and rep["removed"] == 1 # marker w polu 'effect' też łapiemy
|
||||
|
||||
|
||||
def test_endpoint_fences_before_returning():
|
||||
"""/search odsiewa pułapki na wyjściu z warstwy danych (niezależnie od dostawcy)."""
|
||||
assert "canary.screen(result.rows, query.value)" in MAIN
|
||||
assert "result.rows = visible" in MAIN
|
||||
@@ -45,6 +45,12 @@ 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.
|
||||
|
||||
@@ -15,6 +15,16 @@ MAJOR = {
|
||||
"trine": 120.0,
|
||||
"opposition": 180.0,
|
||||
}
|
||||
# Aspekty POBOCZNE (opcjonalne, PRE-06). Tylko te trzy — bo mają już glify i barwy
|
||||
# w warstwie prezentacji (chartwheel) oraz w engine/glyphs.py, więc dokładają się
|
||||
# bez ruszania czegokolwiek poza silnikiem. Bazy interpretacyjne zwykle ich nie
|
||||
# opisują (brak tokenu w DB_TOKEN), więc trafiają na kosmogram i do tabeli, ale
|
||||
# NIE generują faset sygnifikatorów — most po cichu je pomija (significators.py).
|
||||
MINOR = {
|
||||
"semisextile": 30.0,
|
||||
"semisquare": 45.0,
|
||||
"quincunx": 150.0,
|
||||
}
|
||||
DB_TOKEN = {
|
||||
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
|
||||
"trine": "[tri", "opposition": "[opp",
|
||||
@@ -61,16 +71,54 @@ def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: f
|
||||
return dev_next < dev_now
|
||||
|
||||
|
||||
def _first_aspect(sep: float, allowed: float, checks: dict) -> tuple[str, float] | None:
|
||||
"""Pierwszy aspekt z `checks`, w którego orbie mieści się separacja `sep`."""
|
||||
for asp, angle in checks.items():
|
||||
dev = abs(sep - angle)
|
||||
if dev <= allowed:
|
||||
return asp, round(dev, 2)
|
||||
return None
|
||||
|
||||
|
||||
def find_cross_aspects(
|
||||
a_positions: list[dict], b_positions: list[dict],
|
||||
orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS, minor: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Aspekty MIĘDZY dwoma horoskopami (synastria, PRE-04): każdy obiekt z A vs
|
||||
każdy obiekt z B (`obj1` = osoba A, `obj2` = osoba B). Statyczne — dwa natale,
|
||||
brak wspólnego czasu, więc bez applying/separating. RIGID_PAIRS nie dotyczy
|
||||
(NN osoby A vs SN osoby B to realny aspekt, nie artefakt definicji)."""
|
||||
checks = {**MAJOR, **MINOR} if minor else MAJOR
|
||||
out: list[dict] = []
|
||||
for a in a_positions:
|
||||
la = a.get("decimal")
|
||||
if la is None:
|
||||
continue
|
||||
for b in b_positions:
|
||||
lb = b.get("decimal")
|
||||
if lb is None:
|
||||
continue
|
||||
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
|
||||
m = _first_aspect(separation(float(la), float(lb)), allowed, checks)
|
||||
if m:
|
||||
out.append({"obj1": a["name"], "obj2": b["name"],
|
||||
"aspect": m[0], "orb": m[1], "allowed": round(allowed, 2)})
|
||||
return out
|
||||
|
||||
|
||||
def find_aspects(
|
||||
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS,
|
||||
minor: bool = False,
|
||||
) -> list[dict]:
|
||||
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
|
||||
|
||||
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
|
||||
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
|
||||
Zwraca listę aspektów; gdy znane są prędkości, każdy aspekt ma applying (bool)
|
||||
i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny). Orb i bonus dla świateł są
|
||||
KONFIGUROWALNE (PRE-06); `minor=True` dokłada aspekty poboczne (30/45/150°).
|
||||
|
||||
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
|
||||
"""
|
||||
checks = {**MAJOR, **MINOR} if minor else MAJOR
|
||||
out: list[dict] = []
|
||||
n = len(positions)
|
||||
for i in range(n):
|
||||
@@ -83,7 +131,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 MAJOR.items():
|
||||
for asp, angle in checks.items():
|
||||
dev = abs(sep - angle)
|
||||
if dev <= allowed:
|
||||
applying = _is_applying(
|
||||
|
||||
@@ -36,7 +36,9 @@ 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) -> dict:
|
||||
house_systems: list[str] | None = None,
|
||||
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0,
|
||||
aspect_minor: bool = False) -> dict:
|
||||
from app.engine import glyphs as GL
|
||||
from app.engine.aspects import find_aspects
|
||||
from app.engine.houses import mean_obliquity
|
||||
@@ -50,7 +52,9 @@ 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(result["positions"]) # aspekty (LOG-06)
|
||||
result["aspects"] = find_aspects( # aspekty (LOG-06), konfigurowalne (PRE-06)
|
||||
result["positions"], orb=aspect_orb,
|
||||
luminary_bonus=aspect_luminary_bonus, minor=aspect_minor)
|
||||
|
||||
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
|
||||
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
|
||||
@@ -93,7 +97,11 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||
mc = H.compute_mc(ramc, eps)
|
||||
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
|
||||
# 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
|
||||
|
||||
def _cusps_out(cl: list[float]) -> list[dict]:
|
||||
"""Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif."""
|
||||
@@ -105,7 +113,11 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
for i, c in enumerate(cl)
|
||||
]
|
||||
|
||||
result["house_system"] = system
|
||||
result["house_system"] = primary.system # FAKTYCZNIE użyty
|
||||
result["house_system_requested"] = primary.requested
|
||||
# Lista ostrzeżeń dla całego horoskopu — prezentacja, raport i PDF czytają
|
||||
# jedno miejsce, więc żaden z nich nie może „zapomnieć" o fallbacku.
|
||||
result["house_warnings"] = [primary.notice] if primary.notice else []
|
||||
result["angles"] = {
|
||||
"Asc": _fmt("Asc", asc, off),
|
||||
"MC": _fmt("MC", mc, off),
|
||||
@@ -128,10 +140,15 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
if len(ordered) > 1:
|
||||
result["house_systems"] = []
|
||||
for s in ordered:
|
||||
cl = cusp_list if s == system else H.cusps(asc, mc, s)
|
||||
result["house_systems"].append({"system": s, "cusps": _cusps_out(cl)})
|
||||
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, cl)
|
||||
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
|
||||
|
||||
@@ -8,13 +8,31 @@ silnik dostarcza tylko RAMC i ε.
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
||||
|
||||
WHOLE_SIGN = "whole_sign"
|
||||
EQUAL = "equal"
|
||||
EQUAL_MC = "equal_mc" # równe domy zakotwiczone na MC, nie na Asc
|
||||
WHOLE_SIGN_ARIES = "whole_sign_aries" # znaki jako domy, ale dom I to ZAWSZE Baran
|
||||
PORPHYRY = "porphyry"
|
||||
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
|
||||
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania
|
||||
# iteracyjnego i dochodzą osobno.
|
||||
VEHLOW = "vehlow"
|
||||
MORINUS = "morinus"
|
||||
REGIOMONTANUS = "regiomontanus"
|
||||
CAMPANUS = "campanus"
|
||||
ALCABITUS = "alcabitus"
|
||||
TOPOCENTRIC = "topocentric"
|
||||
PLACIDUS = "placidus"
|
||||
KOCH = "koch"
|
||||
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris
|
||||
# (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny:
|
||||
# powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi.
|
||||
SYSTEMS = (WHOLE_SIGN, WHOLE_SIGN_ARIES, EQUAL, EQUAL_MC, PORPHYRY, VEHLOW,
|
||||
MORINUS, REGIOMONTANUS, CAMPANUS, ALCABITUS, TOPOCENTRIC,
|
||||
PLACIDUS, KOCH)
|
||||
|
||||
|
||||
def mean_obliquity(tt_jd: float) -> float:
|
||||
@@ -31,12 +49,27 @@ def compute_mc(ramc_deg: float, eps_deg: float) -> float:
|
||||
|
||||
|
||||
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
|
||||
"""Ascendent — punkt ekliptyki wschodzący na horyzoncie.
|
||||
|
||||
KOREKTA GAŁĘZI (błąd wykryty przez porównanie z wyrocznią, tests/oracle):
|
||||
ekliptyka przecina horyzont w DWÓCH punktach — wschodzącym (Asc) i zachodzącym
|
||||
(Dsc). `atan2` wybiera jeden z nich, ale powyżej koła podbiegunowego potrafi
|
||||
wskazać ten NIEWŁAŚCIWY: dla szerokości 67°+ i szerokiego zakresu RAMC
|
||||
zwracaliśmy Descendent, czyli Ascendent przesunięty o 180°. Skutek nie był
|
||||
subtelny — planety lądowały w PRZECIWNYCH domach dla całej Skandynawii
|
||||
północnej (Tromsø, Rovaniemi, Murmańsk).
|
||||
|
||||
Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze w półkolu
|
||||
(0°, 180°) na wschód od MC. Reguła zweryfikowana na 46 800 przypadkach wobec
|
||||
Swiss Ephemeris — zero rozbieżności.
|
||||
"""
|
||||
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
||||
asc = math.atan2(
|
||||
asc = norm360(math.degrees(math.atan2(
|
||||
math.cos(r),
|
||||
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
||||
)
|
||||
return norm360(math.degrees(asc))
|
||||
)))
|
||||
mc = compute_mc(ramc_deg, eps_deg)
|
||||
return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else asc
|
||||
|
||||
|
||||
def _trisect(a: float, b: float) -> tuple[float, float]:
|
||||
@@ -45,10 +78,29 @@ def _trisect(a: float, b: float) -> tuple[float, float]:
|
||||
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
|
||||
|
||||
|
||||
# Poniżej tej odległości od granicy znaku traktujemy Ascendent jak leżący DOKŁADNIE
|
||||
# na niej. 1e-9° to 3,6 mikrosekundy łuku — o rzędy wielkości poniżej jakiejkolwiek
|
||||
# realnej dokładności danych urodzeniowych, więc nie zmienia to żadnego horoskopu.
|
||||
_SIGN_SNAP_DEG = 1e-9
|
||||
|
||||
|
||||
def _snap_to_sign_boundary(lon: float) -> float:
|
||||
"""Przyciąga długość do granicy znaku, gdy jest od niej o włos.
|
||||
|
||||
Whole sign jest NIECIĄGŁY na granicach znaków: różnica 10⁻¹¹° w Ascendencie
|
||||
przerzuca cały dom I o 30°. Bez tego przyciągania ten sam horoskop policzony
|
||||
na innej maszynie mógłby dać inny wynik (wykryte przez porównanie z wyrocznią:
|
||||
nasz Asc = 359,999999999976, swissepha = 1e-10 — ta sama wartość po dwóch
|
||||
stronach granicy Ryby/Baran). Determinizm jest tu ważniejszy niż dosłowność
|
||||
zmiennoprzecinkowa."""
|
||||
nearest = round(lon / 30.0) * 30.0
|
||||
return norm360(nearest) if abs(lon - nearest) < _SIGN_SNAP_DEG else lon
|
||||
|
||||
|
||||
def cusps(asc: float, mc: float, system: str) -> list[float]:
|
||||
"""Zwraca 12 cusps (długości) domów 1..12."""
|
||||
if system == WHOLE_SIGN:
|
||||
start = sign_index(asc) * 30.0
|
||||
start = sign_index(_snap_to_sign_boundary(asc)) * 30.0
|
||||
return [norm360(start + 30.0 * i) for i in range(12)]
|
||||
if system == EQUAL:
|
||||
return [norm360(asc + 30.0 * i) for i in range(12)]
|
||||
@@ -64,6 +116,424 @@ def cusps(asc: float, mc: float, system: str) -> list[float]:
|
||||
raise ValueError(f"nieznany system domów: {system}")
|
||||
|
||||
|
||||
def polar_circle(eps_deg: float) -> float:
|
||||
"""Szerokość koła podbiegunowego [°] dla danego nachylenia ekliptyki.
|
||||
|
||||
NIE jest to stała 66,56°: ε zmienia się z datą (ok. 23,71° w 370 p.n.e.,
|
||||
23,44° dziś), więc granica przesuwa się o ~0,3° w zakresie dat programu.
|
||||
Powyżej niej stopnie ekliptyki bywają okołobiegunowe — nie wschodzą ani nie
|
||||
zachodzą — przez co systemy oparte na łuku dobowym (Placidus, Koch) tracą
|
||||
definicję."""
|
||||
return 90.0 - abs(eps_deg)
|
||||
|
||||
|
||||
# ── geometria wektorowa dla systemów dzielących koła wielkie ─────────────
|
||||
# Wzory na te systemy krążą w literaturze w kilku wariantach i łatwo o pomyłkę
|
||||
# w gałęzi albo znaku. Liczymy więc WPROST z geometrii: budujemy wektory kierunkowe
|
||||
# w układzie równikowym, przecinamy płaszczyzny i dopiero wynik zamieniamy na
|
||||
# długość ekliptyczną. Jest to dłuższe, ale jednoznaczne i sprawdzalne.
|
||||
|
||||
def _cross(a, b):
|
||||
return (a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0])
|
||||
|
||||
|
||||
def _dot(a, b):
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
|
||||
|
||||
def _equatorial_to_lon(v, eps_rad: float) -> float:
|
||||
"""Wektor w układzie RÓWNIKOWYM → długość ekliptyczna [°]."""
|
||||
x, y, z = v
|
||||
y_ecl = y * math.cos(eps_rad) + z * math.sin(eps_rad)
|
||||
return norm360(math.degrees(math.atan2(y_ecl, x)))
|
||||
|
||||
|
||||
def _ecliptic_pole(eps_rad: float):
|
||||
"""Biegun ekliptyki (normalna płaszczyzny ekliptyki) w układzie równikowym."""
|
||||
return (0.0, -math.sin(eps_rad), math.cos(eps_rad))
|
||||
|
||||
|
||||
def _horizon_north(ramc_rad: float, lat_rad: float):
|
||||
"""Punkt północny horyzontu: RA = RAMC+180°, deklinacja = 90°−φ."""
|
||||
return (-math.sin(lat_rad) * math.cos(ramc_rad),
|
||||
-math.sin(lat_rad) * math.sin(ramc_rad),
|
||||
math.cos(lat_rad))
|
||||
|
||||
|
||||
# Domy POŚREDNIE (bez osi) i to, po której stronie MC leżą. Domy 11, 12, 2, 3
|
||||
# są na wschód od MC (przesunięcie 0–180°), domy 5, 6, 8, 9 — na zachód.
|
||||
_INTERMEDIATE = {1: True, 2: True, 4: False, 5: False,
|
||||
7: False, 8: False, 10: True, 11: True}
|
||||
|
||||
|
||||
def _house_circle_cusp(north, q, eps_rad: float, mc: float, east_of_mc: bool) -> float:
|
||||
"""Cusp = przecięcie ekliptyki z kołem domu.
|
||||
|
||||
Koło domu przechodzi przez punkty N/S horyzontu oraz przez punkt podziału `q`
|
||||
(na równiku dla Regiomontanusa, na pierwszym wertykale dla Campanusa).
|
||||
Przecięcie dwóch płaszczyzn daje PROSTĄ, czyli DWA antypodyczne kierunki;
|
||||
wybieramy ten po właściwej stronie południka.
|
||||
|
||||
Używane WYŁĄCZNIE dla domów pośrednich. Osie (1, 4, 7, 10) znamy dokładnie
|
||||
z Asc i MC — liczenie ich tą drogą było błędem, bo leżą dokładnie na granicy
|
||||
„wschód/zachód" (przesunięcie 0° i 180°), gdzie porównanie zmiennoprzecinkowe
|
||||
się chwieje i potrafi wybrać przeciwny punkt nieba."""
|
||||
normal = _cross(north, q) # normalna płaszczyzny koła domu
|
||||
line = _cross(normal, _ecliptic_pole(eps_rad))
|
||||
lon = _equatorial_to_lon(line, eps_rad)
|
||||
return lon if ((lon - mc) % 360.0 < 180.0) == east_of_mc else norm360(lon + 180.0)
|
||||
|
||||
|
||||
def _culminating_mc(mc: float, eps: float, lat: float) -> float:
|
||||
"""Punkt południka, który dla tej szerokości leży NAD horyzontem.
|
||||
|
||||
Systemy oparte na horyzoncie (Regiomontanus, Campanus, Topocentric) biorą jako
|
||||
dziesiąty dom punkt GÓRUJĄCY, a nie matematyczne MC — a za kołem podbiegunowym
|
||||
to nie zawsze to samo. Punkt południka o deklinacji δ ma wysokość 90−|φ−δ|,
|
||||
więc jest nad horyzontem dokładnie wtedy, gdy |φ−δ| < 90.
|
||||
|
||||
Systemy dzielące samą ekliptykę (porphyry, equal, alcabitus, whole sign) tego
|
||||
nie robią — i tak samo zachowuje się wyrocznia."""
|
||||
dec = math.degrees(math.asin(math.sin(math.radians(mc)) * math.sin(math.radians(eps))))
|
||||
return norm360(mc + 180.0) if abs(lat - dec) > 90.0 else mc
|
||||
|
||||
|
||||
def _with_exact_angles(intermediate, asc: float, mc: float) -> list[float]:
|
||||
"""Składa 12 cuspów: osie wstawione dokładnie, reszta z geometrii."""
|
||||
out = [0.0] * 12
|
||||
out[0], out[3] = asc, norm360(mc + 180.0) # Asc, IC
|
||||
out[6], out[9] = norm360(asc + 180.0), mc # Dsc, MC
|
||||
for i, value in intermediate.items():
|
||||
out[i] = value
|
||||
return out
|
||||
|
||||
|
||||
def _ra_to_ecliptic_lon(ra_deg: float, eps_rad: float) -> float:
|
||||
"""Punkt ekliptyki o zadanej rektascensji (koło godzinne → ekliptyka)."""
|
||||
r = math.radians(ra_deg)
|
||||
return norm360(math.degrees(math.atan2(math.sin(r), math.cos(r) * math.cos(eps_rad))))
|
||||
|
||||
|
||||
def _equator_point(ra_deg: float):
|
||||
"""Kierunek punktu na równiku niebieskim o danej rektascensji."""
|
||||
r = math.radians(ra_deg)
|
||||
return (math.cos(r), math.sin(r), 0.0)
|
||||
|
||||
|
||||
def _prime_vertical_point(ramc_rad: float, lat_rad: float, angle_deg: float):
|
||||
"""Punkt pierwszego wertykału, `angle_deg` od punktu wschodu w stronę nadiru.
|
||||
|
||||
Pierwszy wertykał to koło przez wschód, zenit, zachód i nadir — Campanus dzieli
|
||||
właśnie je."""
|
||||
east = (-math.sin(ramc_rad), math.cos(ramc_rad), 0.0)
|
||||
zenith = (math.cos(lat_rad) * math.cos(ramc_rad),
|
||||
math.cos(lat_rad) * math.sin(ramc_rad),
|
||||
math.sin(lat_rad))
|
||||
a = math.radians(angle_deg)
|
||||
return tuple(east[i] * math.cos(a) - zenith[i] * math.sin(a) for i in range(3))
|
||||
|
||||
|
||||
def _cusps_regiomontanus(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Równik niebieski dzielony na 12 równych łuków, rzut kołami przez N/S horyzontu."""
|
||||
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
||||
north = _horizon_north(rr, lr)
|
||||
mid = {i: _house_circle_cusp(north, _equator_point(ramc + 90.0 + 30.0 * i), er, mc, e)
|
||||
for i, e in _INTERMEDIATE.items()}
|
||||
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
||||
|
||||
|
||||
def _cusps_campanus(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Pierwszy wertykał dzielony na 12 równych łuków, rzut tak samo jak wyżej."""
|
||||
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
||||
north = _horizon_north(rr, lr)
|
||||
mid = {i: _house_circle_cusp(north, _prime_vertical_point(rr, lr, 30.0 * i), er, mc, e)
|
||||
for i, e in _INTERMEDIATE.items()}
|
||||
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
||||
|
||||
|
||||
def _cusps_morinus(ramc: float, eps: float) -> list[float]:
|
||||
"""Równik dzielony od RAMC i rzutowany WPROST na ekliptykę — bez horyzontu.
|
||||
|
||||
Dlatego Morinus jako jedyny nie zależy od szerokości geograficznej, a jego
|
||||
dom I nie pokrywa się z Ascendentem. Uwaga: to ZAMIANA WSPÓŁRZĘDNYCH punktu
|
||||
równika (RA, dec=0) na ekliptyczne, a nie rzut kołem godzinnym — te dwie
|
||||
operacje dają różne wyniki i pomylenie ich kosztowało tu do 5°."""
|
||||
er = math.radians(eps)
|
||||
return [_equatorial_to_lon(_equator_point(ramc + 90.0 + 30.0 * i), er)
|
||||
for i in range(12)]
|
||||
|
||||
|
||||
def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
|
||||
"""Łuki równika MC→Asc i Asc→IC dzielone na trzy; rzut kołami godzinnymi."""
|
||||
er = math.radians(eps)
|
||||
a = math.radians(asc)
|
||||
ra_asc = norm360(math.degrees(math.atan2(math.sin(a) * math.cos(er), math.cos(a))))
|
||||
day = (ra_asc - ramc) % 360.0 # łuk MC → Asc (domy 11, 12)
|
||||
night = (ramc + 180.0 - ra_asc) % 360.0 # łuk Asc → IC (domy 2, 3)
|
||||
|
||||
ra = [0.0] * 12
|
||||
ra[9] = ramc # dom 10 = MC
|
||||
ra[10] = ramc + day / 3.0 # dom 11
|
||||
ra[11] = ramc + 2.0 * day / 3.0 # dom 12
|
||||
ra[0] = ra_asc # dom 1 = Asc
|
||||
ra[1] = ra_asc + night / 3.0 # dom 2
|
||||
ra[2] = ra_asc + 2.0 * night / 3.0 # dom 3
|
||||
for i in range(6): # domy 4–9 naprzeciw 10–3
|
||||
ra[i + 3] = ra[(i + 9) % 12] + 180.0
|
||||
return [_ra_to_ecliptic_lon(x, er) for x in ra]
|
||||
|
||||
|
||||
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (Polich–Page).
|
||||
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
|
||||
# Polich–Page: dom pośredni to Ascendent policzony pod własnym „biegunem"
|
||||
# tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu. Rodzina jest CIĄGŁA:
|
||||
# przy k=0 (biegun 0, przesunięcie −90°) daje MC, przy k=3 (biegun φ, przesunięcie 0)
|
||||
# Ascendent, a domy 11 i 12 leżą po drodze.
|
||||
#
|
||||
# Cała trudność tego systemu siedziała w wyborze gałęzi — dwa koła wielkie
|
||||
# przecinają się w dwóch punktach antypodycznych. Heurystyki („po której stronie
|
||||
# MC", „w łuku kwadrantu", „wschodnia połowa horyzontu", śledzenie ciągłości
|
||||
# krokami) myliły się na 6–11% przypadków powyżej ~70°, bo każda z nich rozstrzyga
|
||||
# LOKALNIE, a przy dużych szerokościach kolejność domów potrafi się odwrócić.
|
||||
#
|
||||
# Rozwiązanie: nie wybierać w ogóle. Iloczyn wektorowy zenitu z biegunem ekliptyki
|
||||
# jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot — dwuznaczność
|
||||
# wprowadza dopiero atan2. Zostajemy więc w wektorach, a znak ustalamy RAZ, kotwicząc
|
||||
# rodzinę na MC górującym. Stąd zgodność co do zera na całej dziedzinie, bez iteracji
|
||||
# i bez zawężania szerokości.
|
||||
|
||||
# Dom → (przesunięcie RAMC [°], ułamek bieguna k/3).
|
||||
_TOPO_STEP = {10: (-60.0, 1 / 3), 11: (-30.0, 2 / 3), # domy 11, 12
|
||||
1: (30.0, 2 / 3), 2: (60.0, 1 / 3)} # domy 2, 3
|
||||
|
||||
|
||||
def _cusps_topocentric(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Ascendenty pod biegunami tan(P) = tan(φ)·k/3, liczone wektorowo.
|
||||
|
||||
Domy 5, 6, 8, 9 bierzemy jako OPOZYCJE domów 11, 12, 2, 3 — to nie skrót,
|
||||
lecz własność konstrukcji: przeciwległe domy leżą na tym samym kole wielkim.
|
||||
|
||||
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik
|
||||
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
|
||||
konstrukcji „ascendent pod biegunem"."""
|
||||
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 assign_house(lon: float, cusp_list: list[float]) -> int:
|
||||
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
|
||||
lon = norm360(lon)
|
||||
|
||||
@@ -45,6 +45,9 @@ class PositionsRequest(BaseModel):
|
||||
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°)
|
||||
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
|
||||
@@ -69,7 +72,10 @@ def chart_positions(req: PositionsRequest) -> dict:
|
||||
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)
|
||||
house_systems=req.house_systems,
|
||||
aspect_orb=req.aspect_orb,
|
||||
aspect_luminary_bonus=req.aspect_luminary_bonus,
|
||||
aspect_minor=req.aspect_minor)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
if req.stations:
|
||||
@@ -86,6 +92,60 @@ 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.
|
||||
|
||||
@@ -200,6 +200,11 @@ 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,5 +1,62 @@
|
||||
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
||||
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
|
||||
from app.engine.aspects import RIGID_PAIRS, find_aspects, find_cross_aspects, separation
|
||||
|
||||
|
||||
# ── synastria: aspekty MIĘDZY dwoma horoskopami (PRE-04) ──
|
||||
|
||||
def test_cross_aspects_between_two_charts():
|
||||
a = [{"name": "Sun", "decimal": 10.0}, {"name": "Moon", "decimal": 100.0}]
|
||||
b = [{"name": "Sun", "decimal": 12.0}, {"name": "Mars", "decimal": 70.0}]
|
||||
pairs = {(c["obj1"], c["obj2"], c["aspect"]) for c in find_cross_aspects(a, b)}
|
||||
assert ("Sun", "Sun", "conjunction") in pairs # A.Słońce ↔ B.Słońce (2°)
|
||||
assert ("Sun", "Mars", "sextile") in pairs # A.Słońce(10) ↔ B.Mars(70) = 60°
|
||||
|
||||
|
||||
def test_cross_aspect_obj1_is_person_a_obj2_person_b():
|
||||
a = [{"name": "Venus", "decimal": 10.0}]
|
||||
b = [{"name": "Mars", "decimal": 12.0}]
|
||||
c = find_cross_aspects(a, b)[0]
|
||||
assert c["obj1"] == "Venus" and c["obj2"] == "Mars"
|
||||
|
||||
|
||||
def test_cross_aspects_do_not_apply_rigid_pair_filter():
|
||||
"""NN osoby A vs SN osoby B to REALNY aspekt między ludźmi — nie wycinamy go
|
||||
jak pary sztywnej w jednym horoskopie."""
|
||||
a = [{"name": "North Node", "decimal": 10.0}]
|
||||
b = [{"name": "South Node", "decimal": 12.0}]
|
||||
assert find_cross_aspects(a, b) # niepuste
|
||||
|
||||
|
||||
def test_cross_aspects_no_applying_flag():
|
||||
"""Dwa natale — brak wspólnego czasu, więc bez applying/separating."""
|
||||
a = [{"name": "Sun", "decimal": 10.0, "speed": 1.0}]
|
||||
b = [{"name": "Moon", "decimal": 12.0, "speed": 13.0}]
|
||||
assert "as" not in find_cross_aspects(a, b)[0]
|
||||
|
||||
|
||||
# ── konfigurowalne aspekty i orby (PRE-06) ──
|
||||
|
||||
def test_minor_aspects_only_with_flag():
|
||||
"""Domyślnie tylko główne; `minor=True` dokłada poboczne (30/45/150°)."""
|
||||
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 40.0}] # 30° = półsekstyl
|
||||
assert find_aspects(pos) == [] # główne: brak
|
||||
assert [a["aspect"] for a in find_aspects(pos, minor=True)] == ["semisextile"]
|
||||
|
||||
|
||||
def test_orb_is_configurable():
|
||||
"""Węższy orb odsiewa aspekt, który szerszy łapie."""
|
||||
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 78.0}] # 68° = sextile dev 8°
|
||||
assert find_aspects(pos, orb=5.0) == []
|
||||
assert [a["aspect"] for a in find_aspects(pos, orb=10.0)] == ["sextile"]
|
||||
|
||||
|
||||
def test_luminary_bonus_widens_orb_for_lights():
|
||||
"""Bonus dolicza orb tylko gdy w parze jest Słońce/Księżyc."""
|
||||
lights = [{"name": "Sun", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}] # dev 9° od sextile
|
||||
assert find_aspects(lights, orb=8.0, luminary_bonus=0.0) == [] # 9 > 8
|
||||
assert [a["aspect"] for a in find_aspects(lights, orb=8.0, luminary_bonus=2.0)] == ["sextile"] # 9 < 10
|
||||
plain = [{"name": "Venus", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}]
|
||||
assert find_aspects(plain, orb=8.0, luminary_bonus=2.0) == [] # brak światła → bez bonusu
|
||||
|
||||
|
||||
def test_separation_wraparound():
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
Waliduje względem astro.com dla horoskopu referencyjnego.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.engine import houses as H
|
||||
from app.engine.chart import build_chart
|
||||
|
||||
|
||||
@@ -61,8 +64,31 @@ def test_primary_always_first_even_if_not_listed(own_engine, reference_moment):
|
||||
|
||||
|
||||
def test_unknown_extra_system_is_ignored(own_engine, reference_moment):
|
||||
"""Nieobsługiwany system (np. placidus — dojdzie przez swisseph osobno) jest
|
||||
po prostu pomijany, nie wywala horoskopu."""
|
||||
"""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=["placidus", "equal"])
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine import houses as H
|
||||
|
||||
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
||||
@@ -39,3 +43,209 @@ def test_assign_house_whole_sign():
|
||||
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°)
|
||||
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
||||
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
|
||||
|
||||
|
||||
# ── regresje wykryte przez porównanie z wyrocznią (tests/oracle) ──────────
|
||||
|
||||
def test_ascendant_branch_beyond_polar_circle():
|
||||
"""REGRESJA: powyżej koła podbiegunowego `atan2` wybierał NIEWŁAŚCIWY punkt
|
||||
przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent zamiast Ascendenta,
|
||||
czyli błąd o 180°. Planety lądowały w PRZECIWNYCH domach dla całej północnej
|
||||
Skandynawii. Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze
|
||||
w półkolu (0°,180°) na wschód od MC."""
|
||||
eps = 23.4393
|
||||
for lat in (67.0, 70.0, 85.0, -70.0):
|
||||
for ramc in (60.0, 100.0, 200.0, 270.0, 300.0):
|
||||
asc = H.compute_asc(ramc, eps, lat)
|
||||
mc = H.compute_mc(ramc, eps)
|
||||
offset = (asc - mc) % 360.0
|
||||
assert 0.0 <= offset <= 180.0, (
|
||||
f"Asc poza półkolem wschodzącym: lat={lat} ramc={ramc} offset={offset:.4f}")
|
||||
|
||||
|
||||
def test_whole_sign_is_deterministic_at_sign_boundary():
|
||||
"""REGRESJA: Ascendent o włos od granicy znaku (359,999999999976 vs 1e-10 —
|
||||
ta sama wartość, różne strony) przerzucał cały dom I o 30°. Ten sam horoskop
|
||||
na innej maszynie dawał inny wynik."""
|
||||
below = H.cusps(359.999999999976, 270.0, H.WHOLE_SIGN)
|
||||
above = H.cusps(1e-10, 270.0, H.WHOLE_SIGN)
|
||||
assert below[0] == above[0] == 0.0, (below[0], above[0])
|
||||
|
||||
|
||||
def test_polar_circle_moves_with_obliquity():
|
||||
"""Granica dziedziny Placidusa/Kocha NIE jest stałą 66,56° — zależy od ε,
|
||||
a ε zmienia się z datą. Testy brzegowe muszą ją liczyć per data."""
|
||||
assert H.polar_circle(23.4393) == pytest.approx(66.5607, abs=1e-4) # dziś
|
||||
assert H.polar_circle(23.747) == pytest.approx(66.253, abs=1e-4) # 370 p.n.e.
|
||||
|
||||
|
||||
# ── systemy egzotyczne o zamkniętym wzorze (Etap 1) ──────────────────────
|
||||
# Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które
|
||||
# muszą zachodzić także bez swissepha (czyli w każdym środowisku).
|
||||
|
||||
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus",
|
||||
"topocentric")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", EXOTIC)
|
||||
def test_exotic_returns_twelve_cusps_in_range(system):
|
||||
out = H.cusps_for(100.0, 23.4393, 50.0, system)
|
||||
assert len(out) == 12
|
||||
assert all(0.0 <= c < 360.0 for c in out)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", EXOTIC)
|
||||
def test_opposite_houses_are_antipodal(system):
|
||||
"""Domy przeciwległe leżą na tym samym kole wielkim, więc ich cuspy są
|
||||
dokładnie antypodyczne. Naruszenie tego oznacza błąd w wyborze gałęzi."""
|
||||
out = H.cusps_for(137.0, 23.4393, 42.0, system)
|
||||
for i in range(6):
|
||||
assert abs(((out[i + 6] - out[i]) % 360.0) - 180.0) < 1e-9, f"domy {i+1}/{i+7}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", ("regiomontanus", "campanus", "alcabitus"))
|
||||
def test_quadrant_systems_anchor_on_ascendant(system):
|
||||
"""Systemy kwadrantowe zaczynają dom I na Ascendencie."""
|
||||
ramc, eps, lat = 100.0, 23.4393, 50.0
|
||||
assert H.cusps_for(ramc, eps, lat, system)[0] == pytest.approx(
|
||||
H.compute_asc(ramc, eps, lat), abs=1e-9)
|
||||
|
||||
|
||||
def test_vehlow_puts_ascendant_in_the_middle_of_house_one():
|
||||
ramc, eps, lat = 100.0, 23.4393, 50.0
|
||||
asc = H.compute_asc(ramc, eps, lat)
|
||||
assert H.cusps_for(ramc, eps, lat, "vehlow")[0] == pytest.approx(
|
||||
H.norm360(asc - 15.0), abs=1e-9)
|
||||
|
||||
|
||||
def test_morinus_ignores_latitude():
|
||||
"""Morinus rzutuje równik wprost na ekliptykę, bez horyzontu — jako jedyny
|
||||
nie zależy od szerokości geograficznej."""
|
||||
a = H.cusps_for(100.0, 23.4393, 20.0, "morinus")
|
||||
b = H.cusps_for(100.0, 23.4393, 65.0, "morinus")
|
||||
assert a == pytest.approx(b, abs=1e-12)
|
||||
|
||||
|
||||
def test_unknown_system_is_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
H.cusps_for(100.0, 23.4393, 50.0, "nie-ma-takiego")
|
||||
|
||||
|
||||
# ── systemy łuku dobowego: Placidus i Koch (Etap 2) ──────────────────────
|
||||
|
||||
DIURNAL = ("placidus", "koch")
|
||||
TROMSO_LAT = 69.65 # za kołem podbiegunowym — tam te systemy nie istnieją
|
||||
KRAKOW_LAT = 50.06
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", DIURNAL)
|
||||
def test_diurnal_systems_refuse_beyond_the_polar_circle(system):
|
||||
"""Odmowa, nie liczba. Cicha podmiana systemu jest niewykrywalna z wykresu."""
|
||||
with pytest.raises(H.HouseSystemUndefined):
|
||||
H.cusps_for(100.0, 23.4393, TROMSO_LAT, system)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", DIURNAL)
|
||||
def test_fallback_says_what_it_did(system):
|
||||
cs = H.cusps_detailed(100.0, 23.4393, TROMSO_LAT, system)
|
||||
assert cs.is_fallback and cs.system == H.FALLBACK_SYSTEM and cs.requested == system
|
||||
assert len(cs.cusps) == 12
|
||||
assert cs.notice and system in cs.notice and H.FALLBACK_SYSTEM in cs.notice
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system", DIURNAL)
|
||||
def test_no_fallback_and_no_notice_where_the_system_exists(system):
|
||||
cs = H.cusps_detailed(100.0, 23.4393, KRAKOW_LAT, system)
|
||||
assert not cs.is_fallback and cs.notice is None
|
||||
assert cs.cusps == H.cusps_for(100.0, 23.4393, KRAKOW_LAT, system)
|
||||
|
||||
|
||||
def test_placidus_cusps_satisfy_their_own_definition():
|
||||
"""Sprawdzian NIEZALEŻNY od wyroczni: czy znaleziony punkt naprawdę przebył
|
||||
zadany ułamek swojego półłuku. Wyrocznia mówi „zgadza się z swissephem",
|
||||
to mówi „zgadza się z definicją Placidusa" — i działa bez swissepha."""
|
||||
ramc, eps, lat = 212.9986, 23.4393, 66.3144 # tuż pod kołem, najtrudniej
|
||||
eps_rad = math.radians(eps)
|
||||
c = H.cusps_for(ramc, eps, lat, "placidus")
|
||||
for house, (frac, nocturnal) in ((11, (1 / 3, False)), (12, (2 / 3, False)),
|
||||
(2, (2 / 3, True)), (3, (1 / 3, True))):
|
||||
lon = c[house - 1]
|
||||
dec = H._declination_of_ecliptic_lon(lon, eps_rad)
|
||||
ad = H._ascensional_difference(dec, lat)
|
||||
ra = math.degrees(math.atan2(math.sin(math.radians(lon)) * math.cos(eps_rad),
|
||||
math.cos(math.radians(lon))))
|
||||
want = (ramc + 180.0 - frac * (90.0 - ad) if nocturnal
|
||||
else ramc + frac * (90.0 + ad))
|
||||
assert abs((ra - want + 180.0) % 360.0 - 180.0) < 1e-9, f"dom {house}"
|
||||
|
||||
|
||||
def test_koch_reduces_to_equal_spacing_on_the_equator():
|
||||
"""Na równiku półłuk każdego stopnia to dokładnie 90°, więc trójdzielenie
|
||||
czasu daje Ascendenty co 30° czasu gwiazdowego — dobry test zdrowia wzoru."""
|
||||
ramc, eps = 100.0, 23.4393
|
||||
c = H.cusps_for(ramc, eps, 0.0, "koch")
|
||||
for house, offset in ((11, -60.0), (12, -30.0), (2, 30.0), (3, 60.0)):
|
||||
assert c[house - 1] == pytest.approx(
|
||||
H.compute_asc(ramc + offset, eps, 0.0), abs=1e-9), f"dom {house}"
|
||||
|
||||
|
||||
def test_polar_circle_boundary_moves_with_the_date():
|
||||
"""Granica dziedziny NIE jest stałą 66,56° — zależy od ε, czyli od daty."""
|
||||
lat = 66.60
|
||||
assert lat > H.polar_circle(23.4393) # dziś: poza dziedziną
|
||||
assert lat < H.polar_circle(23.3528) # rok 2270: już w dziedzinie
|
||||
with pytest.raises(H.HouseSystemUndefined):
|
||||
H.cusps_for(100.0, 23.4393, lat, "placidus")
|
||||
assert len(H.cusps_for(100.0, 23.3528, lat, "placidus")) == 12
|
||||
|
||||
|
||||
def test_topocentric_cusps_stay_in_order_between_mc_and_ascendant():
|
||||
"""Regresja na to, co topocentrica psuło najdłużej — wybór gałęzi.
|
||||
|
||||
Cuspy 11 i 12 to punkty CIĄGŁEJ rodziny biegnącej od MC do Ascendentu, więc
|
||||
czwórka MC → 11 → 12 → Asc leży na okręgu w kolejności, bez wyprzedzania się.
|
||||
Zły wybór gałęzi przerzuca któryś punkt o 180° i kolejność pęka.
|
||||
|
||||
Kierunek celowo NIE jest ustalony: przy dużych szerokościach domy biegną
|
||||
wstecz (przy φ=−79,55° MC=306,8°, a dom 11 to 291,4°) i to jest poprawne —
|
||||
wyrocznia zwraca tam dokładnie to samo. Sprawdzamy więc, że kolejność trzyma
|
||||
się w JEDNĄ ze stron, a nie że rośnie."""
|
||||
def d(a, b):
|
||||
return H.norm360(b - a)
|
||||
|
||||
for lat in (-89.9, -79.55, -67.18, 0.0, 50.06, 73.69, 80.66, 89.9):
|
||||
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_needs_no_domain_limit():
|
||||
"""W przeciwieństwie do Placidusa/Kocha topocentric liczy się WSZĘDZIE —
|
||||
nie ma tu granicy dziedziny do wyprowadzenia, więc nie wolno jej udawać."""
|
||||
for lat in (0.0, 66.5, 69.65, 89.9, -89.9):
|
||||
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
|
||||
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
|
||||
assert not cs.is_fallback and cs.notice is None
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -114,8 +114,15 @@ def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
|
||||
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
|
||||
|
||||
|
||||
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
|
||||
phi = math.radians(180.0 - (lon - asc))
|
||||
# Co stoi po lewej stronie koła (LOG-05: „warianty 0°Aries / fixed Asc").
|
||||
ASC_LEFT = "asc"
|
||||
ARIES_LEFT = "aries"
|
||||
ORIENTATIONS = ((ASC_LEFT, "Ascendent po lewej"), (ARIES_LEFT, "0° Barana po lewej"))
|
||||
|
||||
|
||||
def _pt(lon: float, ref: float, r: float) -> tuple[float, float]:
|
||||
"""Punkt na kole dla długości `lon`, przy `ref` po lewej stronie rysunku."""
|
||||
phi = math.radians(180.0 - (lon - ref))
|
||||
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
|
||||
|
||||
|
||||
@@ -206,12 +213,19 @@ def _delta(a: float, b: float) -> float:
|
||||
return abs(((a - b + 180.0) % 360.0) - 180.0)
|
||||
|
||||
|
||||
def render(chart: dict, theme: str = "screen") -> str:
|
||||
def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> str:
|
||||
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
|
||||
|
||||
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
|
||||
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
|
||||
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
|
||||
|
||||
orientation — co stoi po LEWEJ stronie koła (LOG-05, warianty rysunku):
|
||||
'asc' — Ascendent (domyślne; tak rysuje większość szkół zachodnich,
|
||||
dom I zawsze zaczyna się w tym samym miejscu rysunku),
|
||||
'aries' — 0° Barana (koło nieruchome względem zodiaku, więc dwa horoskopy
|
||||
da się porównywać „na oko"; osie wypadają za to gdzie indziej).
|
||||
Zmienia się WYŁĄCZNIE obrót rysunku — żadna liczba nie jest przeliczana.
|
||||
"""
|
||||
T = _THEMES.get(theme, _THEMES["screen"])
|
||||
# tylko dla druku — na stronie font podaje CSS (.glyph)
|
||||
@@ -223,6 +237,9 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
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
|
||||
@@ -244,8 +261,8 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
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, asc, R_HUB)
|
||||
bx1, by1 = _pt(lb, asc, R_HUB)
|
||||
ax1, ay1 = _pt(la, ref, R_HUB)
|
||||
bx1, by1 = _pt(lb, ref, R_HUB)
|
||||
tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} '
|
||||
f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°')
|
||||
parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
|
||||
@@ -253,17 +270,17 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
# 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, asc, R_ZOD)
|
||||
x2, y2 = _pt(deg, asc, r_in)
|
||||
x1, y1 = _pt(deg, ref, R_ZOD)
|
||||
x2, y2 = _pt(deg, ref, r_in)
|
||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
|
||||
|
||||
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
|
||||
for i, sg in enumerate(signs):
|
||||
b = 30.0 * i
|
||||
x1, y1 = _pt(b, asc, R_ZOD)
|
||||
x2, y2 = _pt(b, asc, R_OUT)
|
||||
x1, y1 = _pt(b, ref, R_ZOD)
|
||||
x2, y2 = _pt(b, ref, R_OUT)
|
||||
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
|
||||
gx, gy = _pt(b + 15.0, asc, R_SIGN)
|
||||
gx, gy = _pt(b + 15.0, ref, R_SIGN)
|
||||
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
|
||||
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
|
||||
|
||||
@@ -271,31 +288,31 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
n = len(cusps)
|
||||
for i, c in enumerate(cusps):
|
||||
lon = float(c["decimal"])
|
||||
x1, y1 = _pt(lon, asc, R_ZOD)
|
||||
x2, y2 = _pt(lon, asc, R_HUB)
|
||||
x1, y1 = _pt(lon, ref, R_ZOD)
|
||||
x2, y2 = _pt(lon, ref, R_HUB)
|
||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
|
||||
nxt = float(cusps[(i + 1) % n]["decimal"])
|
||||
span = (nxt - lon) % 360.0 or 360.0
|
||||
mid = lon + span / 2.0
|
||||
hx, hy = _pt(mid, asc, R_HNUM)
|
||||
hx, hy = _pt(mid, ref, R_HNUM)
|
||||
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
|
||||
# Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy
|
||||
# są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer;
|
||||
# w systemach kwadratowych ta liczba realnie coś mówi.
|
||||
deg_in_sign = lon % 30.0
|
||||
if deg_in_sign >= 0.5:
|
||||
cx1, cy1 = _pt(lon, asc, R_ZOD - 9)
|
||||
cx1, cy1 = _pt(lon, ref, R_ZOD - 9)
|
||||
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
|
||||
|
||||
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
|
||||
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
|
||||
lon_a = float(angles[a]["decimal"])
|
||||
ax, ay = _pt(lon_a, asc, R_ZOD)
|
||||
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
|
||||
ax, ay = _pt(lon_a, ref, R_ZOD)
|
||||
bx, by = _pt(lon_a + 180.0, ref, R_ZOD)
|
||||
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
|
||||
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
|
||||
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
|
||||
lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
|
||||
lax, lay = _pt(lon_a, ref, R_AXIS_LABEL)
|
||||
lbx, lby = _pt(lon_a + 180.0, ref, R_AXIS_LABEL)
|
||||
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
|
||||
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
|
||||
|
||||
@@ -310,15 +327,15 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
|
||||
retro = obj.get("direction") == "Rx"
|
||||
|
||||
mx1, my1 = _pt(true_lon, asc, R_ZOD)
|
||||
mx2, my2 = _pt(true_lon, asc, R_MARK)
|
||||
mx1, my1 = _pt(true_lon, ref, R_ZOD)
|
||||
mx2, my2 = _pt(true_lon, ref, R_MARK)
|
||||
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
|
||||
|
||||
if _delta(draw_lon, true_lon) > 0.4:
|
||||
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
|
||||
lx, ly = _pt(draw_lon, ref, R_PLANET + 12)
|
||||
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
|
||||
|
||||
gx, gy = _pt(draw_lon, asc, R_PLANET)
|
||||
gx, gy = _pt(draw_lon, ref, R_PLANET)
|
||||
tip = obj.get("name") or ""
|
||||
if obj.get("in_sign"):
|
||||
tip += f" · {obj['in_sign']}"
|
||||
@@ -332,7 +349,7 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
# 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, asc, R_DEG)
|
||||
dx, dy = _pt(draw_lon, ref, R_DEG)
|
||||
label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
|
||||
parts.append(_text(dx, dy, label, size=8,
|
||||
fill=T["retro"] if retro else T["muted"]))
|
||||
@@ -346,10 +363,10 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
if not glyph or lot.get("longitude") is None:
|
||||
continue
|
||||
lon = float(lot["longitude"])
|
||||
mx1, my1 = _pt(lon, asc, R_ZOD)
|
||||
mx2, my2 = _pt(lon, asc, R_MARK)
|
||||
mx1, my1 = _pt(lon, ref, R_ZOD)
|
||||
mx2, my2 = _pt(lon, ref, R_MARK)
|
||||
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
|
||||
lx, ly = _pt(lon, asc, R_LOT)
|
||||
lx, ly = _pt(lon, ref, R_LOT)
|
||||
tip = lot.get("name") or ""
|
||||
if lot.get("in_sign"):
|
||||
tip += f" · {lot['in_sign']}"
|
||||
@@ -367,7 +384,6 @@ def render(chart: dict, theme: str = "screen") -> str:
|
||||
# ── 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.
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ class LogicClient:
|
||||
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 = {
|
||||
@@ -64,6 +67,9 @@ class LogicClient:
|
||||
"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,
|
||||
@@ -72,6 +78,18 @@ 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]:
|
||||
@@ -134,6 +152,12 @@ 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:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Katalog systemów domów dla warstwy prezentacji — JEDNO źródło nazw.
|
||||
|
||||
Nazwy były zaszyte w czterech szablonach naraz (selektor na „Horoskop", selektor
|
||||
na „Skompiluj", checkboxy porównania, nagłówki tabeli). Przy trzech systemach
|
||||
uchodziło to na sucho; przy dziesięciu rozjazd jest kwestią czasu — wystarczy
|
||||
dopisać system w jednym miejscu i zapomnieć o trzech pozostałych.
|
||||
|
||||
Kolejność jest celowa: najpierw dwa najpowszechniejsze (Placidus i Whole Sign),
|
||||
potem reszta. `LIMITED` oznacza systemy, które powyżej koła podbiegunowego nie
|
||||
istnieją i podlegają jawnemu fallbackowi — UI ma o tym uprzedzać ZANIM ktoś
|
||||
policzy horoskop dla Tromsø, a nie dopiero w ostrzeżeniu przy wyniku.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
HOUSE_SYSTEMS: list[tuple[str, str]] = [
|
||||
("placidus", "Placidus"),
|
||||
("whole_sign", "Whole Sign"),
|
||||
("whole_sign_aries", "Whole Sign (od Barana)"),
|
||||
("koch", "Koch"),
|
||||
("equal", "Equal (od Asc)"),
|
||||
("equal_mc", "Equal (od MC)"),
|
||||
("porphyry", "Porphyry"),
|
||||
("regiomontanus", "Regiomontanus"),
|
||||
("campanus", "Campanus"),
|
||||
("alcabitus", "Alcabitus"),
|
||||
("topocentric", "Topocentric"),
|
||||
("morinus", "Morinus"),
|
||||
("vehlow", "Vehlow"),
|
||||
]
|
||||
|
||||
# Systemy oparte na łuku dobowym — jedyne z realną granicą dziedziny.
|
||||
LIMITED = frozenset({"placidus", "koch"})
|
||||
|
||||
HOUSE_SYSTEM_NAMES: dict[str, str] = dict(HOUSE_SYSTEMS)
|
||||
|
||||
|
||||
def label(system: str) -> str:
|
||||
"""Nazwa do pokazania; nieznany identyfikator zwracamy bez zmian, nie gubimy go."""
|
||||
return HOUSE_SYSTEM_NAMES.get(system, system)
|
||||
@@ -10,8 +10,11 @@ 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
|
||||
@@ -19,6 +22,9 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app import chartwheel as chartwheel_mod
|
||||
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
|
||||
@@ -30,6 +36,34 @@ logic = LogicClient()
|
||||
security.install(app) # logowanie + limit żądań (LOG-32)
|
||||
|
||||
|
||||
# ── cache-busting statyki (PRE-26) ─────────────────────────────────────────
|
||||
# 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 _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
|
||||
|
||||
@@ -50,6 +84,18 @@ 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 (
|
||||
@@ -80,14 +126,20 @@ def chart_compute(
|
||||
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, "stations": stations,
|
||||
"zodiac": zodiac, "tables": tables}
|
||||
"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}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
@@ -95,10 +147,12 @@ def chart_compute(
|
||||
ctx["result"] = logic.positions(
|
||||
when_utc_iso=iso_utc, 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,
|
||||
)
|
||||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation)
|
||||
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)
|
||||
@@ -129,9 +183,13 @@ def compile_build(
|
||||
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).
|
||||
@@ -144,8 +202,10 @@ def compile_build(
|
||||
"""
|
||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "house_system": house_system,
|
||||
"house_systems": house_systems, "stations": stations,
|
||||
"zodiac": zodiac, "tables": tables}
|
||||
"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}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
@@ -153,10 +213,12 @@ def compile_build(
|
||||
ctx["result"] = logic.positions(
|
||||
when_utc_iso=iso_utc, 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,
|
||||
)
|
||||
from app import chartwheel
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation)
|
||||
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)
|
||||
@@ -192,22 +254,32 @@ 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"), "Kosmogram"),
|
||||
(chartwheel.render(chart, theme="print",
|
||||
orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)),
|
||||
"Kosmogram"),
|
||||
(chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
|
||||
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
|
||||
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
|
||||
@@ -222,6 +294,7 @@ 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 [],
|
||||
}
|
||||
@@ -236,6 +309,66 @@ 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):
|
||||
@@ -254,6 +387,8 @@ def significators_search(
|
||||
ctx: dict = {"form": form, "result": None, "error": None}
|
||||
try:
|
||||
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
|
||||
# ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści
|
||||
security.audit_records(request, len((ctx["result"] or {}).get("rows") or []))
|
||||
except httpx.HTTPError as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
return templates.TemplateResponse(request, "significators.html", ctx)
|
||||
@@ -299,6 +434,9 @@ 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",
|
||||
@@ -316,6 +454,7 @@ 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:
|
||||
|
||||
@@ -5,22 +5,38 @@ 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ę.
|
||||
|
||||
Dwa mechanizmy:
|
||||
* **HTTP Basic** — wejście do aplikacji; włącza się, gdy ustawiono APP_PASSWORD.
|
||||
Trzy mechanizmy:
|
||||
* **HTTP Basic** — wejście do aplikacji; włącza się, gdy są jakiekolwiek konta.
|
||||
* **limit żądań** — hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań);
|
||||
rozliczany per adres klienta, a za odwrotnym proxy — po TRUST_PROXY=true —
|
||||
per adres z nagłówka, nie per adres proxy (patrz `client_ip`).
|
||||
* **konta imienne + dziennik audytowy** (PRE-17) — patrz niżej.
|
||||
|
||||
Świadomie NIE logujemy treści żądań ani promptów — logi to kolejny nośnik wycieku.
|
||||
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.
|
||||
|
||||
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.
|
||||
DZIENNIK AUDYTOWY (PRE-17). Każde żądanie do chronionej ścieżki zostawia wpis:
|
||||
kto, kiedy, jaka ścieżka, status, ile rekordów (gdy handler to poda przez
|
||||
`audit_records`). To jedyny sposób, by zauważyć POWOLNE wypompowywanie bazy przez
|
||||
osobę uprawnioną — pojedyncze zapytanie wygląda niewinnie, suma już nie.
|
||||
Świadomie NIE logujemy treści żądań, promptów ani rekordów — logi to kolejny
|
||||
nośnik wycieku; liczby i metadane wystarczą do wykrycia nadużycia.
|
||||
|
||||
UWAGA: bez kont ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev). Wtedy przy
|
||||
starcie leci głośne ostrzeżenie — żeby nikt nie wdrożył tego w przekonaniu, że
|
||||
jest chroniony.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
@@ -45,6 +61,54 @@ 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"))
|
||||
|
||||
@@ -60,25 +124,30 @@ _hits: dict[str, deque[float]] = {}
|
||||
|
||||
|
||||
def auth_enabled() -> bool:
|
||||
return bool(app_password())
|
||||
return bool(accounts())
|
||||
|
||||
|
||||
def _is_public(path: str) -> bool:
|
||||
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
|
||||
|
||||
|
||||
def _authorized(header: str | None) -> bool:
|
||||
def authenticate(header: str | None) -> str | None:
|
||||
"""Nagłówek Basic → LOGIN zalogowanej osoby albo None.
|
||||
|
||||
Zwracamy login (nie bool), bo dziennik audytowy musi wiedzieć KTO — to sedno
|
||||
PRE-17. Nieznany login i złe hasło traktujemy tak samo (bez podpowiadania,
|
||||
które konto istnieje)."""
|
||||
if not header or not header.lower().startswith("basic "):
|
||||
return False
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
||||
user, _, password = raw.partition(":")
|
||||
except (binascii.Error, UnicodeDecodeError, IndexError):
|
||||
return False
|
||||
# porównanie odporne na atak czasowy; oba pola muszą się zgadzać
|
||||
ok_user = secrets.compare_digest(user, app_user())
|
||||
ok_pass = secrets.compare_digest(password, app_password())
|
||||
return ok_user and ok_pass
|
||||
return None
|
||||
spec = accounts().get(user)
|
||||
if spec is None:
|
||||
return None
|
||||
return user if _secret_matches(spec, password) else None
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
@@ -124,12 +193,62 @@ 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: APP_PASSWORD nie ustawione — aplikacja jest OTWARTA dla każdego, "
|
||||
"kto ma do niej dostęp sieciowy, wraz z treścią baz interpretacyjnych."
|
||||
"UWAGA: brak kont (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA "
|
||||
"dla każdego, kto ma do niej dostęp sieciowy, wraz z treścią baz "
|
||||
"interpretacyjnych."
|
||||
)
|
||||
elif os.getenv("APP_USERS", "").strip() and app_password():
|
||||
# Gdyby wspólne hasło działało obok kont imiennych, zostawiałoby tylne
|
||||
# wejście bez śladu w dzienniku — czyli problem, który PRE-17 zamyka.
|
||||
log.warning(
|
||||
"APP_USERS jest ustawione, więc wspólne APP_PASSWORD NIE działa "
|
||||
"(konta imienne mają pierwszeństwo). Usuń APP_PASSWORD z konfiguracji."
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -137,15 +256,24 @@ 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"},
|
||||
)
|
||||
|
||||
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)
|
||||
user = ""
|
||||
if auth_enabled():
|
||||
user = authenticate(request.headers.get("authorization")) or ""
|
||||
if not user:
|
||||
_audit(request, "-", 401, (time.monotonic() - started) * 1000)
|
||||
return HTMLResponse(
|
||||
"<h1>401 — wymagane logowanie</h1>", status_code=401,
|
||||
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
|
||||
)
|
||||
request.state.user = user
|
||||
response = await call_next(request)
|
||||
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
|
||||
return response
|
||||
|
||||
@@ -109,6 +109,8 @@
|
||||
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 || ''
|
||||
};
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
{ 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: 'house_systems', name: 'house_systems', type: 'multi' },
|
||||
{ key: 'aspect_orb', sel: 'input[name=aspect_orb]' },
|
||||
{ key: 'aspect_luminary_bonus', sel: 'input[name=aspect_luminary_bonus]' },
|
||||
{ key: 'aspect_minor', sel: 'input[name=aspect_minor]', type: 'check' }
|
||||
];
|
||||
|
||||
function elementsOf(f) {
|
||||
|
||||
@@ -93,6 +93,18 @@ button:hover { filter: brightness(1.1); }
|
||||
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
|
||||
.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; }
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{# 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. #}
|
||||
<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>
|
||||
<div class="opts" 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">Porównaj systemy domów:</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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
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>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||
@@ -20,10 +28,9 @@
|
||||
|
||||
{# 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 %}
|
||||
{% set HSN = {'whole_sign': 'Whole Sign', 'equal': 'Equal', 'porphyry': 'Porphyry'} %}
|
||||
<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>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ HSN.get(hs.system, hs.system) }}</th>{% endfor %}</tr></thead>
|
||||
<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>
|
||||
@@ -219,7 +226,7 @@
|
||||
|
||||
{% if result.cusps %}
|
||||
<details class="loc">
|
||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||
<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>
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
<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('wheelzoom.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
@@ -22,8 +22,10 @@
|
||||
<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="/synastry" class="{% block nav_synastry %}{% endblock %}">Synastria</a>
|
||||
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
||||
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
|
||||
<a href="/settings" class="{% block nav_settings %}{% endblock %}">Ustawienia</a>
|
||||
</nav>
|
||||
</header>
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
@@ -34,10 +34,11 @@
|
||||
<label>System domów
|
||||
<select name="house_system">
|
||||
{% set hs = form.house_system or 'whole_sign' %}
|
||||
<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>
|
||||
{% for value, name in HOUSE_SYSTEMS %}
|
||||
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="muted small" title="Placidus i Koch dzielą łuk dobowy, który powyżej koła podbiegunowego (ok. 66,5°) nie istnieje — dla takich miejsc policzymy Porphyry i wyraźnie o tym napiszemy.">* nie działa za kołem podbiegunowym</span>
|
||||
</label>
|
||||
<label>Zodiak
|
||||
<select name="zodiac">
|
||||
@@ -51,19 +52,7 @@
|
||||
</label>
|
||||
</div>
|
||||
{% include "_location_picker.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>
|
||||
<div class="opts" 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">Porównaj systemy domów:</span>
|
||||
<label><input type="checkbox" name="house_systems" value="whole_sign" {{ 'checked' if 'whole_sign' in chosen else '' }}> Whole Sign</label>
|
||||
<label><input type="checkbox" name="house_systems" value="equal" {{ 'checked' if 'equal' in chosen else '' }}> Equal</label>
|
||||
<label><input type="checkbox" name="house_systems" value="porphyry" {{ 'checked' if 'porphyry' in chosen else '' }}> Porphyry</label>
|
||||
</div>
|
||||
{% include "_form_options.html" %}
|
||||
{% 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>
|
||||
@@ -77,10 +66,14 @@
|
||||
{% 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: {{ result.house_system }}{% endif %}
|
||||
{% if result.house_system %}· domy: {{ house_label(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>
|
||||
@@ -99,102 +92,6 @@
|
||||
</figure>
|
||||
{% endif %}
|
||||
|
||||
{% if result.angles %}
|
||||
<table class="angles">
|
||||
<thead><tr><th>Oś</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 %}
|
||||
{% set HSN = {'whole_sign': 'Whole Sign', 'equal': 'Equal', 'porphyry': 'Porphyry'} %}
|
||||
<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>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ HSN.get(hs.system, 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>
|
||||
{% 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 }}
|
||||
@@ -202,24 +99,6 @@
|
||||
</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 }}
|
||||
@@ -227,118 +106,8 @@
|
||||
</figure>
|
||||
{% 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 ({{ 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 %}
|
||||
{% include "_result_tables.html" %}
|
||||
{% endif %}
|
||||
|
||||
<script src="/static/now.js"></script>
|
||||
<script src="{{ static('now.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -39,10 +39,11 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
||||
<label>System domów
|
||||
<select name="house_system">
|
||||
{% set hs = form.house_system or 'whole_sign' %}
|
||||
<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>
|
||||
{% for value, name in HOUSE_SYSTEMS %}
|
||||
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="muted small" title="Placidus i Koch dzielą łuk dobowy, który powyżej koła podbiegunowego (ok. 66,5°) nie istnieje — dla takich miejsc policzymy Porphyry i wyraźnie o tym napiszemy.">* nie działa za kołem podbiegunowym</span>
|
||||
</label>
|
||||
<label>Zodiak
|
||||
<select name="zodiac">
|
||||
@@ -55,21 +56,9 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{# Te same opcje co na „Horoskop" — synchronizowane między zakładkami (formsync),
|
||||
żeby podsumowanie liczyło się z tym, co wybrano przy horoskopie (nie okrojone). #}
|
||||
<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>
|
||||
<div class="opts">
|
||||
{% set chosen = form.house_systems or [] %}
|
||||
<span class="muted small">Porównaj systemy domów:</span>
|
||||
<label><input type="checkbox" name="house_systems" value="whole_sign" {{ 'checked' if 'whole_sign' in chosen else '' }}> Whole Sign</label>
|
||||
<label><input type="checkbox" name="house_systems" value="equal" {{ 'checked' if 'equal' in chosen else '' }}> Equal</label>
|
||||
<label><input type="checkbox" name="house_systems" value="porphyry" {{ 'checked' if 'porphyry' in chosen else '' }}> Porphyry</label>
|
||||
</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>
|
||||
<button type="button" id="pdfBtn" class="ghost"
|
||||
@@ -135,7 +124,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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ustawienia{% endblock %}
|
||||
{% block nav_settings %}active{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Synastria{% endblock %}
|
||||
{% block nav_synastry %}active{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,148 @@
|
||||
"""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
|
||||
@@ -0,0 +1,53 @@
|
||||
"""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
|
||||
@@ -0,0 +1,41 @@
|
||||
"""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
|
||||
@@ -76,16 +76,45 @@ 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():
|
||||
"""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}"
|
||||
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"}
|
||||
|
||||
|
||||
# ──────────────────────── zbieranie materiału z magazynów ────────────────
|
||||
@@ -106,9 +135,10 @@ 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."""
|
||||
"""Szukamy TAGÓW skryptów, nie samych nazw — te padają też w komentarzach.
|
||||
Statyka jedzie przez helper cache-bustingu (PRE-26): src="{{ static('X') }}"."""
|
||||
def tag(name):
|
||||
return TPL.index('src="/static/' + name + '"')
|
||||
return TPL.index("static('" + name + "')")
|
||||
|
||||
assert tag("natal.js") < tag("compile.js")
|
||||
assert tag("predictions.js") < tag("compile.js")
|
||||
@@ -166,9 +196,11 @@ def test_does_nothing_on_other_tabs():
|
||||
# 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" (synchronizowane) — inaczej nie da się ich przekazać."""
|
||||
"""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 TPL, f"brak opcji w formularzu podsumowania: {f}"
|
||||
assert f in opts, f"brak opcji we wspólnym pliku: {f}"
|
||||
|
||||
|
||||
def test_compile_handler_recomputes_with_all_options():
|
||||
|
||||
@@ -50,10 +50,11 @@ def test_person_value_survives_submit(name):
|
||||
|
||||
|
||||
def test_handlers_accept_and_echo_person():
|
||||
"""Każdy handler renderujący formularz przyjmuje `person` i wkłada go
|
||||
"""Każdy handler renderujący WSPÓLNY formularz przyjmuje `person` i wkłada go
|
||||
z powrotem do `form` — inaczej imię znikałoby po przeliczeniu.
|
||||
Cztery: Horoskop, Interpretacje, Kalendarz, Skompiluj (PRE-23)."""
|
||||
assert MAIN_PY.count("person: str = Form") == 4
|
||||
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
|
||||
assert MAIN_PY.count('"person": person') == 4
|
||||
|
||||
|
||||
|
||||
@@ -5,16 +5,56 @@ 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_form_lets_you_pick_extra_systems():
|
||||
"""Checkboxy dla trzech systemów — wielokrotny wybór (name powtórzony)."""
|
||||
for val in ("whole_sign", "equal", "porphyry"):
|
||||
assert f'name="house_systems" value="{val}"' in CHART, f"brak checkboxa {val}"
|
||||
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)
|
||||
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():
|
||||
@@ -28,16 +68,89 @@ def test_client_forwards_house_systems_to_logic():
|
||||
|
||||
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 CHART
|
||||
assert "Porównanie systemów domów" in CHART
|
||||
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 CHART
|
||||
assert "hs.cusps[i].in_sign" in CHART
|
||||
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."""
|
||||
assert "in chosen" in CHART and "form.house_systems" in CHART
|
||||
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
|
||||
|
||||
@@ -66,8 +66,12 @@ 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."""
|
||||
assert 'chartwheel.render(chart, theme="print")' in MAIN
|
||||
"""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
|
||||
|
||||
|
||||
def test_print_theme_has_no_css_variables():
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""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():
|
||||
assert 'href="/settings"' in BASE and "Ustawienia" in BASE
|
||||
|
||||
|
||||
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]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""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():
|
||||
assert 'href="/synastry"' in BASE and "Synastria" in BASE
|
||||
|
||||
|
||||
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
|
||||
@@ -210,6 +210,22 @@ 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
|
||||
|
||||
@@ -307,3 +307,24 @@ 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
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# 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. Czas gwiazdowy
|
||||
i ε mają własny, osobny test (`asc/mc`).
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,146 @@
|
||||
"""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 są (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"})
|
||||
|
||||
# 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ą są 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:
|
||||
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)
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""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
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import swisseph as swe
|
||||
|
||||
from domain import 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).
|
||||
if (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:
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""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_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]
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user