Compare commits

..

2 Commits

Author SHA1 Message Date
gitea a8b2321dd7 feat(logic): dostawcy LLM i pisanie horoskopu (LOG-31)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m48s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m46s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 26s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 19s
Domyslnie model LOKALNY — prompt niesie oryginalne opisy z baz, wiec
domyslnie NIC nie opuszcza sieci. Chmura wlaczana swiadomie (LOG-32).

- app/llm/: LLMProvider (jak EphemerisEngine z LOG-24) + dwie implementacje.
  Lokalny serwer modelu (Ollama/vLLM/llama.cpp) i OpenAI mowia TYM SAMYM
  protokolem /chat/completions, wiec obsluguje je jedna klasa; Anthropic ma
  wlasny /v1/messages. Napisane na samym httpx — bez SDK openai/anthropic:
  mniej zaleznosci i pelna kontrola nad tym, co wychodzi z sieci.
- Kazdy dostawca deklaruje `leaves_lan` — interfejs MUSI jawnie mowic, czy
  tresc baz opuszcza siec; UI na tej podstawie ostrzega.
- Ponawianie z backoffem (429/5xx), timeouty, czytelne bledy zamiast stacktrace.
- Klucz wylacznie z LLM_API_KEY (sekret), nigdy w repo ani w UI.
- POST /chart/horoscope + GET /llm/health.

WAZNE: prompt jest zwracany ZAWSZE — takze gdy model padnie lub brakuje
klucza. Dzieki temu awaria dostawcy nie blokuje pracy: prompt mozna
skopiowac i uzyc recznie.

Prezentacja: wybor modelu (lokalny/Anthropic/OpenAI), przycisk „Napisz
horoskop (AI)", wynik z informacja kto go napisal, czy dane opuscily siec,
ile wskazan weszlo, oraz zastrzezenie ze to nie porada medyczna (PRE-15).

Testy: 13 nowych (transport podstawiony — zaden prawdziwy model nie wolany),
calosc 131 passed / 1 skipped. Zweryfikowane e2e na atrapie serwera modelu:
horoskop napisany, leaves_lan=false, tokeny zliczone; a przy padnietym
modelu / braku klucza / zlym dostawcy — czytelny blad i zachowany prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:47:09 +02:00
gitea 80c008f076 feat(logic): generator promptow do LLM + budzetowanie (LOG-29, LOG-30)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m52s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 1m8s
Testy / Kontrola składni wszystkich warstw (push) Successful in 1m21s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m14s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 29s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 20s
Pierwszy krok ustalonej kolejnosci: prompt z podgladem, uzyteczny od razu
BEZ integracji API (ta przyjdzie w LOG-31).

LOG-29 — app/prompt.py:
- profil natal (ekran Interpretacje) i period (ekran Kalendarz),
- prompt po polsku: zadanie -> dane horoskopu (pozycje, osie, Lots, aspekty,
  sekta, zodiak) -> wskazania z baz wg wagi -> instrukcje -> zastrzezenie,
- twarde reguly: kazda teza musi cytowac konkretny sygnifikator, zakaz
  wychodzenia poza dostarczone dane, jawne wskazanie sprzecznosci,
- deterministyczny: ten sam horoskop + budzet = ten sam prompt.

LOG-30 — redukcja do budzetu (concise/medium/extensive):
dedup -> grupowanie z licznikiem -> sortowanie wg punktacji sily (LOG-21) ->
obciecie ogona (jednostka = CALE wskazanie) -> skracanie dlugich opisow.
Statystyki zwracaja ile weszlo/pominieto i jaki byl prog — takze w tresci
promptu, zeby model wiedzial, ze widzi wybor.

POST /chart/prompt — zwraca sam prompt + statystyki, bez wolania modelu.

Prezentacja: przycisk „Generuj prompt (AI)" na obu ekranach, wybor budzetu,
pole z promptem + kopiowanie (z fallbackiem dla http bez secure context).

WAZNE (znalezione przy tescie e2e): padnieta warstwa danych zabiera tylko
wskazania — horoskop i OS CZASU zostaja, bo sa czysto obliczeniowe. Wczesniej
blad bazy gubil cala osie czasu, czyniac prognoze okresowa bezuzyteczna.
Zabezpieczone testem.

Testy: 19 nowych, calosc 118 passed / 1 skipped. Zweryfikowane e2e w
przegladarce: profil natal (3340 znakow) i period (15 zdarzen, 4728 znakow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:31:51 +02:00
116 changed files with 329 additions and 14374 deletions
-33
View File
@@ -1,33 +0,0 @@
# Kontekst builda dla obrazów budowanych z KORZENIA repo (dziś: pomocniczy obraz
# wyroczni w CI — patrz .gitea/workflows/tests.yml). Obrazy usług mają własne
# konteksty (services/<usługa>), więc ten plik ich nie dotyczy.
#
# UWAGA: docker NIE czyta .gitignore. Bez tego pliku do demona poleciałby m.in.
# lokalny wirtualenv (~344 MB) i jądra efemeryd — a runner miał już incydent
# „no space left on device".
# środowiska lokalne
.env/
.venv/
venv/
# cache Pythona i narzędzi
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.ruff_cache/
.mypy_cache/
# dane generowane/pobierane, odtwarzalne
**/.ephemeris/
**/.cache/
# historia i metadane repo
.git/
.gitea/
.claude/
# rzeczy nieużywane w obrazach
docs/
*.md
-34
View File
@@ -1,34 +0,0 @@
name: build-render
# Osobny pipeline dla uslugi render (raport PDF, PRE-24) — celowo ODDZIELONY od
# glownego build.yaml (data/logic/presentation). Obraz dzwiga TeX Live (setki MB),
# wiec budowanie go przy KAZDYM pushu do mastera spowalnialoby kazdy deploy — a to
# wlasnie ta izolacja mial usunac (patrz services/render/app/main.py, LOG-27).
# Buduje sie tylko, gdy zmienia sie sama usluga.
#
# Obraz konsumuje astrololo/render.yaml w repo `deploy`.
on:
push:
branches: [master]
paths:
- 'services/render/**'
- '.gitea/workflows/build-render.yaml'
workflow_dispatch: {} # reczne odpalenie (bootstrap pierwszego obrazu)
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
- name: Build & push render (TeX Live — pierwszy build trwa dluzej)
run: |
TAG=${GITHUB_SHA::8}
IMG=gitea.czernobog.pl/gitea/astrololo-render
# Dockerfile sprawdza obecnosc xelatex + rsvg-convert przy budowie, wiec
# build jest zarazem testem, ze obraz ma komplet narzedzi.
docker build -t $IMG:$TAG -t $IMG:latest ./services/render
docker push $IMG:$TAG
docker push $IMG:latest
echo "Zbudowano i wypchnieto: $IMG:$TAG (+ latest)"
+15 -109
View File
@@ -51,46 +51,6 @@ jobs:
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
run: pytest tests -q -rs
presentation-tests:
name: Testy warstwy prezentacji (dostęp do baz)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/presentation/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/presentation/requirements-dev.txt
# Bramka chroniąca oryginalne bazy — nietestowany kod ochronny jest gorszy
# niż jego brak, bo daje złudzenie zabezpieczenia.
- name: Testy (pytest)
working-directory: services/presentation
env:
PYTHONPATH: .
run: pytest tests -q -rs
data-tests:
name: Testy warstwy bazodanowej (ochrona baz)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/data/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/data/requirements-dev.txt
# Rekordy-pułapki (DAN-26): nietestowany kod ochronny jest gorszy niż jego
# brak, bo daje złudzenie zabezpieczenia. Ta warstwa dotąd nie miała testów.
- name: Testy (pytest)
working-directory: services/data
env:
PYTHONPATH: .
run: pytest tests -q -rs
swisseph-image:
name: Build obrazu silnika B (swisseph)
runs-on: ubuntu-latest
@@ -102,79 +62,25 @@ jobs:
- name: docker build
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
# Test biegnie WEWNĄTRZ obrazu, bez sieci i bez kontenera w tle. Poprzednia
# wersja startowała kontener w tle (--name swe) i pukała curl-em w
# localhost:8003 — co miało dwie wady:
# 1. nie sprzątała kontenera, więc każdy kolejny przebieg padał na
# konflikcie nazwy (Conflict. The container name "/swe" is already in use),
# 2. job Gitea Actions sam działa w kontenerze, a -p publikuje port na
# HOŚCIE — więc localhost joba to nie ten sam localhost.
# Wywołanie funkcji endpointów wprost omija oba problemy, a sprawdza to samo:
# obraz się zbudował, pyswisseph liczy, kontrakt /positions się zgadza.
# --rm gwarantuje, że nic nie zostaje po przebiegu.
- name: Smoke test (health + pozycje) wewnątrz obrazu
- name: Smoke test (health + pozycje)
run: |
docker run --rm astrololo/engine-swisseph:ci python - <<'PY'
from datetime import datetime, timezone
from app.main import DEFAULT_OBJECTS, PositionsRequest, health, positions
h = health()
assert h["status"] == "ok", h
print("health:", h)
docker run -d --name swe -p 8003:8003 astrololo/engine-swisseph:ci
for i in $(seq 1 30); do
curl -fsS http://localhost:8003/health >/dev/null 2>&1 && break
sleep 1
done
curl -fsS http://localhost:8003/health
echo
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
req = PositionsRequest(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
lat=50.0647, lon=19.9450)
out = positions(req)
by = {p["name"]: p for p in out["positions"]}
curl -fsS -X POST http://localhost:8003/positions \
-H 'Content-Type: application/json' \
-d '{"when_utc":"1984-04-30T09:20:00Z","lat":50.0647,"lon":19.9450}'
echo
assert out["engine"] == "swisseph", out["engine"]
assert len(by) == len(DEFAULT_OBJECTS), sorted(by)
sun = by["Sun"]["longitude"]
assert 39.5 < sun < 41.0, f"Slonce poza oczekiwanym zakresem: {sun}"
nn, sn = by["North Node"]["longitude"], by["South Node"]["longitude"]
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
print("SMOKE OK")
PY
# Zgodność naszych domów z wyrocznią (Swiss Ephemeris). Odpalamy WEWNĄTRZ
# obrazu silnika B — tylko tam jest pyswisseph — montując naszą warstwę
# logiczną i framework. Bez klastra i bez HTTP: to czysta funkcja.
# BLOKUJE build: błędny system domów jest CICHY (wykres wygląda dobrze,
# planety siedzą w złych domach), więc lepiej zatrzymać go przed wypuszczeniem
# niż wykryć po fakcie.
- name: Domy — zgodność z wyrocznią (brzegi + wnętrze)
run: |
# Pliki WBUDOWUJEMY w obraz, a nie montujemy przez -v. Powód ten sam,
# dla którego wyżej nie startujemy kontenera w tle: job Gitea Actions sam
# działa w kontenerze, więc `-v $PWD/...` docker rozwiązuje na HOŚCIE,
# gdzie tej ścieżki nie ma. Docker nie zgłasza wtedy błędu — po cichu
# tworzy PUSTY katalog, przez co skrypt „znika". Kontekst builda jest
# strumieniowany do demona, więc działa niezależnie od tego, gdzie on stoi.
docker build -t astrololo/oracle:ci -f - . <<'DOCKERFILE'
FROM astrololo/engine-swisseph:ci
COPY services/logic /logic
COPY tests/oracle /oracle
ENV LOGIC_PATH=/logic
DOCKERFILE
docker run --rm astrololo/oracle:ci python /oracle/run.py --mode build
# Obraz pomocniczy nie jest już potrzebny — a runner miał już incydent
# „no space left on device", więc sprzątamy po sobie od razu.
- name: Usuń obraz pomocniczy wyroczni
if: always()
run: docker rmi -f astrololo/oracle:ci 2>/dev/null || true
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
- name: Usuń osierocony kontener ze starych przebiegów
if: always()
run: docker rm -f swe 2>/dev/null || true
- name: Logi kontenera (gdy coś padło)
if: failure()
run: docker logs swe || true
compile-all:
name: Kontrola składni wszystkich warstw
-54
View File
@@ -1,54 +0,0 @@
services:
data:
image: gitea.czernobog.pl/gitea/astrololo-data:latest
container_name: astrololo-data
restart: unless-stopped
environment:
DATA_PROVIDER: ${DATA_PROVIDER:-excel}
EXCEL_DIR: /app/data_files
CACHE_DIR: /app/.cache
INDEXED_KEYS: name,id,symbol
volumes:
- ./services/data/data_files:/app/data_files
- data_cache:/app/.cache
ports:
- "8002:8002"
logic:
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
container_name: astrololo-logic
restart: unless-stopped
environment:
DATA_URL: http://data:8002
EPHEMERIS_ENGINE: own # silnik własny (permisywny) — domyślny
# adres silnika B (AGPL) używany tylko w trybie porównawczym (profil comparison)
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on:
- data
ports:
- "8001:8001"
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
# docker compose --profile comparison up
# Nie wchodzi do domyślnego (zamkniętego) produktu — patrz services/engine-swisseph/LICENSE.
engine-swisseph:
image: gitea.czernobog.pl/gitea/astrololo-engine-swisseph:latest
container_name: astrololo-engine-swisseph
restart: unless-stopped
profiles: ["comparison"]
ports:
- "8003:8003"
presentation:
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
container_name: astrololo-presentation
restart: unless-stopped
environment:
LOGIC_URL: http://logic:8001
depends_on:
- logic
ports:
- "8000:8000"
volumes:
data_cache:
Binary file not shown.
-58
View File
@@ -1,58 +0,0 @@
# Rekordy-pułapki (canary) — instrukcja (DAN-26)
Zabezpieczenie **detekcyjne**: nie zapobiega wyciekowi baz, ale pozwala go
**wykryć** i wskazać **z której kopii** wyciekł. Bazy są kupione i są rdzeniem
produktu — jeśli krążą gdzie indziej, chcemy to udowodnić.
Mechanizm żyje w warstwie danych (`services/data/app/canary.py`) i działa na
wyjściu z `/search`, więc pułapki **nie docierają** ani do użytkownika, ani do
promptu LLM (wymóg LOG-30) — niezależnie od dostawcy (Excel/SQL).
## Jak to działa
1. **Marker** — unikalny ciąg, który nie występuje w realnych danych, wpleciony
w kilka wiarygodnie wyglądających rekordów-pułapek w bazach (np. w polu
znaczącym: `Ve Tau ASTROLOLO-CANARY-7f3a9`).
2. **Odsiewanie** — warstwa danych wykrywa rekord z markerem i usuwa go z wyników,
zanim opuszczą usługę. Interpretacje i prompty są czyste (log `info`).
3. **Tripwire** — jeśli zapytanie **celuje wprost** w marker (ktoś enumeruje bazę,
a nie liczy horoskopu), leci `warning` — sygnał podejrzanego zachowania.
## Konfiguracja (per wdrożenie)
Zmienne środowiskowe usługi `data`:
| Zmienna | Znaczenie |
|---|---|
| `CANARY_MARKERS` | markery oddzielone przecinkami (kilka na wariant) |
| `CANARY_VARIANT` | etykieta wariantu tego wdrożenia (np. `prod-2026`, `partnerX`) |
Bez `CANARY_MARKERS` mechanizm jest **przezroczysty** (zero kosztu). Markery są
sekretem — trzymaj je jak `astrololo-auth` (poza repo GitOps), przez `secretKeyRef`.
## Rejestr wariant → kopia (traitor tracing)
Sedno atrybucji: **każda dystrybuowana kopia baz dostaje inny zestaw pułapek**,
a Ty trzymasz mapę, który wariant trafił dokąd. Gdy zobaczysz pułapkę w cudzej
kopii → sprawdzasz marker w rejestrze → wiesz, skąd wyciekła.
Rejestr trzymaj **poza kodem i repo** (arkusz/menedżer sekretów po stronie ops),
np.:
| Wariant | Markery | Wdrożenie / odbiorca | Data |
|---|---|---|---|
| `prod-2026` | `…7f3a9`, `…b12c` | produkcja czernobog | 2026-08 |
| `partnerX` | `…9de4`, `…0a1b` | kopia dla partnera X | 2026-08 |
## Wstrzyknięcie pułapek do baz (krok właściciela)
To robi właściciel na **realnych** plikach (kod tego nie robi — nie ruszamy
kupionych baz automatycznie): dodać kilka rekordów-pułapek z markerem danego
wariantu, w stylu nieodróżnialnym od prawdziwych wpisów. Kilka, wtopionych —
łatwiej, gdy ktoś zna mechanizm, wyciąć jeden oczywisty niż wszystkie.
## Granice
Canary dowodzi pochodzenia tylko, gdy wyciek **zawiera** treść pułapki (pełna
kopia — tak; parafraza — niekoniecznie). Nie wykrywa retencji *prawdziwej* treści
u dostawcy LLM — od tego jest bramka LOG-32 i wniosek o Zero Data Retention.
-195
View File
@@ -1,195 +0,0 @@
# DAN-25 — zamknięcie dostępu do baz na NFS (TrueNAS SCALE)
Bazy interpretacyjne leżą na `192.168.1.34:/mnt/Tank1/astrololo`. Dziś udział jest
osiągalny z całej sieci, więc **kto ma dostęp do LAN, bierze kompletne bazy
w oryginale — z pominięciem logowania, limitów, audytu i canary**. Żadne
zabezpieczenie w kodzie tego nie zamyka: to najkrótsza droga do wycieku.
Cel: udział `astrololo` widoczny **tylko dla trzech węzłów k3s**, **tylko do
odczytu**, z **root_squash**.
## ⚠️ Zasada nadrzędna: ruszamy WYŁĄCZNIE udział astrololo
Tank1 obsługuje cały homelab — Proxmox (`proxmox-NFS`), conjurera (`conjurer_swap`,
z **zapisem**), media, LXC-e, stację roboczą. **Nie dotykamy globalnych ustawień
usługi NFS ani innych udziałów** — inaczej wywalimy VM-y, bota i bibliotekę mediów.
Każda komenda niżej celuje w jeden konkretny udział.
Druga zasada: **w TrueNAS SCALE nie edytuje się `/etc/exports` ręcznie.** Plik
generuje middleware i nadpisze każdą ręczną zmianę. Wszystko robimy przez `midclt`
(albo GUI: *Shares → Unix (NFS) Shares*).
## Ustalone dane
| Co | Wartość |
|---|---|
| NAS | `192.168.1.34` (TrueNAS SCALE / Community Edition) |
| Udział do zamknięcia | `/mnt/Tank1/astrololo` |
| Węzły k3s (jedyni uprawnieni) | `192.168.1.73` (server), `192.168.1.80` (agent2), `192.168.1.81` (agent1) |
| Kto montuje astrololo | wyłącznie pod `data` w ns `astrololo`, **read-only** |
---
## Faza 0 — rozpoznanie (nic nie zmienia)
```bash
ssh admin@192.168.1.34
```
Wersja systemu (potwierdza, że komendy niżej pasują):
```bash
midclt call system.version
```
Lista udziałów NFS z ich obecnymi ustawieniami — **stąd bierzemy ID udziału astrololo**:
```bash
midclt call sharing.nfs.query | python3 -m json.tool
```
> W wyniku poszukaj wpisu ze ścieżką `/mnt/Tank1/astrololo` i zapamiętaj jego `id`.
> **Sprawdź też, jak nazywają się pola** (`path` vs `paths`, `hosts`, `networks`,
> `ro`, `maproot_user`, `mapall_user`) — middleware zmieniało ich nazwy między
> wersjami SCALE. Dalsze komendy używają nazw z Twojego wyniku.
Kto jest teraz podłączony (żeby nie odciąć czegoś w trakcie pracy):
```bash
ss -tn state established '( sport = :2049 )'
```
## Faza 1 — dowód dziury (zrób PRZED zmianą)
Na **stacji roboczej** (Mac mini, czyli host spoza klastra):
```bash
mkdir -p /tmp/nfs-test && sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
```
```bash
ls -la /tmp/nfs-test | head
```
Jeśli widzisz pliki baz — **to jest dokładnie problem, który zamykamy**. Odmontuj:
```bash
sudo umount /tmp/nfs-test
```
## Faza 2 — kopia obecnej konfiguracji (możliwość cofnięcia)
Na NAS-ie, podstaw `<ID>` z Fazy 0:
```bash
midclt call sharing.nfs.query '[["id","=",<ID>]]' > /root/astrololo-nfs-share.backup.json && cat /root/astrololo-nfs-share.backup.json
```
## Faza 3 — zawężenie udziału
Jedna komenda ustawia wszystkie trzy zabezpieczenia naraz: listę hostów, tylko
odczyt i root_squash. Podstaw `<ID>`:
```bash
midclt call sharing.nfs.update <ID> '{"hosts": ["192.168.1.73", "192.168.1.80", "192.168.1.81"], "ro": true, "maproot_user": null, "maproot_group": null, "mapall_user": null, "mapall_group": null}'
```
Co robi każdy element:
| Ustawienie | Znaczenie |
|---|---|
| `hosts` | eksport **tylko** dla trzech węzłów k3s — reszta LAN przestaje widzieć udział |
| `ro: true` | tylko odczyt; aplikacja i tak montuje read-only, więc niczego nie łamie |
| `maproot_*`, `mapall_*` = `null` | **root_squash**: root z klienta nie jest rootem na udziale |
Zastosuj i sprawdź, że middleware przepisał eksporty:
```bash
midclt call service.reload nfs && exportfs -v | grep -A1 astrololo
```
> Jeśli Twoja wersja nie ma `service.reload`, użyj GUI (*Shares → NFS → zapisz*),
> co wymusi to samo.
## Faza 4 — weryfikacja (wszystkie cztery testy)
**1. Spoza klastra ma NIE działać.** Na Macu:
```bash
sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
```
Oczekiwane: `access denied` / `Operation not permitted`. **Jeśli montuje się dalej —
zmiana nie zadziałała, nie idź dalej.**
**2. Z węzła klastra ma działać.**
```bash
ssh 192.168.1.73 'sudo mount -t nfs -o ro 192.168.1.34:/mnt/Tank1/astrololo /mnt/test && ls /mnt/test | head -3 && sudo umount /mnt/test'
```
**3. Aplikacja żyje.** Pody muszą wstać i realnie czytać bazy:
```bash
kubectl -n astrololo rollout restart deploy/data && kubectl -n astrololo rollout status deploy/data
```
```bash
kubectl -n astrololo exec deploy/data -- ls /app/data_files | head -3
```
**4. Reszta homelabu nietknięta** — conjurer (zapis!) i pozostałe udziały:
```bash
kubectl -n conjurer get pods
```
```bash
midclt call sharing.nfs.query | python3 -c "import sys,json;[print(s.get('path') or s.get('paths'), '| hosts:', s.get('hosts'), '| ro:', s.get('ro')) for s in json.load(sys.stdin)]"
```
Oczekiwane: **tylko** astrololo ma zawężone `hosts` i `ro: true`; reszta bez zmian.
## Faza 5 — wycofanie (gdyby coś padło)
```bash
midclt call sharing.nfs.update <ID> '{"hosts": [], "ro": false}'
```
```bash
midclt call service.reload nfs
```
To przywraca poprzedni stan (pełna kopia w `/root/astrololo-nfs-share.backup.json`).
---
## Pułapki, o których warto wiedzieć
**Hookscript Proxmoxa.** Na Proxmoxie działa `wait-truenas.sh`, który przed startem
VM czeka w pętli na `showmount -e 192.168.1.34`. Zawężamy tylko udział astrololo,
więc `showmount` nadal zwróci pozostałe eksporty i pętla przejdzie. Mimo to sprawdź
po zmianie:
```bash
ssh root@pve2 'showmount -e 192.168.1.34'
```
**Aktualizacja baz przestanie działać przez NFS.** Po `ro: true` nikt nie wgra
nowych plików baz przez ten udział — również Ty. Do wgrywania użyj GUI TrueNAS,
SMB albo SSH bezpośrednio na NAS-ie. To celowe: udział ma być drogą tylko do
czytania przez aplikację.
**`hosts` przyjmuje adresy IP, nie nazwy** — świadomie, żeby dostęp nie zależał od
DNS-u (AdGuard na `.57`). Gdyby doszedł czwarty węzeł k3s, trzeba dopisać jego IP,
inaczej pod `data` na nim nie wstanie.
**To nie jest uwierzytelnianie.** Lista IP zatrzymuje przypadkowy i oportunistyczny
dostęp, ale adres da się podszyć w tej samej sieci. Docelowo (poza zakresem tego
kroku): NFSv4 + Kerberos albo przeniesienie plików na wolumen nieosiągalny poza
klastrem — tak mówi samo wymaganie DAN-25.
## Po wykonaniu
Zaktualizuj status DAN-25 w `docs/astrololo_wymagania.xlsx` na **Zrobione** (albo
**W trakcie**, jeśli zostawiasz Kerberosa jako etap docelowy).
-65
View File
@@ -1,65 +0,0 @@
# Konta imienne i dziennik audytowy (PRE-17)
Zamiast jednego wspólnego hasła: **konta imienne**, bo przy bazach o realnej
wartości handlowej trzeba wiedzieć **kto** sięgał do treści — i móc odciąć jedną
osobę bez zmiany hasła całej reszcie.
## Zakładanie konta
Hasło podajesz interaktywnie (nie trafia do historii powłoki ani do listy procesów);
na wyjściu dostajesz **hash**, nie hasło:
```bash
cd services/presentation && python scripts/make_user.py alicja
```
Wynik wklejasz do `APP_USERS` (wpisy po przecinku):
```
APP_USERS='alicja:scrypt$…,bartek:scrypt$…'
```
Hash liczy `scrypt` ze stdlib — **bez nowych zależności**. Sekret ustawiasz jak
resztę (`kubectl create secret …`, `secretKeyRef`), nigdy w repo GitOps.
## Odebranie dostępu jednej osobie
Usuń jej wpis z `APP_USERS` i zrestartuj `presentation`. **Pozostali nie zmieniają
haseł** — to była główna bolączka wspólnego hasła.
## Uwaga: wspólne hasło przestaje działać
Gdy `APP_USERS` jest ustawione, stare `APP_PASSWORD` **nie działa** (aplikacja
zgłasza to ostrzeżeniem przy starcie). Celowo: działające obok kont wspólne hasło
byłoby tylnym wejściem bez śladu w dzienniku, czyli dokładnie problemem, który to
wymaganie zamyka. Po migracji usuń `APP_PASSWORD` z konfiguracji.
Zgodność wstecz: dopóki `APP_USERS` **nie** jest ustawione, `APP_USER`/`APP_PASSWORD`
działa jak dotąd — aktualizacja nie wywraca istniejącego wdrożenia.
## Dziennik audytowy
Każde żądanie do chronionej ścieżki zostawia wpis na stdout (w k8s zbierany
standardowo):
```
2026-08-03 21:45:44 INFO AUDYT user=alicja ip=10.1.2.3 method=POST path=/interpret status=200 records=428 ms=1530
```
| Pole | Znaczenie |
|---|---|
| `user` | kto (`-` przy nieudanym logowaniu — nie podpowiadamy, które konto istnieje) |
| `path`, `method`, `status` | co robił i z jakim skutkiem |
| `records` | **ile rekordów baz** oddaliśmy (`-` gdy żądanie nie dotyka baz) |
| `ms` | czas obsługi |
`records` jest tu najważniejsze: pojedyncze zapytanie wygląda niewinnie, ale suma
pokazuje **powolne wypompowywanie bazy** przez osobę uprawnioną — czego żadne
uwierzytelnienie nie wykryje. Liczone m.in. dla wyszukiwarki sygnifikatorów,
raportu interpretacji i **eksportu do Excela** (ten wynosi najwięcej naraz).
**W dzienniku nie ma treści** — ani rekordów, ani promptów, ani danych
urodzeniowych. Logi byłyby kolejnym nośnikiem wycieku; do wykrycia nadużycia
wystarczą metadane i liczby.
Poziom sterujesz przez `AUDIT_LEVEL` (domyślnie `INFO`).
-171
View File
@@ -1,171 +0,0 @@
# LOG-33 — sekrety w spoczynku i procedura rotacji
Sekrety (`APP_PASSWORD`/`APP_USERS`, `INTERNAL_TOKEN`, klucze łącz AES, klucze API
do dostawców LLM) trafiają do obiektów Secret w Kubernetesie, gdzie domyślnie są
**tylko zakodowane base64** — jawne dla każdego, kto przeczyta magazyn stanu k3s
albo ma prawo odczytu sekretów w namespace.
Ten dokument opisuje: **macierz rotacji** (co restartować przy zmianie czego),
**procedury rotacji per sekret** i **kroki hartowania**, które wymagają dostępu do
węzła.
---
## Macierz zależności — kto używa którego sekretu
Wyliczona z żywych deploymentów, nie z założeń:
| Sekret / klucz | Usługi, które go czytają | Restart obejmuje |
|---|---|---|
| `astrololo-auth` / **`INTERNAL_TOKEN`** | data, logic, presentation, **render** | **wszystkie cztery, równocześnie** |
| `astrololo-auth` / `APP_PASSWORD`, `APP_USERS` | presentation | tylko presentation |
| `astrololo-link` / `LINK_KEY_LOGIC_DATA` | logic, data | **para**: logic + data |
| `astrololo-link` / `LINK_KEY_PRESENTATION_LOGIC` | presentation, logic | **para**: presentation + logic |
| `astrololo-link` / `LINK_KEY_PRESENTATION_RENDER` | presentation, render | **para**: presentation + render |
| `astrololo-llm` / `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | logic | tylko logic |
> **UWAGA — częsty błąd.** Wcześniejsza wersja tego wymagania mówiła o „wszystkich
> **trzech** usługach" przy `INTERNAL_TOKEN`. To już nieprawda: `render` (PRE-24)
> również go używa. Restart trzech zostawi render ze starym tokenem i **usługa po
> cichu przestanie się dogadywać** — dokładnie ta awaria, przed którą wymaganie
> ostrzega.
Klucze łącz są **parami** — rotacja jednego wymaga restartu tylko dwóch usług, nie
całej czwórki. W trakcie wymiany para chwilowo się nie dogaduje (klucze muszą być
zgodne po obu stronach łącza), dlatego restart obu naraz.
---
## Procedury rotacji
### A. Klucze LLM (najbezpieczniejsze do przećwiczenia)
Dotykają wyłącznie logiki, a awaria jest widoczna od razu i nieszkodliwa
(niedostępna chmura, model lokalny działa dalej). **Zacznij ćwiczenie od tego.**
```bash
read -rs -p "Nowy ANTHROPIC_API_KEY: " NEW; echo
```
```bash
kubectl -n astrololo create secret generic astrololo-llm \
--from-literal=OPENAI_API_KEY="$(kubectl -n astrololo get secret astrololo-llm -o jsonpath='{.data.OPENAI_API_KEY}' | base64 -d)" \
--from-literal=ANTHROPIC_API_KEY="$NEW" \
--dry-run=client -o yaml | kubectl apply -f -
```
```bash
unset NEW && kubectl -n astrololo rollout restart deploy/logic && kubectl -n astrololo rollout status deploy/logic
```
### B. Klucz łącza (para usług)
Przykład dla `LINK_KEY_PRESENTATION_RENDER`. Pozostałe klucze zachowujemy bez zmian,
odczytując je z istniejącego sekretu — inaczej skasowalibyśmy pozostałe łącza.
```bash
kubectl -n astrololo create secret generic astrololo-link \
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
--dry-run=client -o yaml | kubectl apply -f -
```
```bash
kubectl -n astrololo rollout restart deploy/presentation deploy/render
```
```bash
kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
```
### C. `INTERNAL_TOKEN` (wszystkie cztery naraz)
Najbardziej wrażliwa rotacja: w trakcie usługi z różnymi tokenami **odrzucają się
nawzajem**, więc restart musi objąć całą czwórkę.
```bash
kubectl -n astrololo create secret generic astrololo-auth \
--from-literal=APP_PASSWORD="$(kubectl -n astrololo get secret astrololo-auth -o jsonpath='{.data.APP_PASSWORD}' | base64 -d)" \
--from-literal=INTERNAL_TOKEN="$(openssl rand -hex 32)" \
--dry-run=client -o yaml | kubectl apply -f -
```
```bash
kubectl -n astrololo rollout restart deploy/data deploy/logic deploy/presentation deploy/render
```
```bash
kubectl -n astrololo rollout status deploy/data && kubectl -n astrololo rollout status deploy/logic && kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
```
> Jeśli używasz kont imiennych (PRE-17), zamiast `APP_PASSWORD` zachowaj `APP_USERS`
> — patrz [`konta-i-audyt.md`](konta-i-audyt.md).
### Weryfikacja po KAŻDEJ rotacji
Sam `Running` nie wystarczy — pody wstaną nawet, gdy warstwy się nie dogadują.
Trzeba sprawdzić **realny przelot przez wszystkie łącza**:
```bash
kubectl -n astrololo get pods
```
```bash
curl -s -o /dev/null -w "aplikacja: %{http_code}\n" -u "<login>:<hasło>" https://astrololo.czernobog.pl/
```
Policz horoskop w przeglądarce (dotyka presentation→logic→data) i wygeneruj PDF
(dotyka presentation→render). Dopiero to potwierdza, że wszystkie cztery klucze
i token są spójne.
---
## Hartowanie — kroki wymagające dostępu do węzła
### 1. Szyfrowanie sekretów w spoczynku (najważniejsze)
k3s z jednym serwerem trzyma stan w **SQLite**, nie w etcd, więc „szyfrowanie etcd"
sprowadza się do wbudowanej funkcji k3s. Kroki są w sekcji „Co musisz zrobić sam"
poniżej. Efekt: kopia pliku stanu albo snapshot VM przestaje być wyciekiem haseł
i kluczy API.
**Granica:** klucz szyfrujący leży na tym samym serwerze. Chroni przed kradzieżą
pliku/snapshotu — nie przed kimś, kto ma roota na węźle.
### 2. Ograniczenie tokenów kont serwisowych ✅
Zrobione: `automountServiceAccountToken: false` we wszystkich czterech usługach
(deploy #13). Żadna nie rozmawia z API Kubernetesa — sekrety wstrzykuje kubelet,
nie pod — więc token był zbędny, a stanowił gotowy punkt wyjścia do klastra.
### 3. RBAC
Stan sprawdzony: **zero RoleBindings** w `astrololo`, `cluster-admin` tylko dla
`system:masters` i dwóch kont Helma w `kube-system`. Nie ma rozdanych nadmiarowych
uprawnień do cofania.
Realna ekspozycja to **kubeconfig admina**. Sensowny krok: osobny, ograniczony
kubeconfig do codziennej pracy, a admin tylko wtedy, gdy naprawdę potrzebny.
### 4. Sealed Secrets / SOPS — świadomie ODŁOŻONE
Dziś sekrety tworzone są ręcznie i **nie ma ich w repo GitOps** — czyli zasada
„nie wpisywać sekretów do repozytorium" **jest już spełniona**. Kosztem jest
odtwarzalność: po utracie klastra nikt nie wie, co tam było.
Sealed Secrets pozwoliłoby trzymać je w gicie w postaci zaszyfrowanej, ale to
zmiana filozofii i **nowy pojedynczy punkt awarii**: utrata klucza kontrolera =
utrata wszystkich sekretów. Rekomendacja: dopiero po punkcie 1, i tylko jeśli
zależy Ci na odtwarzalności klastra z gita.
---
## Czego NIE robimy
- **Nie wpisujemy sekretów do logów.** Dziennik audytowy (PRE-17) niesie wyłącznie
metadane i liczby.
- **Nie wpisujemy sekretów do repo GitOps.** Manifesty odwołują się do sekretów
przez `secretKeyRef` i celowo nie zawierają wartości.
- **Nie zostawiamy haseł w historii powłoki** — stąd `read -rs` i odczyt istniejących
wartości przez `kubectl … | base64 -d` zamiast wpisywania ich ponownie.
-296
View File
@@ -1,296 +0,0 @@
# Wdrożenie PRE-16 — HTTPS na wejściu i szyfrowanie łączy między warstwami
Instrukcja krok po kroku. **Kolejność ma znaczenie** — punkt „Dlaczego taka
kolejność" niżej tłumaczy, co się stanie, jeśli ją zamienić.
Dotyczy dwóch pull requestów:
| Repo | PR | Co wnosi |
|---|---|---|
| `gitea/astrololo` | [#21](https://gitea.czernobog.pl/gitea/astrololo/pulls/21) | kod: szyfrowanie łączy, limit żądań za proxy |
| `gitea/deploy` | [#4](https://gitea.czernobog.pl/gitea/deploy/pulls/4) | manifesty: Ingress, certyfikat, klucze łączy |
---
## Co się właściwie zmienia
**Na wejściu do aplikacji.** Dotąd logowanie szło przez HTTP Basic po zwykłym
http — czyli hasło leciało siecią w postaci trywialnej do podsłuchania (base64 to
nie szyfrowanie). Po zmianie wejście jest po https, a http odsyła na https.
Przy okazji **odblokowują się dwie funkcje zepsute dziś z tego samego powodu**:
geolokalizacja („Tu i teraz") i kopiowanie promptu do schowka działają wyłącznie
w tzw. secure context i po http po prostu odmawiały.
**Między warstwami.** Prezentacja, logika i dane rozmawiały ze sobą otwartym
tekstem wewnątrz klastra. Token międzywarstwowy mówił *kto* pyta, ale nie ukrywał
*czego dotyczy odpowiedź* — a płyną nią surowe wiersze oryginalnych baz. Teraz
każde ciało żądania i odpowiedzi jest szyfrowane **AES-256-GCM**, osobnym kluczem
na każdą parę rozmówców.
**Wejście na świat pozostaje jedno: prompt do modelu.** Ta zmiana niczego tu nie
rusza — dotyczy wyłącznie ruchu wewnątrz sieci i wejścia z przeglądarki.
---
## Zanim zaczniesz — stan wyjściowy
```bash
kubectl -n astrololo get deploy,svc
kubectl -n astrololo get secret # powinny być: astrololo-auth, gitea-registry
kubectl -n kube-system get svc traefik -o jsonpath='{.status.loadBalancer.ingress[*].ip}'; echo
```
Zanotuj adres Traefika — będzie potrzebny w kroku 3. Sprawdź też, czy działa
aplikacja w obecnej postaci (przez NodePort), żeby mieć punkt odniesienia.
---
## Krok 1 — sekret z kluczami łączy
**Przed czymkolwiek innym.** Klucze muszą istnieć, zanim pody spróbują wstać
z nową konfiguracją, bo bez nich celowo **nie wystartują**.
```bash
kubectl -n astrololo create secret generic astrololo-link \
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(openssl rand -hex 32)" \
--from-literal=LINK_KEY_LOGIC_DATA="$(openssl rand -hex 32)"
```
Kluczy nikt nigdy nie musi oglądać — służą tylko usługom. Nie ma ich w repo
GitOps i **nie ma ich tam wkładać**: cokolwiek trafi do gita, zostaje w historii
na zawsze.
Dwa osobne klucze to nie ozdobnik. Przejęcie klucza prezentacji nie daje dostępu
do warstwy danych, gdzie leżą całe bazy. Logika dostaje oba, bo rozmawia w obie
strony; prezentacja i dane dostają wyłącznie swój.
Sprawdź:
```bash
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
# oczekiwane: dwa klucze, każdy 64 znaki po odkodowaniu (32 bajty)
```
---
## Krok 2 — cert-manager
Jednorazowo, na cały klaster:
```bash
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml
kubectl -n cert-manager rollout status deploy/cert-manager deploy/cert-manager-webhook --timeout=180s
```
Poczekaj, aż **webhook** będzie gotowy — dopóki nie wstanie, tworzenie obiektów
`Certificate` kończy się błędem połączenia i wygląda jak zepsuty manifest.
Sprawdź:
```bash
kubectl get crd | grep cert-manager | head -3 # muszą się pojawić
```
> **Dlaczego własne CA, a nie Let's Encrypt.** Klaster stoi w LAN (Traefik trzyma
> LoadBalancera na adresach 192.168.1.x), więc walidacja HTTP-01 nie ma jak dojść
> z internetu, a DNS-01 wymagałby trzymania w klastrze tokena API do domeny.
> Własne CA nie potrzebuje niczego z zewnątrz i odnawia certyfikaty samo. Cena:
> raz na urządzenie importujesz korzeń (krok 6).
---
## Krok 3 — DNS
Wpis `astrololo.czernobog.pl` → adres Traefika z kroku „stan wyjściowy”.
W routerze, lokalnym DNS-ie albo doraźnie w `/etc/hosts`:
```bash
echo "192.168.1.73 astrololo.czernobog.pl" | sudo tee -a /etc/hosts
```
**To nie jest krok opcjonalny.** Service `presentation` przestaje być NodePortem
(był drugą, nieszyfrowaną drogą do aplikacji — czyli obejściem całego PRE-16),
więc po wdrożeniu manifestów nazwa jest jedynym wejściem. Awaryjnie zawsze zostaje:
```bash
kubectl -n astrololo port-forward svc/presentation 8000:8000 # http://localhost:8000
```
---
## Krok 4 — merge PR-a aplikacji (astrololo #21)
Teraz, **przed** manifestami.
```bash
tea pr merge --login gitea --repo gitea/astrololo 21
```
Po merge'u CI zbuduje obrazy, a image-updater sam podbije tagi w repo `deploy`,
skąd ArgoCD wymieni pody. Poczekaj, aż to się przetoczy:
```bash
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
kubectl -n astrololo get pods -o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}'
```
Na tym etapie **nic się jeszcze nie szyfruje** — nowy kod to potrafi, ale zmienne
z kluczami dokłada dopiero PR do `deploy`. Aplikacja działa dokładnie jak dotąd.
To celowe: chcemy, żeby *cała* obsada podów umiała szyfrować, zanim ktokolwiek
tego zażąda.
---
## Krok 5 — merge PR-a manifestów (deploy #4)
```bash
tea pr merge --login gitea --repo gitea/deploy 4
```
ArgoCD zsynchronizuje się sam (`automated`, `selfHeal`). Wjeżdża naraz: Ingress,
certyfikat, zmienne z kluczami, `TRUST_PROXY` i zdjęcie NodePortu.
```bash
kubectl -n argocd get application astrololo
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
kubectl -n astrololo get certificate # astrololo-ca i astrololo-tls: READY=True
```
> **Spodziewaj się kilkudziesięciu sekund błędów w trakcie.** Pody wymieniają się
> po kolei, więc przez chwilę stara prezentacja (jeszcze bez klucza) rozmawia
> z nową logiką (już z kluczem) i dostaje odmowę. To zamierzone: alternatywą byłby
> tryb „przyjmuj i szyfrowane, i jawne”, który zwykle zostaje włączony na zawsze.
Merge nie cofnie tagów obrazów — PR dotyka w `kustomization.yaml` wyłącznie listy
`resources`, nie bloku `images`, więc git złoży to z nowszymi tagami z mastera.
---
## Krok 6 — zaufanie do własnego CA (raz na urządzenie)
Bez tego przeglądarka pokaże ostrzeżenie o certyfikacie. Korzeń jest ważny 10 lat,
więc robisz to raz:
```bash
kubectl -n astrololo get secret astrololo-ca -o jsonpath='{.data.ca\.crt}' \
| base64 -d > astrololo-ca.crt
# macOS — do systemowego zaufania (poprosi o hasło administratora)
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain astrololo-ca.crt
# Linux (Debian/Ubuntu)
sudo cp astrololo-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
```
Firefox ma **własny** magazyn certyfikatów — import przez *Ustawienia →
Prywatność i bezpieczeństwo → Wyświetl certyfikaty → Organy certyfikacji*.
---
## Krok 7 — sprawdzenie, że działa to, co miało zadziałać
### Wejście po https
```bash
curl -sI http://astrololo.czernobog.pl/ | head -2 # 301 → https
curl -s -o /dev/null -w "bez hasła: %{http_code}\n" https://astrololo.czernobog.pl/
curl -s -o /dev/null -w "z hasłem: %{http_code}\n" -u astrololo:'<hasło>' https://astrololo.czernobog.pl/
curl -sI -u astrololo:'<hasło>' https://astrololo.czernobog.pl/ | grep -i strict-transport
```
Oczekiwane: **301**, **401**, **200**, nagłówek HSTS obecny. Brak ostrzeżenia
o certyfikacie w przeglądarce oznacza, że krok 6 się udał.
### W przeglądarce
Kliknij **„Tu i teraz"** — powinno pobrać lokalizację (po http odmawiało).
Wygeneruj prompt i kliknij **kopiuj** — schowek powinien zadziałać bez obejść.
### Szyfrowanie łączy — sprawdzenie wprost
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **bez klucza**:
```bash
kubectl -n astrololo exec deploy/presentation -- \
python -c "
import httpx, os
r = httpx.post('http://logic:8001/chart/report',
json={'when_utc':'1984-04-30T09:20:00+00:00','lat':50.06,'lon':19.94},
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
print(r.status_code, r.text[:120])
"
```
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Zwróć uwagę, że
żądanie miało **prawidłowy token** — sam token już nie wystarcza, i o to chodziło.
To samo w dół, do warstwy danych:
```bash
kubectl -n astrololo exec deploy/logic -- \
python -c "
import httpx, os
r = httpx.post('http://data:8002/search',
json={'key':'significator','value':'[Sat','exact':False,'limit':5},
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
print(r.status_code, r.text[:120])
"
```
### Logi startowe
```bash
kubectl -n astrololo logs deploy/logic | grep -i "łącze\|UWAGA"
```
Powinno być `łącze szyfrowane (AES-256-GCM…)`. Jeśli widzisz ostrzeżenie
o rozmowie **jawnym tekstem** — klucz nie doszedł do poda.
---
## Dlaczego taka kolejność
| Kolejność | Skutek zamiany |
|---|---|
| Sekret **przed** manifestami | `LINK_ENCRYPTION_REQUIRED=true` bez klucza celowo wywraca start. Pody wpadną w CrashLoop i będą tak siedzieć do czasu utworzenia sekretu. |
| cert-manager **przed** manifestami | API odrzuci `Certificate`/`Issuer` jako nieznane rodzaje zasobów, ArgoCD pokaże aplikację jako niezsynchronizowaną i sam tego nie naprawi. |
| DNS **przed** manifestami | NodePort znika razem z nimi. Bez wpisu DNS zostaje tylko `port-forward`. |
| Aplikacja **przed** manifestami | Odwrotnie: manifesty włączyłyby szyfrowanie na obrazach, które go nie znają — wszystkie żądania kończyłyby się odmową do czasu przebudowy obrazów. |
Fail-closed w obie strony jest zamierzony. Usługa, która wstała i **po cichu nie
szyfruje**, jest gorsza niż pod w CrashLoop — awarii nie widać, a bazy jadą
otwartym tekstem.
---
## Wycofanie
Manifestów: `git revert` merge'a w `deploy` — ArgoCD samo wróci do NodePortu
i ruchu bez szyfrowania. Kod aplikacji **nie wymaga wycofania**: bez zmiennych
`LINK_KEY_*` moduł przepuszcza ruch jak dotąd (i głośno o tym mówi w logach).
Certyfikat i CA zostają w namespace; usunięcie: `kubectl -n astrololo delete
certificate astrololo-ca astrololo-tls`. cert-managera można zostawić — nie
przeszkadza.
---
## Gdy coś nie gra
| Objaw | Przyczyna | Co zrobić |
|---|---|---|
| Pody w `CrashLoopBackOff`, w logach `LINK_ENCRYPTION_REQUIRED … nie ustawiony` | brak sekretu `astrololo-link` | krok 1, potem `rollout restart` |
| `400 Łącze międzywarstwowe wymaga szyfrowania` przy normalnym korzystaniu | jedna warstwa ma klucz, druga nie (albo trwa rollout) | `rollout status`; sprawdź, czy wszystkie trzy pody mają zmienną |
| `400 Nie udało się odczytać zaszyfrowanego żądania` | klucze po obu stronach łącza są **różne** | wymień sekret i zrestartuj **wszystkie trzy** naraz |
| `Certificate` stoi w `READY=False` | webhook cert-managera jeszcze nie wstał | `kubectl -n cert-manager get pods`, poczekaj i sprawdź `kubectl -n astrololo describe certificate astrololo-tls` |
| Przeglądarka: „połączenie nie jest prywatne” | korzeń CA nieimportowany na tym urządzeniu | krok 6 (pamiętaj, że Firefox ma osobny magazyn) |
| `404` z Traefika pod adresem aplikacji | DNS wskazuje gdzie indziej niż LoadBalancer Traefika | porównaj `dig +short astrololo.czernobog.pl` z adresem z kroku „stan wyjściowy” |
| Limit żądań odcina wszystkich naraz | brak `TRUST_PROXY=true` — cały ruch liczony jako jeden klient | sprawdź zmienną w `deploy/presentation` |
---
## Czego to nie załatwia
- **Szyfrowane są ciała żądań, nie nagłówki.** Ścieżka (`/search`) i token
międzywarstwowy jadą czytelnie. Sam token nikomu nic nie daje — bez klucza łącza
każde żądanie kończy się odmową — ale metadanych to nie ukrywa. Pełne ukrycie
wymagałoby mTLS.
- **Własne CA to nie publiczne zaufanie.** Każde nowe urządzenie wymaga importu
korzenia. Gdyby aplikacja miała kiedyś wyjść na świat, właściwą drogą jest
Let's Encrypt przez DNS-01.
- **NFS z plikami baz** stoi obok aplikacji — kto ma dostęp do share'u, bierze
pliki z pominięciem wszystkich powyższych zabezpieczeń. Do zamknięcia po stronie
infrastruktury (eksport tylko dla IP węzłów, `root_squash`, najlepiej read-only).
- **Sekrety w etcd** są tylko zakodowane base64. Docelowo: szyfrowanie etcd
at-rest albo Sealed Secrets / SOPS.
-139
View File
@@ -1,139 +0,0 @@
# Wdrożenie usługi `render` — raport PDF (PRE-24)
Instrukcja krok po kroku dla nowego komponentu. Dotyczy dwóch repozytoriów:
| Repo | Co wnosi |
|---|---|
| `gitea/astrololo` | usługa `services/render`, klient w prezentacji, przycisk „Pobierz PDF", wariant kosmogramu do druku |
| `gitea/deploy` | `astrololo/render.yaml`, adres i klucz łącza w `presentation.yaml` |
---
## Dlaczego osobna usługa
TeX Live waży setki megabajtów. W obrazie prezentacji spowalniałby każdy build
i deploy, a przy każdej poprawce w CSS trzeba by go ciągnąć od nowa. Osobno:
obraz produktu zostaje mały, TeX aktualizuje się niezależnie, a **awaria renderu
nie kładzie aplikacji** — przestaje działać wyłącznie przycisk „Pobierz PDF".
To ta sama zasada, co przy izolacji silnika swisseph (LOG-27).
---
## Krok 1 — klucz łącza (PRZED wdrożeniem)
Usługa dostaje **cały raport**: dane urodzeniowe i opisy z baz. Łącze jest
szyfrowane AES-256-GCM, własnym, **trzecim** kluczem — przejęcie go nie może
otwierać łącza do logiki ani do danych.
Sekret `astrololo-link` już istnieje (PRE-16); dokładamy do niego trzeci klucz,
**zachowując dwa dotychczasowe**:
```bash
kubectl -n astrololo create secret generic astrololo-link \
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link \
-o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link \
-o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
--dry-run=client -o yaml | kubectl apply -f -
```
Sprawdzenie — mają być **trzy** klucze:
```bash
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
```
> **Uwaga:** `LINK_ENCRYPTION_REQUIRED=true` działa fail-closed. Bez klucza pod
> `render` **nie wstanie** — i tak ma być. Usługa, która wstała i po cichu nie
> szyfruje, jest gorsza niż CrashLoop, bo awarii nie widać.
---
## Krok 2 — obraz usługi
Obraz budowany jest osobnym workflow (jak silnik swisseph — nie wchodzi do
głównego pipeline'u produktu). Pierwszy build trwa dłużej, bo ciągnie TeX Live.
```bash
kubectl -n astrololo get deploy render -o jsonpath='{.spec.template.spec.containers[0].image}'; echo
```
Pakiety w obrazie dobrane **wąsko** (nie `texlive-full`, który ma kilka GB):
| Pakiet | Po co |
|---|---|
| `texlive-xetex` | silnik **XeLaTeX** — konieczny, bo raport ma polskie znaki i glify astrologiczne; `pdflatex` ich nie złoży |
| `texlive-latex-recommended` | `geometry`, `graphicx` |
| `fonts-dejavu-core` | jeden font na polskie znaki **i** symbole (♄ ♓ ☉) |
| `librsvg2-bin` | `rsvg-convert` — SVG kosmogramu → PDF (LaTeX nie wstawia SVG wprost) |
Dockerfile sprawdza obecność obu narzędzi **przy budowie**, więc zepsuty obraz
nie dojedzie na produkcję niezauważony.
---
## Krok 3 — merge manifestów
```bash
tea pr merge --login gitea --repo gitea/deploy <numer>
kubectl -n astrololo rollout status deploy/render deploy/presentation
```
Wjeżdża naraz: `Deployment` + `Service` renderu (ClusterIP — **bez** Ingressu
i NodePortu, nie ma powodu sięgać do niej z zewnątrz) oraz `RENDER_URL`
i trzeci klucz w prezentacji.
---
## Krok 4 — sprawdzenie
### Narzędzia są na miejscu
`/health` raportuje obecność `xelatex` i `rsvg-convert`, żeby zepsuty obraz
było widać od razu, a nie dopiero przy pierwszym raporcie:
```bash
kubectl -n astrololo exec deploy/presentation -- \
python -c "import httpx; print(httpx.get('http://render:8004/health').text)"
```
Oczekiwane: `{"status":"ok","layer":"render","tools":{"xelatex":true,"rsvg-convert":true}}`.
Gdy `status` to `degraded` — obraz zbudował się bez któregoś narzędzia.
### Łącze faktycznie szyfruje
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **z poprawnym tokenem,
ale bez szyfrowania**:
```bash
kubectl -n astrololo exec deploy/presentation -- python -c "
import httpx, os
r = httpx.post('http://render:8004/pdf', json={'person':'test'},
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
print(r.status_code, r.text[:80])
"
```
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Sam token już
nie wystarcza — o to chodziło.
### Raport od końca do końca
W przeglądarce: **Skompiluj** → wypełnij dane → *Złóż podsumowanie**Pobierz PDF*.
PDF ma zacząć się od imienia i nazwiska, potem wprowadzone dane, **rysunek
kosmogramu**, a po nim interpretacja natalna i predykcje okresowe.
---
## Czego ta wersja nie załatwia
- **Kompilacja PDF nie została sprawdzona end-to-end** w środowisku, w którym
powstawała — nie było tam ani TeX Live, ani runtime'u kontenerów. Sprawdzone
jest wszystko dookoła: generowanie źródła `.tex` (w tym ucieczka znaków
specjalnych), szyfrowanie łącza, kontrakt API i samowystarczalność SVG.
**Pierwsze uruchomienie na klastrze trzeba obejrzeć.**
- **Dzielenie wyrazów** jest angielskie — nie wciągamy `polyglossia`, żeby nie
puchł obraz. Tekst składa się poprawnie, tylko przenoszenie bywa nieoptymalne.
- **Brak kolejki**. Długi raport blokuje jedno połączenie na czas kompilacji
(timeout klienta: 180 s). Przy większym ruchu warto dołożyć zadania w tle.
- **Glify w tekście od modelu**. Kosmogram idzie jako obraz (rysuje go
`rsvg-convert` z DejaVu), ale gdyby model wplótł symbole w prozę, złoży je
XeLaTeX — też z DejaVu. Jeśli któregoś zabraknie, LaTeX zgłasza
„Missing character" w logu i **nie drukuje znaku**; warto zerknąć w log
pierwszego raportu.
-66
View File
@@ -1,66 +0,0 @@
"""Przegląd baz na udziale i ich globalne włączanie/wyłączanie (DAN-15 / PRE-09).
Wymaganie przedefiniowane pod model serwerowy: pliki baz leżą na stałym NFS, więc
nie wybiera się folderu — potrzeba za to WIDZIEĆ, jakie bazy są dostępne (nazwa +
metaopis) i móc globalnie zdecydować, które biorą udział w interpretacji.
Stan przełączników trzymamy DEKLARATYWNIE w zmiennej `DISABLED_BASES`, a nie w
pliku, bo warstwa danych nie ma gdzie trwale zapisywać: udział z bazami jest
montowany read-only, a katalog cache to `emptyDir` (ginie przy restarcie poda).
Zapis do pliku po cichu wracałby więc do stanu sprzed restartu — a ciche
przywrócenie wyłączonej bazy jest gorsze niż konieczność edycji konfiguracji.
Dopasowanie jest tolerancyjne: wpis pasuje po nazwie pliku ALBO po ścieżce
względnej — żeby dało się wyłączyć zarówno „stara_baza.xlsx", jak i
„archiwum/stara_baza.xlsx".
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
from pathlib import Path
def disabled_entries() -> list[str]:
return [e.strip() for e in os.getenv("DISABLED_BASES", "").split(",") if e.strip()]
def _relative(path: Path, root: Path) -> str:
try:
return str(path.relative_to(root))
except ValueError:
return path.name
def is_enabled(path: str | Path, root: str | Path, entries: list[str] | None = None) -> bool:
"""Czy baza bierze udział w wyszukiwaniu interpretacji."""
entries = disabled_entries() if entries is None else entries
if not entries:
return True
p = Path(path)
rel = _relative(p, Path(root))
return not any(e == p.name or e == rel for e in entries)
def list_bases(root: str | Path, paths: list[str]) -> list[dict]:
"""Bazy dostępne na udziale + metaopis. Celowo TANI opis (dane z systemu
plików): przy setkach plików liczenie rekordów oznaczałoby wczytanie każdego."""
root = Path(root)
entries = disabled_entries()
out: list[dict] = []
for raw in paths:
p = Path(raw)
try:
st = p.stat()
size_mb, modified = round(st.st_size / (1024 * 1024), 2), st.st_mtime
except OSError:
size_mb, modified = None, None
out.append({
"name": p.name,
"path": _relative(p, root),
"size_mb": size_mb,
"modified": (datetime.fromtimestamp(modified, tz=timezone.utc).strftime("%Y-%m-%d")
if modified else None),
"enabled": is_enabled(p, root, entries),
})
return out
-70
View File
@@ -1,70 +0,0 @@
"""Rekordy-pułapki (canary) — wykrywanie wycieku baz (DAN-26).
Zabezpieczenie DETEKCYJNE, nie prewencyjne. Kilka unikalnych, wiarygodnie
wyglądających rekordów wplecionych w bazy: nie zmieniają interpretacji (są
ODSIEWANE z wyników, więc nie trafiają ani do użytkownika, ani do promptu LLM —
wymóg LOG-30), ale jeśli kiedyś pojawią się w cudzej kopii, są dowodem pochodzenia.
Pułapkę rozpoznajemy po MARKERZE: unikalny ciąg, który nie występuje w realnych
danych (wpleciony np. w pole znaczące). Markery i WARIANT tego wdrożenia biorą się
z konfiguracji (ENV `CANARY_MARKERS`, `CANARY_VARIANT`) — każda kopia może dostać
swój zestaw. Który wariant trafił do którego wdrożenia trzyma rejestr po stronie
ops (osobno, poza kodem — patrz docs/canary-registry.md).
Dwa sygnały:
* ODSIEWANIE — pułapka w wynikach znika, zanim opuści warstwę danych (log info);
* TRIPWIRE — zapytanie CELUJE wprost w marker (ktoś enumeruje bazę, a nie liczy
realny horoskop) → log warning, bo to podejrzane zachowanie.
"""
from __future__ import annotations
import logging
import os
log = logging.getLogger("astrololo.data.canary")
def _load_markers() -> list[str]:
return [m.strip() for m in os.getenv("CANARY_MARKERS", "").split(",") if m.strip()]
MARKERS = _load_markers()
VARIANT = os.getenv("CANARY_VARIANT", "")
def _row_is_canary(row: dict, markers: list[str]) -> bool:
"""Czy KTÓRAKOLWIEK tekstowa wartość wiersza zawiera marker pułapki."""
for v in row.values():
if isinstance(v, str):
for m in markers:
if m in v:
return True
return False
def screen(
rows: list[dict], query_value: str = "",
markers: list[str] | None = None, variant: str | None = None,
) -> tuple[list[dict], dict]:
"""Zwraca (widoczne_wiersze, raport).
Odsiewa pułapki z wyników (nie opuszczą warstwy danych). Gdy zapytanie celuje
wprost w marker — podnosi TRIPWIRE (możliwa enumeracja bazy). Bez skonfigurowanych
markerów: przezroczyste (`active=False`), zero kosztu dla normalnego ruchu.
"""
markers = MARKERS if markers is None else markers
variant = VARIANT if variant is None else variant
if not markers:
return rows, {"active": False, "removed": 0, "tripwire": False}
visible = [r for r in rows if not _row_is_canary(r, markers)]
removed = len(rows) - len(visible)
tripwire = any(m in (query_value or "") for m in markers)
if tripwire:
log.warning(
"CANARY TRIPWIRE: zapytanie celuje wprost w rekord-pułapkę (wariant %s) "
"— możliwa enumeracja bazy", variant or "?")
elif removed:
log.info("Odsiano %d rekord(ów)-pułapek z wyników (wariant %s)", removed, variant or "?")
return visible, {"active": True, "removed": removed, "tripwire": tripwire}
-525
View File
@@ -1,525 +0,0 @@
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
zrzut z węzła), miał je w całości.
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
podatne na modyfikację".
**Dwa niezależne klucze**, po jednym na parę rozmówców:
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
losowej jednorazówki w jedną stronę nie osłabia drugiej.
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import struct
import time
from typing import Iterable, Iterator
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
log = logging.getLogger("astrololo.link")
MAGIC = b"AL1"
VERSION = "v1"
NONCE_BYTES = 12
KEY_BYTES = 32 # AES-256
LENGTH_PREFIX = 4
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
MAX_SKEW_SECONDS = 300.0
HEADER_ENC = "X-Astrololo-Enc"
HEADER_TS = "X-Astrololo-Enc-Ts"
CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
# jej klucza nie może otwierać łącza do logiki ani do danych.
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res"
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
PUBLIC_PATHS = frozenset({"/health"})
class LinkError(Exception):
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
# --------------------------------------------------------------------- klucze
def parse_key(raw: str) -> bytes:
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
text = raw.strip()
if not text:
raise LinkError("pusty klucz łącza")
try:
key = bytes.fromhex(text)
except ValueError:
try:
key = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError) as exc:
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
if len(key) != KEY_BYTES:
raise LinkError(
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
f"`openssl rand -hex 32`"
)
return key
def key_from_env(env_name: str) -> bytes | None:
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
raw = os.getenv(env_name, "")
return parse_key(raw) if raw.strip() else None
def encryption_required() -> bool:
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
"""
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
def _subkey(link_key: bytes, direction: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
info=b"astrololo/link/" + direction,
).derive(link_key)
class Link:
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
def __init__(self, link_key: bytes) -> None:
self._by_direction = {
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
}
# ---------------------------------------------------------- pojedyncza ramka
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
return b"|".join([MAGIC, direction, path.encode("utf-8"),
stamp.encode("ascii"), str(seq).encode("ascii")])
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
plaintext: bytes) -> bytes:
nonce = os.urandom(NONCE_BYTES)
sealed = self._by_direction[direction].encrypt(
nonce, plaintext, self._aad(direction, path, stamp, seq))
return MAGIC + nonce + sealed
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
try:
return self._by_direction[direction].decrypt(
nonce, sealed, self._aad(direction, path, stamp, seq))
except InvalidTag as exc:
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
# ------------------------------------------------------------ strumień ramek
def seal_stream(self, direction: bytes, path: str, stamp: str,
chunks: Iterable[bytes]) -> Iterator[bytes]:
for seq, chunk in enumerate(chunks):
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
def open_stream(self, direction: bytes, path: str, stamp: str,
raw: bytes) -> Iterator[bytes]:
for seq, frame in enumerate(frames_in(raw)):
yield self.open(direction, path, stamp, seq, frame)
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
return b"".join(self.open_stream(direction, path, stamp, raw))
# ---------------------------------------------------------------- ramkowanie
def frame_out(payload: bytes) -> bytes:
return struct.pack(">I", len(payload)) + payload
def frames_in(raw: bytes) -> Iterator[bytes]:
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
offset = 0
while offset < len(raw):
if offset + LENGTH_PREFIX > len(raw):
raise LinkError("urwana ramka (brak nagłówka długości)")
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
offset += LENGTH_PREFIX
if offset + size > len(raw):
raise LinkError("urwana ramka (za mało danych)")
yield raw[offset:offset + size]
offset += size
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
rozjechać się między dwa odczyty.
"""
while True:
if len(buffer) < LENGTH_PREFIX:
return
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
if len(buffer) < LENGTH_PREFIX + size:
return
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
del buffer[:LENGTH_PREFIX + size]
yield frame
# ------------------------------------------------------------- świeżość ruchu
def stamp_now() -> str:
return f"{time.time():.3f}"
def check_stamp(stamp: str) -> None:
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
odtworzyć w dowolnym momencie w przyszłości."""
try:
sent = float(stamp)
except (TypeError, ValueError) as exc:
raise LinkError("brak albo błędny znacznik czasu") from exc
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
# =========================================================== strona serwerowa
class LinkCryptoMiddleware:
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
"""
def __init__(self, app, link: Link | None, layer: str) -> None:
self.app = app
self.link = link
self.layer = layer
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
return await self.app(scope, receive, send)
path = scope["path"]
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
if headers.get(HEADER_ENC.lower()) != VERSION:
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
# chcemy po cichu wrócić do jawnego ruchu.
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
self.layer, path)
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
stamp = headers.get(HEADER_TS.lower(), "")
try:
check_stamp(stamp)
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
except LinkError as exc:
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
scope = dict(scope)
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
def _sealing_send(self, send, path: str):
state: dict = {"stamp": "", "seq": 0}
async def sealing(message):
if message["type"] == "http.response.start":
state["stamp"] = stamp_now()
keep = [(k, v) for k, v in message.get("headers", [])
if k.lower() not in (b"content-length", b"content-type")]
message = dict(message)
message["headers"] = keep + [
(b"content-type", CONTENT_TYPE.encode()),
(HEADER_ENC.lower().encode(), VERSION.encode()),
(HEADER_TS.lower().encode(), state["stamp"].encode()),
]
return await send(message)
if message["type"] == "http.response.body":
chunk = message.get("body", b"")
sealed = b""
if chunk:
sealed = frame_out(self.link.seal(
RESPONSE, path, state["stamp"], state["seq"], chunk))
state["seq"] += 1
return await send({"type": "http.response.body", "body": sealed,
"more_body": message.get("more_body", False)})
return await send(message)
return sealing
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
kept.append((b"content-length", str(length).encode()))
if length:
kept.append((b"content-type", b"application/json"))
return kept
async def _read_body(receive) -> bytes:
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.disconnect":
raise LinkError("rozłączenie w trakcie odbioru żądania")
body += message.get("body", b"")
if not message.get("more_body", False):
return bytes(body)
def _replay(body: bytes, original):
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
"""
delivered = False
async def receive():
nonlocal delivered
if delivered:
return await original()
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return receive
async def _refuse(send, detail: str) -> None:
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
await send({"type": "http.response.start", "status": 400, "headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode()),
]})
await send({"type": "http.response.body", "body": payload})
def install(app, env_name: str, layer: str):
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
link_key = key_from_env(env_name)
if link_key is None and encryption_required():
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
f"warstwa {layer} nie wystartuje bez klucza łącza"
)
if link_key is None:
log.warning(
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
"ruch wewnątrz sieci.", env_name, layer,
)
return None
link = Link(link_key)
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
return link
# ============================================================ strona kliencka
def call(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
się niejasnym błędem odszyfrowania.
"""
import json as _json
import httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
# sygnifikatory, o które pytamy bazę.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
)
response = client.request(method, url, json=payload, headers=request_headers)
response.raise_for_status()
return response.content
path = httpx.URL(url).path
stamp = stamp_now()
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
"Content-Type": CONTENT_TYPE})
response = client.request(method, url, content=body, headers=request_headers)
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
log.error("łącze %s odmówiło: %s", path, response.text[:200])
response.raise_for_status()
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
reply_stamp = response.headers.get(HEADER_TS, "")
check_stamp(reply_stamp)
return link.open_all(RESPONSE, path, reply_stamp, response.content)
def call_json(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None):
import json as _json
return _json.loads(call(client, method, url, payload=payload,
headers=headers, link=link))
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
w buforze zamiast zakładać, że każdy kawałek to komplet.
"""
if link is None:
yield from response.iter_bytes()
return
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
stamp = response.headers.get(HEADER_TS, "")
check_stamp(stamp)
path = response.request.url.path
buffer = bytearray()
seq = 0
for chunk in response.iter_bytes():
buffer += chunk
for frame in unframe_incremental(buffer):
yield link.open(RESPONSE, path, stamp, seq, frame)
seq += 1
if buffer:
raise LinkError("strumień urwał się w połowie ramki")
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
link: Link | None) -> Iterator[str]:
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
dopiero na znakach nowej linii.
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
działał bez zmian.
"""
import json as _json
import httpx as _httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
)
with client.stream("POST", url, json=payload, headers=request_headers) as response:
response.raise_for_status()
for text_line in response.iter_lines():
if text_line:
yield text_line
return
path = _httpx.URL(url).path
stamp = stamp_now()
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
with client.stream("POST", url, content=body, headers=request_headers) as response:
response.raise_for_status()
buffer = bytearray()
for plain in open_response_stream(response, link):
buffer += plain
while True:
nl = buffer.find(b"\n")
if nl < 0:
break
text_line = bytes(buffer[:nl])
del buffer[:nl + 1]
if text_line:
yield text_line.decode("utf-8")
if buffer:
yield bytes(buffer).decode("utf-8")
+1 -24
View File
@@ -4,14 +4,12 @@ Jedyne zadanie: przyjąć znormalizowane zapytanie z warstwy logicznej, wyszuka
dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
ani prezentacji.
"""
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app import canary, link_crypto, security
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.factory import build_provider
@@ -26,32 +24,11 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
security.install(app, "danych") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych")
@app.post("/search", response_model=SearchResult)
def search(query: SearchQuery) -> SearchResult:
result = provider.search(query)
# Rekordy-pułapki (DAN-26) odsiewamy TU, na wyjściu z warstwy danych — dzięki
# temu nie dotrą ani wyżej, ani do promptu LLM (LOG-30), niezależnie od dostawcy.
visible, report = canary.screen(result.rows, query.value)
if report["active"]:
result.rows = visible
result.total = len(visible)
return result
@app.get("/bases")
def bases() -> dict:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
Same METADANE — nazwy plików, rozmiar, data. Żadnej treści baz, więc podgląd
listy nie jest kolejną drogą do ich wyniesienia."""
items = provider.list_bases()
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
return provider.search(query)
@app.get("/health", response_model=HealthInfo)
+1 -4
View File
@@ -18,10 +18,7 @@ class SearchQuery(BaseModel):
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
value: str = Field(..., description="Szukana wartość.")
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
# Górny limit celowo niski: to zapytanie oddaje SUROWE wiersze baz, więc wysoki
# pułap zamienia je w narzędzie do masowego pobrania (LOG-32). 5000 = tyle, ile
# realnie potrzebuje build_report na jeden obiekt.
limit: int = Field(50, ge=1, le=5000)
limit: int = Field(50, ge=1, le=50000)
fields: list[str] | None = Field(
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
)
-7
View File
@@ -25,10 +25,3 @@ class DataProvider(ABC):
def warmup(self) -> None:
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
return None
def list_bases(self) -> list[dict]:
"""Bazy widoczne dla dostawcy + metaopis i stan włączenia (DAN-15/PRE-09).
Opcjonalne: dostawca SQL nie operuje na plikach, więc domyślnie pusto —
UI pokaże wtedy, że nie ma czego przełączać, zamiast się wywrócić."""
return []
+1 -25
View File
@@ -91,31 +91,10 @@ class ExcelDataProvider(DataProvider):
base = Path(self.s.excel_dir)
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
def _enabled_files(self, paths: list[str]) -> list[str]:
"""Odsiewa bazy WYŁĄCZONE globalnie (DAN-15) — nie biorą udziału
w interpretacji, choć fizycznie leżą na udziale."""
from app import bases
entries = bases.disabled_entries()
if not entries:
return paths
return [p for p in paths if bases.is_enabled(p, self.s.excel_dir, entries)]
def list_bases(self) -> list[dict]:
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
from app import bases
return bases.list_bases(self.s.excel_dir, self._excel_files())
# ---- publiczne API ----
def search(self, query: SearchQuery) -> SearchResult:
t0 = time.perf_counter()
# Lista wyłączonych baz wchodzi do klucza cache: bez tego zmiana ustawień
# oddawałaby wynik sprzed zmiany, czyli treść bazy uznanej za wyłączoną.
from app import bases
disabled = ",".join(bases.disabled_entries())
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}|{disabled}"
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
if hit is not None:
@@ -126,9 +105,6 @@ class ExcelDataProvider(DataProvider):
if not candidates:
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
candidates = [(p, "0") for p in self._excel_files()]
# bazy wyłączone globalnie (DAN-15) pomijamy niezależnie od źródła kandydatów
allowed = set(self._enabled_files([p for p, _ in candidates]))
candidates = [(p, s) for p, s in candidates if p in allowed]
rows: list[dict] = []
for path, _sheet in candidates:
-50
View File
@@ -1,50 +0,0 @@
"""Uwierzytelnianie międzywarstwowe (LOG-32).
Warstwa danych oddaje SUROWE wiersze baz — to najbardziej wrażliwy punkt całego
systemu. Bez tego kontrolera wystarczyłoby uderzyć w nią bezpośrednio, z pominięciem
i logiki, i logowania w UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
musi go przynieść w nagłówku X-Astrololo-Token.
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
starcie leci ostrzeżenie.
"""
from __future__ import annotations
import logging
import os
import secrets
from fastapi import Request
from fastapi.responses import JSONResponse
log = logging.getLogger("astrololo.security")
HEADER = "X-Astrololo-Token"
PUBLIC_PATHS = frozenset({"/health"})
def token() -> str:
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
return os.getenv("INTERNAL_TOKEN", "")
def enabled() -> bool:
return bool(token())
def install(app, layer: str) -> None:
if not enabled():
log.warning(
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or not enabled():
return await call_next(request)
got = request.headers.get(HEADER, "")
if not secrets.compare_digest(got, token()):
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
status_code=401)
return await call_next(request)
-2
View File
@@ -1,2 +0,0 @@
-r requirements.txt
pytest>=8.0
-2
View File
@@ -7,5 +7,3 @@ openpyxl>=3.1
pyarrow>=18.0
SQLAlchemy>=2.0
pydantic>=2.10
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
-50
View File
@@ -1,50 +0,0 @@
"""Przegląd baz na udziale i ich globalne wyłączanie (DAN-15 / PRE-09)."""
import pytest
from app import bases
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.delenv("DISABLED_BASES", raising=False)
ROOT = "/dane"
PATHS = [f"{ROOT}/main_base.xlsx", f"{ROOT}/archiwum/stara.xlsx"]
def test_everything_enabled_by_default():
for b in bases.list_bases(ROOT, PATHS):
assert b["enabled"] is True
def test_disable_by_file_name(monkeypatch):
monkeypatch.setenv("DISABLED_BASES", "stara.xlsx")
state = {b["name"]: b["enabled"] for b in bases.list_bases(ROOT, PATHS)}
assert state == {"main_base.xlsx": True, "stara.xlsx": False}
def test_disable_by_relative_path(monkeypatch):
"""Wpis może wskazywać ścieżkę względną, nie tylko samą nazwę."""
monkeypatch.setenv("DISABLED_BASES", "archiwum/stara.xlsx")
assert bases.is_enabled(f"{ROOT}/archiwum/stara.xlsx", ROOT) is False
assert bases.is_enabled(f"{ROOT}/main_base.xlsx", ROOT) is True
def test_entries_are_trimmed_and_multiple(monkeypatch):
monkeypatch.setenv("DISABLED_BASES", " stara.xlsx , main_base.xlsx ")
assert [b["enabled"] for b in bases.list_bases(ROOT, PATHS)] == [False, False]
def test_listing_carries_metadata_not_content(tmp_path):
"""Metaopis: nazwa, ścieżka, rozmiar, data — ŻADNEJ treści bazy."""
f = tmp_path / "baza.xlsx"
f.write_bytes(b"x" * 2048)
item = bases.list_bases(tmp_path, [str(f)])[0]
assert set(item) == {"name", "path", "size_mb", "modified", "enabled"}
assert item["name"] == "baza.xlsx" and item["size_mb"] is not None and item["modified"]
def test_missing_file_does_not_crash_the_listing():
item = bases.list_bases(ROOT, [f"{ROOT}/nie-ma.xlsx"])[0]
assert item["size_mb"] is None and item["modified"] is None
-61
View File
@@ -1,61 +0,0 @@
"""Rekordy-pułapki (canary) — DAN-26.
Testujemy sam MECHANIZM na syntetycznych pułapkach: prawdziwe markery i injekcja
do baz przychodzą od właściciela produktu. Regresja byłaby CICHA i podwójnie zła:
albo pułapka wycieka do interpretacji/LLM (zdradza się i psuje wynik), albo
przestaje odsiewać i nie wiadomo o tym.
"""
import pathlib
from app import canary
MAIN = (pathlib.Path(__file__).resolve().parents[1] / "app" / "main.py").read_text(encoding="utf-8")
MARK = "ASTROLOLO-CANARY-7f3a9" # unikalny — nie wystąpi w realnych danych
ROWS = [
{"significator": "Ma Ari", "effect": "odważny, impulsywny"},
{"significator": "Ve Tau " + MARK, "effect": "pułapka — nie dotknie interpretacji"},
{"significator": "Su Leo", "effect": "dumny, twórczy"},
]
def test_no_markers_is_transparent():
"""Bez skonfigurowanych markerów — zero ingerencji, zero kosztu."""
out, rep = canary.screen(ROWS, "cokolwiek", markers=[])
assert out == ROWS and rep["active"] is False
def test_canary_row_is_fenced_from_results():
"""Pułapka znika z wyników — nie opuści warstwy danych (a więc i promptu LLM)."""
out, rep = canary.screen(ROWS, "Ve Tau", markers=[MARK])
assert rep["active"] and rep["removed"] == 1
assert all(MARK not in str(r) for r in out) # nigdzie nie ma markera
assert len(out) == 2 and {"significator": "Su Leo", "effect": "dumny, twórczy"} in out
def test_real_results_pass_through_untouched():
out, _ = canary.screen(ROWS, "Ari", markers=[MARK])
assert {"significator": "Ma Ari", "effect": "odważny, impulsywny"} in out
def test_tripwire_when_query_targets_a_marker():
"""Zapytanie CELUJĄCE w marker = ktoś enumeruje bazę, nie liczy horoskopu."""
_, rep = canary.screen(ROWS, "Ve Tau " + MARK, markers=[MARK])
assert rep["tripwire"] is True
def test_normal_query_does_not_trip():
_, rep = canary.screen(ROWS, "Ma Ari", markers=[MARK])
assert rep["tripwire"] is False
def test_marker_matched_in_any_string_field():
rows = [{"significator": "X", "effect": "opis " + MARK, "extra": 5}]
out, rep = canary.screen(rows, "X", markers=[MARK])
assert out == [] and rep["removed"] == 1 # marker w polu 'effect' też łapiemy
def test_endpoint_fences_before_returning():
"""/search odsiewa pułapki na wyjściu z warstwy danych (niezależnie od dostawcy)."""
assert "canary.screen(result.rows, query.value)" in MAIN
assert "result.rows = visible" in MAIN
+7 -30
View File
@@ -5,28 +5,13 @@ Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
"""
from __future__ import annotations
import os
from typing import Any
import httpx
from app import link_crypto
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza logika↔dane. Czytany przy każdym wywołaniu, bo konfiguracja
może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
key = link_crypto.key_from_env(link_crypto.ENV_LOGIC_DATA)
return link_crypto.Link(key) if key else None
class DataClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.data_url).rstrip("/")
@@ -41,20 +26,12 @@ class DataClient:
) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}/search",
payload=payload, headers=_auth_headers(),
link=_link())
def bases(self) -> dict[str, Any]:
"""Lista baz na udziale + metaopis (DAN-15) — same metadane, bez treści."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
headers=_auth_headers(), link=_link())
def health(self) -> dict[str, Any]:
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
# nie mają, a nie przechodzi tędy nic z baz.
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
r = client.post(f"{self.base_url}/search", json=payload)
r.raise_for_status()
return r.json()
def health(self) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health")
r.raise_for_status()
return r.json()
+4 -68
View File
@@ -15,16 +15,6 @@ MAJOR = {
"trine": 120.0,
"opposition": 180.0,
}
# Aspekty POBOCZNE (opcjonalne, PRE-06). Tylko te trzy — bo mają już glify i barwy
# w warstwie prezentacji (chartwheel) oraz w engine/glyphs.py, więc dokładają się
# bez ruszania czegokolwiek poza silnikiem. Bazy interpretacyjne zwykle ich nie
# opisują (brak tokenu w DB_TOKEN), więc trafiają na kosmogram i do tabeli, ale
# NIE generują faset sygnifikatorów — most po cichu je pomija (significators.py).
MINOR = {
"semisextile": 30.0,
"semisquare": 45.0,
"quincunx": 150.0,
}
DB_TOKEN = {
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
"trine": "[tri", "opposition": "[opp",
@@ -37,18 +27,6 @@ LUMINARIES = {"Sun", "Moon"}
DEFAULT_ORB = 8.0
LUMINARY_BONUS = 2.0
# Pary sztywno powiązane definicyjnie — kąt między nimi wynika z samej definicji
# punktu, nie z układu nieba (SN = NN + 180°). Aspekt taki zawsze wychodzi
# dokładny (orb 0,00°) i nie niesie żadnej informacji astrologicznej, więc
# wycinamy go z wyników: zaśmieca listę w UI i zjada budżet promptu do LLM.
RIGID_PAIRS = frozenset({
frozenset({"North Node", "South Node"}),
})
def _is_rigid(name_a: str, name_b: str) -> bool:
return frozenset({name_a, name_b}) in RIGID_PAIRS
def separation(a: float, b: float) -> float:
"""Najmniejsza separacja kątowa [0,180]."""
@@ -71,67 +49,25 @@ def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: f
return dev_next < dev_now
def _first_aspect(sep: float, allowed: float, checks: dict) -> tuple[str, float] | None:
"""Pierwszy aspekt z `checks`, w którego orbie mieści się separacja `sep`."""
for asp, angle in checks.items():
dev = abs(sep - angle)
if dev <= allowed:
return asp, round(dev, 2)
return None
def find_cross_aspects(
a_positions: list[dict], b_positions: list[dict],
orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS, minor: bool = False,
) -> list[dict]:
"""Aspekty MIĘDZY dwoma horoskopami (synastria, PRE-04): każdy obiekt z A vs
każdy obiekt z B (`obj1` = osoba A, `obj2` = osoba B). Statyczne — dwa natale,
brak wspólnego czasu, więc bez applying/separating. RIGID_PAIRS nie dotyczy
(NN osoby A vs SN osoby B to realny aspekt, nie artefakt definicji)."""
checks = {**MAJOR, **MINOR} if minor else MAJOR
out: list[dict] = []
for a in a_positions:
la = a.get("decimal")
if la is None:
continue
for b in b_positions:
lb = b.get("decimal")
if lb is None:
continue
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
m = _first_aspect(separation(float(la), float(lb)), allowed, checks)
if m:
out.append({"obj1": a["name"], "obj2": b["name"],
"aspect": m[0], "orb": m[1], "allowed": round(allowed, 2)})
return out
def find_aspects(
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS,
minor: bool = False,
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
) -> list[dict]:
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
Zwraca listę aspektów; gdy znane są prędkości, każdy aspekt ma applying (bool)
i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny). Orb i bonus dla świateł są
KONFIGUROWALNE (PRE-06); `minor=True` dokłada aspekty poboczne (30/45/150°).
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
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).
"""
checks = {**MAJOR, **MINOR} if minor else MAJOR
out: list[dict] = []
n = len(positions)
for i in range(n):
for j in range(i + 1, n):
a, b = positions[i], positions[j]
if _is_rigid(a["name"], b["name"]):
continue
la, lb = a.get("decimal"), b.get("decimal")
if la is None or lb is None:
continue
sep = separation(float(la), float(lb))
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
for asp, angle in checks.items():
for asp, angle in MAJOR.items():
dev = abs(sep - angle)
if dev <= allowed:
applying = _is_applying(
+10 -86
View File
@@ -35,44 +35,13 @@ def _shift_pos(pdict: dict, off: float) -> None:
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
lots_method: str = "degree", zodiac: str = Z.TROPICAL,
house_systems: list[str] | None = None,
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0,
aspect_minor: bool = False) -> dict:
from app.engine import glyphs as GL
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
from app.engine.aspects import find_aspects
from app.engine.houses import mean_obliquity
from app.engine.out_of_zodiac import (
declination,
find_antiscia,
find_declination_aspects,
is_out_of_bounds,
)
positions = engine.positions(moment)
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
result["aspects"] = find_aspects( # aspekty (LOG-06), konfigurowalne (PRE-06)
result["positions"], orb=aspect_orb,
luminary_bonus=aspect_luminary_bonus, minor=aspect_minor)
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
# z surowych długości/szerokości silnika.
eps = mean_obliquity(Z.julian_day(moment.when_utc))
result["obliquity"] = round(eps, 6)
for pdict, obj in zip(result["positions"], positions):
dec = declination(obj.longitude, obj.latitude, eps)
pdict["declination"] = round(dec, 4)
if is_out_of_bounds(dec, eps):
pdict["out_of_bounds"] = True
result["parallels"] = find_declination_aspects(result["positions"])
result["antiscia"] = find_antiscia(result["positions"])
# glify aspektów (LOG-22) — symbol aspektu nie zależy od zodiaku
for a in result["aspects"]:
a["glyph"] = GL.aspect_glyph(a["aspect"])
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
@@ -83,13 +52,6 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
for pdict in result["positions"]:
_shift_pos(pdict, off)
# glify obiektów (LOG-22): symbol planety jest niezmienniczy, symbol ZNAKU
# zależy od zodiaku, więc po przesunięciu. Pierścień 12 znaków pod kosmogram.
for pdict in result["positions"]:
pdict["glyph"] = GL.glyph_for(pdict["name"])
pdict["sign_glyph"] = GL.sign_glyph(pdict["sign"])
result["sign_glyphs"] = [{"sign": s, "glyph": GL.sign_glyph(s)} for s in SIGNS]
if not hasattr(engine, "sidereal"):
return result
@@ -97,59 +59,23 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
asc = H.compute_asc(ramc, eps, moment.lat)
mc = H.compute_mc(ramc, eps)
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
# cusps_detailed, nie cusps_for: Placidus i Koch nie istnieją powyżej koła
# podbiegunowego, a astrolog z Tromsø ma dostać wynik ZE ŚLADEM, czym go
# policzyliśmy. Ten ślad musi dojść aż do raportu i PDF-a.
primary = H.cusps_detailed(ramc, eps, moment.lat, system)
cusp_list = primary.cusps # tropikalne — geometria domów jest niezmiennicza
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
def _cusps_out(cl: list[float]) -> list[dict]:
"""Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif."""
return [
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
"in_sign": in_sign(norm360(c - off)),
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
for i, c in enumerate(cl)
]
result["house_system"] = primary.system # FAKTYCZNIE użyty
result["house_system_requested"] = primary.requested
# Lista ostrzeżeń dla całego horoskopu — prezentacja, raport i PDF czytają
# jedno miejsce, więc żaden z nich nie może „zapomnieć" o fallbacku.
result["house_warnings"] = [primary.notice] if primary.notice else []
result["house_system"] = system
result["angles"] = {
"Asc": _fmt("Asc", asc, off),
"MC": _fmt("MC", mc, off),
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
"IC": _fmt("IC", norm360(mc + 180.0), off),
}
for a in result["angles"].values(): # glif znaku osi (LOG-22)
a["sign_glyph"] = GL.sign_glyph(a["sign"])
result["cusps"] = _cusps_out(cusp_list) # PRYMARNY system — pod kosmogram i wstecz
result["cusps"] = [
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
"in_sign": in_sign(norm360(c - off))}
for i, c in enumerate(cusp_list)
]
for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
# Wiele systemów domów NARAZ (PRE-05/LOG-05) — do porównania obok siebie.
# Osie (Asc/MC) są wspólne; różni się tylko PODZIAŁ na domy. Prymarny zostaje
# w `cusps`/`house_system` (kosmogram i wstecz), a `house_systems` niesie pełen
# zestaw; `positions[].houses[system]` mówi, w którym domu obiekt siedzi wg
# danego systemu. Dokładamy tylko gdy poproszono o więcej niż jeden.
requested = [system] + [s for s in (house_systems or []) if s in H.SYSTEMS]
ordered = list(dict.fromkeys(requested)) # prymarny pierwszy, bez duplikatów
if len(ordered) > 1:
result["house_systems"] = []
for s in ordered:
cs = primary if s == system else H.cusps_detailed(ramc, eps, moment.lat, s)
block = {"system": s, "used_system": cs.system, "cusps": _cusps_out(cs.cusps)}
if cs.notice:
block["notice"] = cs.notice
if cs.notice not in result["house_warnings"]:
result["house_warnings"].append(cs.notice)
result["house_systems"].append(block)
for pdict, obj in zip(result["positions"], positions):
pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cs.cusps)
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
from app.engine.firdaria import is_day_birth
from app.engine.lots import compute_lots
@@ -163,9 +89,7 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
"in_sign": in_sign(norm360(lot["longitude"] - off)),
"house": H.assign_house(lot["longitude"], cusp_list), # dom po długości tropikalnej
"glyph": GL.glyph_for(lot["name"]), # ⊗ dla Fortuny; reszta None (LOG-22)
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(lot["longitude"] - off))])}
"house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej
for lot in compute_lots(pts, day, lots_method)
]
return result
-177
View File
@@ -1,177 +0,0 @@
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
Litery i słowa (Sa Pis 26°08' conj Fortune) ↔ glify (♄ ♓ 26°08' ☌ ⊗). Potrzebne
pod rysowanie kosmogramu (PRE-12) — planety i znaki na kole rysujemy symbolami.
DWIE zasady z bazy wymagań, obie krytyczne:
* DAN-18: TYLKO tekstowy Unicode, NIGDY emoji. Część znaków (zodiak, ♀, ♂) ma
domyślnie prezentację emoji — kolorowy kwadrat zamiast czarno-białego glifu,
nieczytelny i niesterowalny przez CSS. Wymuszamy prezentację tekstową
selektorem wariantu U+FE0E (NIE U+FE0F, który robi odwrotnie).
* DAN-17: łańcuch musi znosić dowolny Unicode. Reverse (symbol→tekst)
normalizuje wejście, zdejmując selektory wariantu, żeby glif z FE0E i bez
dawał ten sam wynik.
Glify zapisane przez \\u — jednoznaczne code-pointy, odporne na zniekształcenia
edytorów i samodokumentujące (widać, który to znak Unicode).
"""
from __future__ import annotations
# selektory wariantu prezentacji
_TEXT = "" # VS15 — wymusza glif tekstowy (czarno-biały)
_EMOJI = "" # VS16 — prezentacja emoji; NIGDY nie emitujemy, zdejmujemy przy reverse
def _t(cp: str) -> str:
"""Znak z wymuszoną prezentacją tekstową (dokleja VS15)."""
return cp + _TEXT
# ── planety i światła ────────────────────────────────────────────────────
# ♀ i ♂ mają wariant emoji → wymuszamy tekst; reszta jest tekstowa domyślnie.
PLANET = {
"Sun": "", # ☉
"Moon": "", # ☽
"Mercury": "", # ☿
"Venus": _t(""), # ♀︎
"Mars": _t(""), # ♂︎
"Jupiter": "", # ♃
"Saturn": "", # ♄
"Uranus": "", # ♅
"Neptune": "", # ♆
"Pluto": "", # ♇
}
# ── punkty wirtualne ─────────────────────────────────────────────────────
POINT = {
"North Node": "", # ☊
"South Node": "", # ☋
"Lilith": "", # ⚸ (Black Moon Lilith)
"Chiron": "", # ⚷
}
# ── Lots (punkty arabskie) ───────────────────────────────────────────────
# Standardowy glif ma tylko Fortuna (⊗). Reszta Lotów nie ma powszechnie
# wspieranego symbolu → zwracamy None i UI pokazuje nazwę.
LOT = {
"Fortune": "", # ⊗
"Part of Fortune": "",
}
# ── znaki zodiaku (wszystkie mają wariant emoji → wszystkie z VS15) ──────
_SIGN_CP = {
"Aries": "", "Taurus": "", "Gemini": "", "Cancer": "",
"Leo": "", "Virgo": "", "Libra": "", "Scorpio": "",
"Sagittarius": "", "Capricorn": "", "Aquarius": "", "Pisces": "",
}
SIGN = {name: _t(cp) for name, cp in _SIGN_CP.items()}
# ── aspekty (nazwy jak w aspects.py + minor z abbreviations) ─────────────
ASPECT = {
"conjunction": "", # ☌
"opposition": "", # ☍
"trine": "", # △
"square": "", # □
"sextile": "", # ⚹
"semisextile": "", # ⚺
"quincunx": "", # ⚻
"semisquare": "", # ∠
}
# ── ruch ─────────────────────────────────────────────────────────────────
RETROGRADE = "" # ℞
DIRECT = "D" # zwykłe „D" — brak dedykowanego glifu prostego ruchu
# ── forward: nazwa → glif ────────────────────────────────────────────────
def glyph_for(name: str) -> str | None:
"""Glif obiektu/punktu/Lota po nazwie (jak w silniku). None, gdy brak."""
return PLANET.get(name) or POINT.get(name) or LOT.get(name)
def sign_glyph(sign: str) -> str | None:
return SIGN.get(sign)
def aspect_glyph(aspect: str) -> str | None:
return ASPECT.get(aspect)
# ── reverse: glif → nazwa ────────────────────────────────────────────────
def _strip_variants(g: str) -> str:
"""Zdejmuje selektory wariantu — glif z FE0E i bez daje ten sam klucz."""
return g.replace(_TEXT, "").replace(_EMOJI, "")
def _reverse(mapping: dict[str, str]) -> dict[str, str]:
# pierwsze wystąpienie wygrywa (Fortune vs Part of Fortune → 'Fortune')
out: dict[str, str] = {}
for name, g in mapping.items():
out.setdefault(_strip_variants(g), name)
return out
_PLANET_POINT_REV = _reverse({**PLANET, **POINT, **{"Fortune": LOT["Fortune"]}})
_SIGN_REV = _reverse(SIGN)
_ASPECT_REV = _reverse(ASPECT)
def name_for_glyph(g: str) -> str | None:
"""Obiekt/punkt po glifie (znosi obecność lub brak selektora wariantu)."""
return _PLANET_POINT_REV.get(_strip_variants(g))
def sign_for_glyph(g: str) -> str | None:
return _SIGN_REV.get(_strip_variants(g))
def aspect_for_glyph(g: str) -> str | None:
return _ASPECT_REV.get(_strip_variants(g))
# ── glifikacja tekstu sygnifikatora (składnia [XX z bazy) ────────────────
# Nasze sygnifikatory zapisane są tokenami z prefiksem [ (np. „[Sa [conj [PF").
# glyphify zamienia znane tokeny na glify, resztę zostawia. To druga strona
# abbreviations.expand: tam token → słowo, tu token → symbol.
import re as _re
# token po nawiasie (np. „Su", „Tau", „conj", „PF") → glif
_TOKEN_GLYPH: dict[str, str] = {}
_ABBR_TO_NAME = {
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus", "Ma": "Mars",
"Ju": "Jupiter", "Sa": "Saturn", "Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
"PF": "Fortune", "Fortune": "Fortune",
}
for _abbr, _name in _ABBR_TO_NAME.items():
_g = glyph_for(_name)
if _g:
_TOKEN_GLYPH[_abbr] = _g
_SIGN_ABBR_NAME = {
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer", "Leo": "Leo",
"Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio", "Sag": "Sagittarius",
"Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
}
for _abbr, _name in _SIGN_ABBR_NAME.items():
_TOKEN_GLYPH[_abbr] = SIGN[_name]
_ASP_ABBR_NAME = {
"conj": "conjunction", "opp": "opposition", "tri": "trine", "sq": "square",
"sex": "sextile", "semisex": "semisextile", "quincunx": "quincunx", "semisq": "semisquare",
}
for _abbr, _name in _ASP_ABBR_NAME.items():
if _name in ASPECT:
_TOKEN_GLYPH[_abbr] = ASPECT[_name]
_TOKEN_RE = _re.compile(r"\[([A-Za-z]+)")
def glyphify(text: str) -> str:
"""Zamienia tokeny [XX na glify; nieznane zostawia bez zmiany. Dodatkowo
»Rx«/»R« → ℞. Nie parsuje stopni — te i tak są czytelne (26°08')."""
if not text:
return text
out = _TOKEN_RE.sub(lambda m: _TOKEN_GLYPH.get(m.group(1), m.group(0)), text)
out = _re.sub(r"\bR[x]?\b", RETROGRADE, out)
return out
+5 -457
View File
@@ -8,31 +8,13 @@ silnik dostarcza tylko RAMC i ε.
from __future__ import annotations
import math
from dataclasses import dataclass
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
WHOLE_SIGN = "whole_sign"
EQUAL = "equal"
PORPHYRY = "porphyry"
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania
# iteracyjnego i dochodzą osobno.
VEHLOW = "vehlow"
MORINUS = "morinus"
REGIOMONTANUS = "regiomontanus"
CAMPANUS = "campanus"
ALCABITUS = "alcabitus"
TOPOCENTRIC = "topocentric"
PLACIDUS = "placidus"
KOCH = "koch"
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris co do zera
# (tests/oracle). TOPOCENTRIC celowo POZA listą: implementacja jest zgodna
# z wyrocznią wszędzie poza otoczeniem bieguna (|φ| ≈ 89,9° przy RAMC 90°/270°),
# gdzie kolejność domów się odwraca i konstrukcja traci sens — jego „biegun"
# atan(tan(89,9°)/3) to już 89,7°. Nie zawężamy dziedziny wyłącznie po to, by
# testy przeszły; system dołączy, gdy będzie poprawny na całej dziedzinie.
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY, VEHLOW, MORINUS,
REGIOMONTANUS, CAMPANUS, ALCABITUS, PLACIDUS, KOCH)
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
def mean_obliquity(tt_jd: float) -> float:
@@ -49,27 +31,12 @@ def compute_mc(ramc_deg: float, eps_deg: float) -> float:
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
"""Ascendent — punkt ekliptyki wschodzący na horyzoncie.
KOREKTA GAŁĘZI (błąd wykryty przez porównanie z wyrocznią, tests/oracle):
ekliptyka przecina horyzont w DWÓCH punktach — wschodzącym (Asc) i zachodzącym
(Dsc). `atan2` wybiera jeden z nich, ale powyżej koła podbiegunowego potrafi
wskazać ten NIEWŁAŚCIWY: dla szerokości 67°+ i szerokiego zakresu RAMC
zwracaliśmy Descendent, czyli Ascendent przesunięty o 180°. Skutek nie był
subtelny — planety lądowały w PRZECIWNYCH domach dla całej Skandynawii
północnej (Tromsø, Rovaniemi, Murmańsk).
Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze w półkolu
(0°, 180°) na wschód od MC. Reguła zweryfikowana na 46 800 przypadkach wobec
Swiss Ephemeris — zero rozbieżności.
"""
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
asc = norm360(math.degrees(math.atan2(
asc = math.atan2(
math.cos(r),
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
)))
mc = compute_mc(ramc_deg, eps_deg)
return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else asc
)
return norm360(math.degrees(asc))
def _trisect(a: float, b: float) -> tuple[float, float]:
@@ -78,29 +45,10 @@ def _trisect(a: float, b: float) -> tuple[float, float]:
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
# Poniżej tej odległości od granicy znaku traktujemy Ascendent jak leżący DOKŁADNIE
# na niej. 1e-9° to 3,6 mikrosekundy łuku — o rzędy wielkości poniżej jakiejkolwiek
# realnej dokładności danych urodzeniowych, więc nie zmienia to żadnego horoskopu.
_SIGN_SNAP_DEG = 1e-9
def _snap_to_sign_boundary(lon: float) -> float:
"""Przyciąga długość do granicy znaku, gdy jest od niej o włos.
Whole sign jest NIECIĄGŁY na granicach znaków: różnica 10⁻¹¹° w Ascendencie
przerzuca cały dom I o 30°. Bez tego przyciągania ten sam horoskop policzony
na innej maszynie mógłby dać inny wynik (wykryte przez porównanie z wyrocznią:
nasz Asc = 359,999999999976, swissepha = 1e-10 — ta sama wartość po dwóch
stronach granicy Ryby/Baran). Determinizm jest tu ważniejszy niż dosłowność
zmiennoprzecinkowa."""
nearest = round(lon / 30.0) * 30.0
return norm360(nearest) if abs(lon - nearest) < _SIGN_SNAP_DEG else lon
def cusps(asc: float, mc: float, system: str) -> list[float]:
"""Zwraca 12 cusps (długości) domów 1..12."""
if system == WHOLE_SIGN:
start = sign_index(_snap_to_sign_boundary(asc)) * 30.0
start = sign_index(asc) * 30.0
return [norm360(start + 30.0 * i) for i in range(12)]
if system == EQUAL:
return [norm360(asc + 30.0 * i) for i in range(12)]
@@ -116,406 +64,6 @@ def cusps(asc: float, mc: float, system: str) -> list[float]:
raise ValueError(f"nieznany system domów: {system}")
def polar_circle(eps_deg: float) -> float:
"""Szerokość koła podbiegunowego [°] dla danego nachylenia ekliptyki.
NIE jest to stała 66,56°: ε zmienia się z datą (ok. 23,71° w 370 p.n.e.,
23,44° dziś), więc granica przesuwa się o ~0,3° w zakresie dat programu.
Powyżej niej stopnie ekliptyki bywają okołobiegunowe — nie wschodzą ani nie
zachodzą — przez co systemy oparte na łuku dobowym (Placidus, Koch) tracą
definicję."""
return 90.0 - abs(eps_deg)
# ── geometria wektorowa dla systemów dzielących koła wielkie ─────────────
# Wzory na te systemy krążą w literaturze w kilku wariantach i łatwo o pomyłkę
# w gałęzi albo znaku. Liczymy więc WPROST z geometrii: budujemy wektory kierunkowe
# w układzie równikowym, przecinamy płaszczyzny i dopiero wynik zamieniamy na
# długość ekliptyczną. Jest to dłuższe, ale jednoznaczne i sprawdzalne.
def _cross(a, b):
return (a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0])
def _dot(a, b):
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _equatorial_to_lon(v, eps_rad: float) -> float:
"""Wektor w układzie RÓWNIKOWYM → długość ekliptyczna [°]."""
x, y, z = v
y_ecl = y * math.cos(eps_rad) + z * math.sin(eps_rad)
return norm360(math.degrees(math.atan2(y_ecl, x)))
def _ecliptic_pole(eps_rad: float):
"""Biegun ekliptyki (normalna płaszczyzny ekliptyki) w układzie równikowym."""
return (0.0, -math.sin(eps_rad), math.cos(eps_rad))
def _horizon_north(ramc_rad: float, lat_rad: float):
"""Punkt północny horyzontu: RA = RAMC+180°, deklinacja = 90°−φ."""
return (-math.sin(lat_rad) * math.cos(ramc_rad),
-math.sin(lat_rad) * math.sin(ramc_rad),
math.cos(lat_rad))
# Domy POŚREDNIE (bez osi) i to, po której stronie MC leżą. Domy 11, 12, 2, 3
# są na wschód od MC (przesunięcie 0180°), domy 5, 6, 8, 9 — na zachód.
_INTERMEDIATE = {1: True, 2: True, 4: False, 5: False,
7: False, 8: False, 10: True, 11: True}
def _house_circle_cusp(north, q, eps_rad: float, mc: float, east_of_mc: bool) -> float:
"""Cusp = przecięcie ekliptyki z kołem domu.
Koło domu przechodzi przez punkty N/S horyzontu oraz przez punkt podziału `q`
(na równiku dla Regiomontanusa, na pierwszym wertykale dla Campanusa).
Przecięcie dwóch płaszczyzn daje PROSTĄ, czyli DWA antypodyczne kierunki;
wybieramy ten po właściwej stronie południka.
Używane WYŁĄCZNIE dla domów pośrednich. Osie (1, 4, 7, 10) znamy dokładnie
z Asc i MC — liczenie ich tą drogą było błędem, bo leżą dokładnie na granicy
„wschód/zachód" (przesunięcie 0° i 180°), gdzie porównanie zmiennoprzecinkowe
się chwieje i potrafi wybrać przeciwny punkt nieba."""
normal = _cross(north, q) # normalna płaszczyzny koła domu
line = _cross(normal, _ecliptic_pole(eps_rad))
lon = _equatorial_to_lon(line, eps_rad)
return lon if ((lon - mc) % 360.0 < 180.0) == east_of_mc else norm360(lon + 180.0)
def _culminating_mc(mc: float, eps: float, lat: float) -> float:
"""Punkt południka, który dla tej szerokości leży NAD horyzontem.
Systemy oparte na horyzoncie (Regiomontanus, Campanus, Topocentric) biorą jako
dziesiąty dom punkt GÓRUJĄCY, a nie matematyczne MC — a za kołem podbiegunowym
to nie zawsze to samo. Punkt południka o deklinacji δ ma wysokość 90|φ−δ|,
więc jest nad horyzontem dokładnie wtedy, gdy |φ−δ| < 90.
Systemy dzielące samą ekliptykę (porphyry, equal, alcabitus, whole sign) tego
nie robią — i tak samo zachowuje się wyrocznia."""
dec = math.degrees(math.asin(math.sin(math.radians(mc)) * math.sin(math.radians(eps))))
return norm360(mc + 180.0) if abs(lat - dec) > 90.0 else mc
def _with_exact_angles(intermediate, asc: float, mc: float) -> list[float]:
"""Składa 12 cuspów: osie wstawione dokładnie, reszta z geometrii."""
out = [0.0] * 12
out[0], out[3] = asc, norm360(mc + 180.0) # Asc, IC
out[6], out[9] = norm360(asc + 180.0), mc # Dsc, MC
for i, value in intermediate.items():
out[i] = value
return out
def _ra_to_ecliptic_lon(ra_deg: float, eps_rad: float) -> float:
"""Punkt ekliptyki o zadanej rektascensji (koło godzinne → ekliptyka)."""
r = math.radians(ra_deg)
return norm360(math.degrees(math.atan2(math.sin(r), math.cos(r) * math.cos(eps_rad))))
def _equator_point(ra_deg: float):
"""Kierunek punktu na równiku niebieskim o danej rektascensji."""
r = math.radians(ra_deg)
return (math.cos(r), math.sin(r), 0.0)
def _prime_vertical_point(ramc_rad: float, lat_rad: float, angle_deg: float):
"""Punkt pierwszego wertykału, `angle_deg` od punktu wschodu w stronę nadiru.
Pierwszy wertykał to koło przez wschód, zenit, zachód i nadir — Campanus dzieli
właśnie je."""
east = (-math.sin(ramc_rad), math.cos(ramc_rad), 0.0)
zenith = (math.cos(lat_rad) * math.cos(ramc_rad),
math.cos(lat_rad) * math.sin(ramc_rad),
math.sin(lat_rad))
a = math.radians(angle_deg)
return tuple(east[i] * math.cos(a) - zenith[i] * math.sin(a) for i in range(3))
def _cusps_regiomontanus(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Równik niebieski dzielony na 12 równych łuków, rzut kołami przez N/S horyzontu."""
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
north = _horizon_north(rr, lr)
mid = {i: _house_circle_cusp(north, _equator_point(ramc + 90.0 + 30.0 * i), er, mc, e)
for i, e in _INTERMEDIATE.items()}
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
def _cusps_campanus(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Pierwszy wertykał dzielony na 12 równych łuków, rzut tak samo jak wyżej."""
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
north = _horizon_north(rr, lr)
mid = {i: _house_circle_cusp(north, _prime_vertical_point(rr, lr, 30.0 * i), er, mc, e)
for i, e in _INTERMEDIATE.items()}
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
def _cusps_morinus(ramc: float, eps: float) -> list[float]:
"""Równik dzielony od RAMC i rzutowany WPROST na ekliptykę — bez horyzontu.
Dlatego Morinus jako jedyny nie zależy od szerokości geograficznej, a jego
dom I nie pokrywa się z Ascendentem. Uwaga: to ZAMIANA WSPÓŁRZĘDNYCH punktu
równika (RA, dec=0) na ekliptyczne, a nie rzut kołem godzinnym — te dwie
operacje dają różne wyniki i pomylenie ich kosztowało tu do 5°."""
er = math.radians(eps)
return [_equatorial_to_lon(_equator_point(ramc + 90.0 + 30.0 * i), er)
for i in range(12)]
def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
"""Łuki równika MC→Asc i Asc→IC dzielone na trzy; rzut kołami godzinnymi."""
er = math.radians(eps)
a = math.radians(asc)
ra_asc = norm360(math.degrees(math.atan2(math.sin(a) * math.cos(er), math.cos(a))))
day = (ra_asc - ramc) % 360.0 # łuk MC → Asc (domy 11, 12)
night = (ramc + 180.0 - ra_asc) % 360.0 # łuk Asc → IC (domy 2, 3)
ra = [0.0] * 12
ra[9] = ramc # dom 10 = MC
ra[10] = ramc + day / 3.0 # dom 11
ra[11] = ramc + 2.0 * day / 3.0 # dom 12
ra[0] = ra_asc # dom 1 = Asc
ra[1] = ra_asc + night / 3.0 # dom 2
ra[2] = ra_asc + 2.0 * night / 3.0 # dom 3
for i in range(6): # domy 49 naprzeciw 103
ra[i + 3] = ra[(i + 9) % 12] + 180.0
return [_ra_to_ecliptic_lon(x, er) for x in ra]
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (PolichPage).
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
_TOPO_POLE_FRACTION = (1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3,
1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3)
def _asc_under_pole_raw(ramc_deg: float, eps_deg: float, pole_deg: float) -> float:
"""Surowy wzór na ascendent pod zadanym „biegunem", BEZ korekty gałęzi.
Korektę stosuje wywołujący — względem PRAWDZIWEGO MC horoskopu. `compute_asc`
poprawia gałąź względem MC dla PRZESUNIĘTEGO RAMC, co dla cuspu domu jest złym
punktem odniesienia i w okolicach biegunów dawało obrót o 180°."""
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(pole_deg)
return norm360(math.degrees(math.atan2(
math.cos(r), -(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)))))
def _cusps_topocentric(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""PolichPage: dom pośredni to ASCENDENT policzony pod własnym „biegunem"
tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu.
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".
Liczymy tylko domy 11, 12, 2, 3, a 5, 6, 8, 9 bierzemy jako ich OPOZYCJE —
to nie skrót, lecz własność tych systemów: przeciwległe domy leżą na tym samym
kole wielkim, więc ich cuspy są dokładnie antypodyczne."""
tan_lat = math.tan(math.radians(lat))
# Gałąź liczymy względem MC GÓRUJĄCEGO, nie matematycznego: gdy za kołem
# podbiegunowym te dwa się rozjeżdżają, cała czwórka domów pośrednich musi
# obrócić się razem z dziesiątym domem.
culminating = _culminating_mc(mc, eps, lat)
out = [0.0] * 12
for i in (10, 11, 1, 2): # domy 11, 12, 2, 3
pole = math.degrees(math.atan(tan_lat * _TOPO_POLE_FRACTION[i]))
lon = _asc_under_pole_raw(ramc + 30.0 * i, eps, pole)
if ((lon - culminating) % 360.0 < 180.0) != _INTERMEDIATE[i]:
lon = norm360(lon + 180.0)
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 == 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)
-140
View File
@@ -1,140 +0,0 @@
"""Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja.
Aspekty głowne (LOG-06) mierzą kąt wzdłuż EKLIPTYKI. Ale dwa ciała mogą być
powiązane też inaczej — a te powiązania klasyczna astrologia liczy naprawdę,
nie na oko:
* **Paralela / kontrparalela deklinacji.** Deklinacja to „szerokość" na równiku
niebieskim — jak daleko na północ/południe od równika stoi ciało. Dwa ciała na
tej samej deklinacji (parallel) działają jak koniunkcja, na przeciwnej
(kontrparalela) — jak opozycja, mimo że wzdłuż ekliptyki mogą być gdziekolwiek.
Baza interpretacyjna zna to zjawisko pod skrótem „P. Dec.".
* **Antyscja / kontrantyscja.** Odbicie punktu względem osi przesileń
(0° Raka 0° Koziorożca). Dwa punkty w antyscji są równo odległe od tej osi —
„dzielą" tę samą długość dnia. Kontrantyscja to odbicie względem osi
równonocy (0° Barana 0° Wagi).
Wszystko liczymy na współrzędnych TROPIKALNYCH of-date, bo:
- deklinacja jest wielkością fizyczną (równikową), niezależną od wyboru zodiaku;
- antyscja jest z definicji tropikalna — jej oś to punkty przesileń, czyli
kardynalne punkty zodiaku tropikalnego.
Dlatego moduł bierze surowe długości/szerokości z silnika, a nie etykiety po
przesunięciu na zodiak syderyczny/draconiczny.
"""
from __future__ import annotations
from app.engine.aspects import LUMINARIES, RIGID_PAIRS, separation
from app.engine.formats import norm360
from app.engine.zodiac import to_equatorial
# Orby — konfigurowalne (wymóg LOG-07). Paralele i antyscja są klasycznie CIASNE:
# to kontakty „punktowe", więc szeroki orb produkowałby fałszywe trafienia.
DECL_ORB = 1.0 # ° deklinacji dla paraleli/kontrparaleli
ANTISCIA_ORB = 1.0 # ° długości dla antyscji/kontrantyscji
LUMINARY_BONUS = 0.0 # świadomie 0 — te kontakty trzymamy ciasno; do podniesienia w API
def _is_rigid(name_a: str, name_b: str) -> bool:
return frozenset({name_a, name_b}) in RIGID_PAIRS
def declination(lon: float, lat: float, eps: float) -> float:
"""Deklinacja [90, 90]° z długości i szerokości ekliptycznej (pełny wzór,
z szerokością — istotne dla Księżyca i planet, które schodzą z ekliptyki)."""
_, dec = to_equatorial(lon, lat, eps)
return dec
def is_out_of_bounds(dec: float, eps: float) -> bool:
"""Deklinacja poza zakresem Słońca (|dec| > nachylenie ekliptyki).
„Out of bounds" — ciało zaszło dalej na północ/południe, niż Słońce kiedykolwiek
potrafi. Astrologicznie czytane jako działanie „poza normą", stąd wart odnotowania."""
return abs(dec) > eps
def antiscion(lon: float) -> float:
"""Odbicie długości względem osi przesileń (0° Raka 0° Koziorożca)."""
return norm360(180.0 - lon)
def contra_antiscion(lon: float) -> float:
"""Odbicie długości względem osi równonocy (0° Barana 0° Wagi)."""
return norm360(-lon)
def _allowed(orb: float, name_a: str, name_b: str, bonus: float) -> float:
if bonus and (name_a in LUMINARIES or name_b in LUMINARIES):
return orb + bonus
return orb
def find_declination_aspects(
bodies: list[dict], orb: float = DECL_ORB, luminary_bonus: float = LUMINARY_BONUS
) -> list[dict]:
"""bodies: dicty z 'name' i 'declination' (°).
Zwraca paralele (ta sama deklinacja) i kontrparalele (przeciwna). Pary z
RIGID_PAIRS pomijane — np. węzły są z definicji zawsze w kontrparaleli
(SN = NN+180 na ekliptyce → deklinacja przeciwna), co nie niesie informacji.
"""
out: list[dict] = []
n = len(bodies)
for i in range(n):
for j in range(i + 1, n):
a, b = bodies[i], bodies[j]
if _is_rigid(a["name"], b["name"]):
continue
da, db = a.get("declination"), b.get("declination")
if da is None or db is None:
continue
da, db = float(da), float(db)
allowed = _allowed(orb, a["name"], b["name"], luminary_bonus)
parallel_dev = abs(da - db)
contra_dev = abs(da + db)
# ciało może wpaść tylko w jeden z dwóch — bierzemy ciaśniejszy
if parallel_dev <= allowed and parallel_dev <= contra_dev:
out.append(_row("parallel", a, b, parallel_dev, allowed, da, db))
elif contra_dev <= allowed:
out.append(_row("contraparallel", a, b, contra_dev, allowed, da, db))
return out
def find_antiscia(
bodies: list[dict], orb: float = ANTISCIA_ORB, luminary_bonus: float = LUMINARY_BONUS
) -> list[dict]:
"""bodies: dicty z 'name' i 'decimal' (długość tropikalna of-date, °).
Zwraca antyscje (odbicie względem osi przesileń) i kontrantyscje (osi równonocy).
"""
out: list[dict] = []
n = len(bodies)
for i in range(n):
for j in range(i + 1, n):
a, b = bodies[i], bodies[j]
if _is_rigid(a["name"], b["name"]):
continue
la, lb = a.get("decimal"), b.get("decimal")
if la is None or lb is None:
continue
la, lb = float(la), float(lb)
allowed = _allowed(orb, a["name"], b["name"], luminary_bonus)
anti_dev = separation(la, antiscion(lb))
contra_dev = separation(la, contra_antiscion(lb))
if anti_dev <= allowed and anti_dev <= contra_dev:
out.append(_row("antiscion", a, b, anti_dev, allowed))
elif contra_dev <= allowed:
out.append(_row("contra_antiscion", a, b, contra_dev, allowed))
return out
def _row(kind: str, a: dict, b: dict, dev: float, allowed: float,
dec_a: float | None = None, dec_b: float | None = None) -> dict:
row = {
"obj1": a["name"], "obj2": b["name"],
"type": kind, "orb": round(dev, 3), "allowed": round(allowed, 3),
}
if dec_a is not None:
row["dec1"], row["dec2"] = round(dec_a, 3), round(dec_b, 3)
return row
-368
View File
@@ -1,368 +0,0 @@
"""Tabele pomocnicze horoskopu (LOG-23).
Zbiór wyliczeń, które astrolog czyta „obok" pozycji: bilans żywiołów i jakości,
faza Księżyca, stopnie krytyczne, dzień i godziny planetarne, syzygia prenatalna
oraz podziały (dwunastniki i nawamsa).
Dwie rzeczy wymagają prawdziwego liczenia, nie tabelki:
* **godziny planetarne** — są NIERÓWNE: dzień od wschodu do zachodu Słońca dzieli
się na 12 części, noc osobno. Bez faktycznego wschodu/zachodu wynik byłby
zmyślony, więc szukamy ich numerycznie (przejście wysokości Słońca przez 0°50);
* **syzygia prenatalna** — ostatni nów albo pełnia PRZED urodzeniem; szukamy
wstecz momentu, w którym elongacja Księżyca przechodzi przez 0° lub 180°.
Moduł jest silnik-agnostyczny: potrzebuje tylko `positions()` i `sidereal()`.
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
from app.engine.models import ChartMoment
from app.engine.zodiac import to_equatorial
# --- żywioły i jakości ------------------------------------------------------
ELEMENTS = ["Fire", "Earth", "Air", "Water"]
QUALITIES = ["Cardinal", "Fixed", "Mutable"]
ELEMENT_PL = {"Fire": "Ogień", "Earth": "Ziemia", "Air": "Powietrze", "Water": "Woda"}
QUALITY_PL = {"Cardinal": "Kardynalny", "Fixed": "Stały", "Mutable": "Zmienny"}
CLASSICAL = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"]
MODERN = CLASSICAL + ["Uranus", "Neptune", "Pluto"]
# --- dzień i godziny planetarne --------------------------------------------
# Kolejność chaldejska: od najwolniejszej do najszybszej planety
CHALDEAN = ["Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon"]
# Władca dnia wg dnia tygodnia (0 = poniedziałek, jak w datetime.weekday())
WEEKDAY_RULER = ["Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun"]
# wysokość środka tarczy Słońca przy wschodzie/zachodzie (refrakcja + promień tarczy)
SUNRISE_ALTITUDE = -0.833
def element_of(sign: str) -> str:
return ELEMENTS[SIGNS.index(sign) % 4]
def quality_of(sign: str) -> str:
return QUALITIES[SIGNS.index(sign) % 3]
def tally(positions: list[dict], asc_sign: str | None = None,
modern: bool = True) -> dict:
"""Bilans żywiołów i jakości (LOG-23).
Liczymy w dwóch wariantach naraz, bo szkoły się różnią: 7 planet klasycznych
i 10 z nowożytnymi. Ascendent doliczany osobno — bywa traktowany jak punkt
równorzędny planetom.
"""
wanted = MODERN if modern else CLASSICAL
by_name = {p.get("name"): p for p in positions}
def count(names: list[str], with_asc: bool) -> dict:
elements = dict.fromkeys(ELEMENTS, 0)
qualities = dict.fromkeys(QUALITIES, 0)
used = []
for name in names:
p = by_name.get(name)
if not p or not p.get("sign"):
continue
elements[element_of(p["sign"])] += 1
qualities[quality_of(p["sign"])] += 1
used.append(name)
if with_asc and asc_sign:
elements[element_of(asc_sign)] += 1
qualities[quality_of(asc_sign)] += 1
used.append("Asc")
return {"elements": elements, "qualities": qualities,
"counted": used, "total": len(used)}
classical = count(CLASSICAL, False)
result = {
"classical_7": classical,
"with_modern_10": count(wanted, False),
"classical_7_plus_asc": count(CLASSICAL, True),
"with_modern_10_plus_asc": count(wanted, True),
}
# brakujące żywioły — klasyczne „no air" itd., podstawa pod scoring (LOG-21)
base = result["with_modern_10_plus_asc"]
result["missing_elements"] = [e for e, n in base["elements"].items() if n == 0]
result["missing_qualities"] = [q for q, n in base["qualities"].items() if n == 0]
result["labels"] = {"elements": ELEMENT_PL, "qualities": QUALITY_PL}
return result
# --- faza Księżyca ----------------------------------------------------------
_PHASES = [
(0.0, "New Moon", "Nów"),
(45.0, "Waxing Crescent", "Sierp przybywający"),
(90.0, "First Quarter", "Pierwsza kwadra"),
(135.0, "Waxing Gibbous", "Garb przybywający"),
(180.0, "Full Moon", "Pełnia"),
(225.0, "Waning Gibbous", "Garb ubywający"),
(270.0, "Last Quarter", "Ostatnia kwadra"),
(315.0, "Waning Crescent", "Sierp ubywający"),
]
def moon_phase(sun_lon: float, moon_lon: float) -> dict:
"""Faza Księżyca z elongacji (Księżyc Słońce)."""
angle = norm360(moon_lon - sun_lon)
idx = int(((angle + 22.5) % 360.0) // 45.0)
_, name, name_pl = _PHASES[idx]
illumination = (1.0 - math.cos(math.radians(angle))) / 2.0
return {
"angle": round(angle, 4),
"phase": name,
"phase_pl": name_pl,
"illumination": round(illumination, 4),
"waxing": angle < 180.0,
}
# --- stopnie krytyczne ------------------------------------------------------
# klasyczne stopnie krytyczne zależą od jakości znaku
_CRITICAL = {"Cardinal": (0, 13, 26), "Fixed": (8, 21), "Mutable": (4, 17)}
CRITICAL_ORB = 1.0
def critical_degrees(positions: list[dict]) -> list[dict]:
"""Obiekty stojące na stopniach krytycznych, 0° albo 29° (anaretycznym)."""
out = []
for p in positions:
lon = p.get("decimal")
sign = p.get("sign")
if lon is None or not sign:
continue
deg = norm360(lon) - sign_index(lon) * 30.0
flags = []
for critical in _CRITICAL[quality_of(sign)]:
if abs(deg - critical) <= CRITICAL_ORB:
flags.append(f"stopień krytyczny {critical}° ({QUALITY_PL[quality_of(sign)].lower()})")
if deg >= 29.0:
flags.append("29° — stopień anaretyczny (koniec znaku)")
elif deg < 1.0:
flags.append("0° — wejście w znak")
if flags:
out.append({"name": p.get("name"), "sign": sign,
"in_sign": p.get("in_sign"), "flags": flags})
return out
# --- podziały: dwunastnik i nawamsa ----------------------------------------
def dwadasamsa(lon: float) -> float:
"""12. część (dwadasamsa): znak dzielony na 12 po 2°30, licząc od siebie."""
lon = norm360(lon)
start = sign_index(lon) * 30.0
return norm360(start + (lon - start) * 12.0)
def navamsa(lon: float) -> float:
"""9. część (nawamsa): 108 podziałów po 3°20 liczonych od 0° Barana."""
lon = norm360(lon)
part = int(lon // (30.0 / 9.0))
return norm360((part % 12) * 30.0 + (lon % (30.0 / 9.0)) * 9.0)
def divisional(positions: list[dict]) -> list[dict]:
"""Pozycje w podziałach 12. i 9. — obie tabele naraz."""
out = []
for p in positions:
lon = p.get("decimal")
if lon is None:
continue
d12, d9 = dwadasamsa(lon), navamsa(lon)
out.append({
"name": p.get("name"),
"d12_sign": SIGNS[sign_index(d12)], "d12_in_sign": in_sign(d12),
"d9_sign": SIGNS[sign_index(d9)], "d9_in_sign": in_sign(d9),
})
return out
# --- wschód/zachód Słońca i godziny planetarne ------------------------------
def sun_altitude(engine, moment: ChartMoment) -> float:
"""Wysokość Słońca nad horyzontem [°] dla momentu i miejsca."""
ramc, eps = engine.sidereal(moment)
sun = engine.positions(moment, ["Sun"])[0]
ra, dec = to_equatorial(sun.longitude, sun.latitude, eps)
hour_angle = math.radians(norm360(ramc - ra))
phi, d = math.radians(moment.lat), math.radians(dec)
sin_alt = math.sin(d) * math.sin(phi) + math.cos(d) * math.cos(phi) * math.cos(hour_angle)
return math.degrees(math.asin(max(-1.0, min(1.0, sin_alt))))
def _at(moment: ChartMoment, when: datetime) -> ChartMoment:
return ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon)
def _crossings(engine, moment: ChartMoment, start: datetime, end: datetime,
step_minutes: int = 20) -> list[tuple[datetime, str]]:
"""Momenty przejścia Słońca przez horyzont w oknie [start, end].
Skan zgrubny + bisekcja — ten sam wzorzec co przy stacjach planet (LOG-03).
"""
out: list[tuple[datetime, str]] = []
step = timedelta(minutes=step_minutes)
t0 = start
f0 = sun_altitude(engine, _at(moment, t0)) - SUNRISE_ALTITUDE
while t0 < end:
t1 = min(t0 + step, end)
f1 = sun_altitude(engine, _at(moment, t1)) - SUNRISE_ALTITUDE
if f0 == 0.0 or (f0 < 0.0) != (f1 < 0.0):
lo, hi, flo = t0, t1, f0
for _ in range(40): # ~sekundowa dokładność
mid = lo + (hi - lo) / 2
fmid = sun_altitude(engine, _at(moment, mid)) - SUNRISE_ALTITUDE
if (flo < 0.0) != (fmid < 0.0):
hi = mid
else:
lo, flo = mid, fmid
out.append((lo + (hi - lo) / 2, "sunrise" if f1 > f0 else "sunset"))
t0, f0 = t1, f1
return out
def planetary_hours(engine, moment: ChartMoment) -> dict | None:
"""Dzień i godziny planetarne w porządku chaldejskim (LOG-23).
Godziny są NIERÓWNE: dzień (wschód→zachód) i noc (zachód→wschód) dzielą się
na 12 części każde. Doba planetarna zaczyna się o WSCHODZIE, nie o północy —
dlatego władcę dnia bierzemy z dnia tygodnia tego wschodu, który otworzył
bieżący okres.
Zwraca None dla dnia polarnego/nocy polarnej, gdzie wschód nie występuje.
"""
now = moment.when_utc
events = _crossings(engine, moment, now - timedelta(hours=30), now + timedelta(hours=30))
if not events:
return None # brak wschodu/zachodu w oknie
before = [e for e in events if e[0] <= now]
after = [e for e in events if e[0] > now]
if not before or not after:
return None
last_time, last_kind = before[-1]
next_time, _ = after[0]
daytime = last_kind == "sunrise"
period_start, period_end = last_time, next_time
# doba planetarna startuje o wschodzie: w nocy to wschód POPRZEDZAJĄCY zachód
day_start = last_time if daytime else next((t for t, k in reversed(before)
if k == "sunrise"), last_time)
length = (period_end - period_start) / 12
index = int((now - period_start) / length)
index = max(0, min(11, index))
day_ruler = WEEKDAY_RULER[day_start.weekday()]
hour_number = index if daytime else index + 12 # 0..23 od wschodu
ruler = CHALDEAN[(CHALDEAN.index(day_ruler) + hour_number) % 7]
hours = []
for i in range(12):
start = period_start + length * i
hours.append({
"index": i + 1,
"ruler": CHALDEAN[(CHALDEAN.index(day_ruler) + (i if daytime else i + 12)) % 7],
"start": start.isoformat(timespec="seconds"),
"end": (start + length).isoformat(timespec="seconds"),
"current": i == index,
})
return {
"day_ruler": day_ruler,
"hour_ruler": ruler,
"hour_number": hour_number + 1,
"daytime": daytime,
"period": "dzień" if daytime else "noc",
"hour_length_minutes": round(length.total_seconds() / 60.0, 2),
"period_start": period_start.isoformat(timespec="seconds"),
"period_end": period_end.isoformat(timespec="seconds"),
"hours": hours,
}
# --- syzygia prenatalna -----------------------------------------------------
def prenatal_syzygy(engine, moment: ChartMoment, max_days: float = 32.0) -> dict | None:
"""Ostatni nów albo pełnia PRZED podanym momentem (LOG-23).
Szukamy wstecz przejścia elongacji przez 0° (nów) lub 180° (pełnia); bierzemy
to, które wypadło później. Cykl trwa ~29,5 dnia, więc okno 32 dni wystarcza.
"""
def elongation(when: datetime) -> float:
pts = {p.name: p.longitude for p in
engine.positions(_at(moment, when), ["Sun", "Moon"])}
return norm360(pts["Moon"] - pts["Sun"])
def signed(when: datetime, target: float) -> float:
"""Odległość od celu w [180, 180] — zeruje się dokładnie w syzygii."""
return ((elongation(when) - target + 180.0) % 360.0) - 180.0
best: tuple[datetime, str] | None = None
for target, kind in ((0.0, "new_moon"), (180.0, "full_moon")):
step = timedelta(hours=6)
t1 = moment.when_utc
f1 = signed(t1, target)
scanned = timedelta()
while scanned < timedelta(days=max_days):
t0 = t1 - step
f0 = signed(t0, target)
if (f0 < 0.0) != (f1 < 0.0) and abs(f0 - f1) < 180.0:
lo, hi, flo = t0, t1, f0
for _ in range(40):
mid = lo + (hi - lo) / 2
fmid = signed(mid, target)
if (flo < 0.0) != (fmid < 0.0):
hi = mid
else:
lo, flo = mid, fmid
found = lo + (hi - lo) / 2
if best is None or found > best[0]:
best = (found, kind)
break
t1, f1 = t0, f0
scanned += step
if best is None:
return None
when, kind = best
pts = {p.name: p.longitude for p in engine.positions(_at(moment, when), ["Sun", "Moon"])}
lon = pts["Sun"] if kind == "new_moon" else pts["Moon"]
return {
"type": kind,
"type_pl": "nów" if kind == "new_moon" else "pełnia",
"when_utc": when.isoformat(timespec="seconds"),
"days_before_birth": round((moment.when_utc - when).total_seconds() / 86400.0, 3),
"sign": SIGNS[sign_index(lon)],
"in_sign": in_sign(lon),
"decimal": round(norm360(lon), 6),
}
# --- złożenie wszystkiego ---------------------------------------------------
def build_tables(engine, moment: ChartMoment, chart: dict,
heavy: bool = True) -> dict:
"""Komplet tabel dla policzonego horoskopu.
`heavy=False` pomija wyliczenia wymagające szukania numerycznego (godziny
planetarne, syzygia) — przydatne, gdy liczy się czas odpowiedzi.
"""
positions = chart.get("positions") or []
by_name = {p.get("name"): p for p in positions}
asc_sign = (chart.get("angles") or {}).get("Asc", {}).get("sign")
out: dict = {
"tally": tally(positions, asc_sign),
"critical_degrees": critical_degrees(positions),
"divisional": divisional(positions),
}
if "Sun" in by_name and "Moon" in by_name:
out["moon_phase"] = moon_phase(by_name["Sun"]["decimal"], by_name["Moon"]["decimal"])
if heavy:
out["planetary_hours"] = planetary_hours(engine, moment)
out["prenatal_syzygy"] = prenatal_syzygy(engine, moment)
return out
-525
View File
@@ -1,525 +0,0 @@
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
zrzut z węzła), miał je w całości.
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
podatne na modyfikację".
**Dwa niezależne klucze**, po jednym na parę rozmówców:
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
losowej jednorazówki w jedną stronę nie osłabia drugiej.
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import struct
import time
from typing import Iterable, Iterator
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
log = logging.getLogger("astrololo.link")
MAGIC = b"AL1"
VERSION = "v1"
NONCE_BYTES = 12
KEY_BYTES = 32 # AES-256
LENGTH_PREFIX = 4
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
MAX_SKEW_SECONDS = 300.0
HEADER_ENC = "X-Astrololo-Enc"
HEADER_TS = "X-Astrololo-Enc-Ts"
CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
# jej klucza nie może otwierać łącza do logiki ani do danych.
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res"
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
PUBLIC_PATHS = frozenset({"/health"})
class LinkError(Exception):
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
# --------------------------------------------------------------------- klucze
def parse_key(raw: str) -> bytes:
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
text = raw.strip()
if not text:
raise LinkError("pusty klucz łącza")
try:
key = bytes.fromhex(text)
except ValueError:
try:
key = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError) as exc:
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
if len(key) != KEY_BYTES:
raise LinkError(
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
f"`openssl rand -hex 32`"
)
return key
def key_from_env(env_name: str) -> bytes | None:
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
raw = os.getenv(env_name, "")
return parse_key(raw) if raw.strip() else None
def encryption_required() -> bool:
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
"""
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
def _subkey(link_key: bytes, direction: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
info=b"astrololo/link/" + direction,
).derive(link_key)
class Link:
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
def __init__(self, link_key: bytes) -> None:
self._by_direction = {
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
}
# ---------------------------------------------------------- pojedyncza ramka
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
return b"|".join([MAGIC, direction, path.encode("utf-8"),
stamp.encode("ascii"), str(seq).encode("ascii")])
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
plaintext: bytes) -> bytes:
nonce = os.urandom(NONCE_BYTES)
sealed = self._by_direction[direction].encrypt(
nonce, plaintext, self._aad(direction, path, stamp, seq))
return MAGIC + nonce + sealed
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
try:
return self._by_direction[direction].decrypt(
nonce, sealed, self._aad(direction, path, stamp, seq))
except InvalidTag as exc:
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
# ------------------------------------------------------------ strumień ramek
def seal_stream(self, direction: bytes, path: str, stamp: str,
chunks: Iterable[bytes]) -> Iterator[bytes]:
for seq, chunk in enumerate(chunks):
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
def open_stream(self, direction: bytes, path: str, stamp: str,
raw: bytes) -> Iterator[bytes]:
for seq, frame in enumerate(frames_in(raw)):
yield self.open(direction, path, stamp, seq, frame)
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
return b"".join(self.open_stream(direction, path, stamp, raw))
# ---------------------------------------------------------------- ramkowanie
def frame_out(payload: bytes) -> bytes:
return struct.pack(">I", len(payload)) + payload
def frames_in(raw: bytes) -> Iterator[bytes]:
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
offset = 0
while offset < len(raw):
if offset + LENGTH_PREFIX > len(raw):
raise LinkError("urwana ramka (brak nagłówka długości)")
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
offset += LENGTH_PREFIX
if offset + size > len(raw):
raise LinkError("urwana ramka (za mało danych)")
yield raw[offset:offset + size]
offset += size
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
rozjechać się między dwa odczyty.
"""
while True:
if len(buffer) < LENGTH_PREFIX:
return
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
if len(buffer) < LENGTH_PREFIX + size:
return
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
del buffer[:LENGTH_PREFIX + size]
yield frame
# ------------------------------------------------------------- świeżość ruchu
def stamp_now() -> str:
return f"{time.time():.3f}"
def check_stamp(stamp: str) -> None:
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
odtworzyć w dowolnym momencie w przyszłości."""
try:
sent = float(stamp)
except (TypeError, ValueError) as exc:
raise LinkError("brak albo błędny znacznik czasu") from exc
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
# =========================================================== strona serwerowa
class LinkCryptoMiddleware:
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
"""
def __init__(self, app, link: Link | None, layer: str) -> None:
self.app = app
self.link = link
self.layer = layer
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
return await self.app(scope, receive, send)
path = scope["path"]
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
if headers.get(HEADER_ENC.lower()) != VERSION:
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
# chcemy po cichu wrócić do jawnego ruchu.
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
self.layer, path)
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
stamp = headers.get(HEADER_TS.lower(), "")
try:
check_stamp(stamp)
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
except LinkError as exc:
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
scope = dict(scope)
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
def _sealing_send(self, send, path: str):
state: dict = {"stamp": "", "seq": 0}
async def sealing(message):
if message["type"] == "http.response.start":
state["stamp"] = stamp_now()
keep = [(k, v) for k, v in message.get("headers", [])
if k.lower() not in (b"content-length", b"content-type")]
message = dict(message)
message["headers"] = keep + [
(b"content-type", CONTENT_TYPE.encode()),
(HEADER_ENC.lower().encode(), VERSION.encode()),
(HEADER_TS.lower().encode(), state["stamp"].encode()),
]
return await send(message)
if message["type"] == "http.response.body":
chunk = message.get("body", b"")
sealed = b""
if chunk:
sealed = frame_out(self.link.seal(
RESPONSE, path, state["stamp"], state["seq"], chunk))
state["seq"] += 1
return await send({"type": "http.response.body", "body": sealed,
"more_body": message.get("more_body", False)})
return await send(message)
return sealing
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
kept.append((b"content-length", str(length).encode()))
if length:
kept.append((b"content-type", b"application/json"))
return kept
async def _read_body(receive) -> bytes:
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.disconnect":
raise LinkError("rozłączenie w trakcie odbioru żądania")
body += message.get("body", b"")
if not message.get("more_body", False):
return bytes(body)
def _replay(body: bytes, original):
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
"""
delivered = False
async def receive():
nonlocal delivered
if delivered:
return await original()
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return receive
async def _refuse(send, detail: str) -> None:
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
await send({"type": "http.response.start", "status": 400, "headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode()),
]})
await send({"type": "http.response.body", "body": payload})
def install(app, env_name: str, layer: str):
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
link_key = key_from_env(env_name)
if link_key is None and encryption_required():
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
f"warstwa {layer} nie wystartuje bez klucza łącza"
)
if link_key is None:
log.warning(
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
"ruch wewnątrz sieci.", env_name, layer,
)
return None
link = Link(link_key)
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
return link
# ============================================================ strona kliencka
def call(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
się niejasnym błędem odszyfrowania.
"""
import json as _json
import httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
# sygnifikatory, o które pytamy bazę.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
)
response = client.request(method, url, json=payload, headers=request_headers)
response.raise_for_status()
return response.content
path = httpx.URL(url).path
stamp = stamp_now()
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
"Content-Type": CONTENT_TYPE})
response = client.request(method, url, content=body, headers=request_headers)
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
log.error("łącze %s odmówiło: %s", path, response.text[:200])
response.raise_for_status()
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
reply_stamp = response.headers.get(HEADER_TS, "")
check_stamp(reply_stamp)
return link.open_all(RESPONSE, path, reply_stamp, response.content)
def call_json(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None):
import json as _json
return _json.loads(call(client, method, url, payload=payload,
headers=headers, link=link))
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
w buforze zamiast zakładać, że każdy kawałek to komplet.
"""
if link is None:
yield from response.iter_bytes()
return
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
stamp = response.headers.get(HEADER_TS, "")
check_stamp(stamp)
path = response.request.url.path
buffer = bytearray()
seq = 0
for chunk in response.iter_bytes():
buffer += chunk
for frame in unframe_incremental(buffer):
yield link.open(RESPONSE, path, stamp, seq, frame)
seq += 1
if buffer:
raise LinkError("strumień urwał się w połowie ramki")
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
link: Link | None) -> Iterator[str]:
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
dopiero na znakach nowej linii.
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
działał bez zmian.
"""
import json as _json
import httpx as _httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
)
with client.stream("POST", url, json=payload, headers=request_headers) as response:
response.raise_for_status()
for text_line in response.iter_lines():
if text_line:
yield text_line
return
path = _httpx.URL(url).path
stamp = stamp_now()
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
with client.stream("POST", url, content=body, headers=request_headers) as response:
response.raise_for_status()
buffer = bytearray()
for plain in open_response_stream(response, link):
buffer += plain
while True:
nl = buffer.find(b"\n")
if nl < 0:
break
text_line = bytes(buffer[:nl])
del buffer[:nl + 1]
if text_line:
yield text_line.decode("utf-8")
if buffer:
yield bytes(buffer).decode("utf-8")
-75
View File
@@ -1,75 +0,0 @@
"""Katalog modeli do wyboru w UI (LOG-31).
To są **podpowiedzi**, nie zamknięta lista. Pole modelu w UI jest tekstowe z
datalistą, więc można wpisać dowolny identyfikator — konto może mieć dostęp do
modeli, których tu nie ma, a nowe wychodzą szybciej, niż aktualizuje się kod.
Puste pole = model domyślny dostawcy.
Uwaga o pewności danych:
* modele **Anthropic** pochodzą z oficjalnej dokumentacji API (okna kontekstu
i limity wyjścia zgadzają się z `app/llm/limits.py`);
* modele **OpenAI** to podpowiedzi — nie weryfikowałem ich katalogu, więc
traktuj je jako wygodę, a nie źródło prawdy;
* modele **lokalne** zależą wyłącznie od tego, co masz pobrane w Ollamie/vLLM.
Katalog można nadpisać/rozszerzyć zmienną `<DOSTAWCA>_MODELS` (lista po przecinku),
np. `OPENAI_MODELS="gpt-5,gpt-4o"`.
"""
from __future__ import annotations
import os
from app.llm.limits import limits_for
# dostawca -> [(id modelu, krótki opis dla człowieka)]
_CATALOG: dict[str, list[tuple[str, str]]] = {
"anthropic": [
("claude-opus-4-8", "Opus 4.8 — domyślny, bardzo zdolny, 1M kontekstu"),
("claude-fable-5", "Fable 5 — najbardziej zdolny, do najtrudniejszych zadań"),
("claude-sonnet-5", "Sonnet 5 — szybszy i tańszy, jakość blisko Opusa"),
("claude-opus-4-7", "Opus 4.7 — poprzednia generacja Opusa"),
("claude-haiku-4-5", "Haiku 4.5 — najszybszy i najtańszy, mniejsze okno"),
],
"openai": [
("gpt-4o-mini", "GPT-4o mini — tani i szybki"),
("gpt-4o", "GPT-4o"),
("gpt-5", "GPT-5 — jeśli Twoje konto ma dostęp"),
("gpt-4.1", "GPT-4.1"),
("gpt-4.1-mini", "GPT-4.1 mini"),
],
"local": [
("llama3.1:8b", "Llama 3.1 8B"),
("llama3.2", "Llama 3.2"),
("qwen2.5", "Qwen 2.5 — większe okno kontekstu"),
("mistral", "Mistral"),
],
}
def models_for(provider: str) -> list[dict]:
"""Podpowiedzi modeli dla dostawcy, wraz z oknem kontekstu.
Okno kontekstu podajemy, bo wprost przekłada się na opcję „maksymalny
kontekst modelu" — użytkownik widzi, na ile budżetu promptu może liczyć.
"""
override = os.getenv(f"{provider.upper()}_MODELS", "").strip()
if override:
entries = [(m.strip(), "") for m in override.split(",") if m.strip()]
else:
entries = _CATALOG.get(provider, [])
out = []
for model_id, label in entries:
context_window, max_output = limits_for(provider, model_id)
out.append({
"id": model_id,
"label": label or model_id,
"context_window": context_window,
"max_output": max_output,
})
return out
def catalog() -> dict[str, list[dict]]:
"""Pełny katalog dla UI — jedno żądanie zamiast trzech."""
return {provider: models_for(provider) for provider in ("local", "anthropic", "openai")}
+13 -64
View File
@@ -4,26 +4,13 @@ Domyślny jest **model lokalny**: prompt niesie oryginalne opisy z baz, więc
domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
przez konfigurację albo pojedyncze żądanie.
Konfiguracja jest **per dostawca**, bo UI pozwala przełączać go przy każdym żądaniu.
Wspólne `LLM_*` nie wystarczy: ustawienie `LLM_BASE_URL` na lokalny model kierowałoby
tam także żądania do OpenAI, a `LLM_MODEL=llama3.1:8b` kazałoby Anthropic użyć modelu
llama. Dlatego każdy dostawca ma własny komplet zmiennych.
Zmienne środowiskowe:
LLM_PROVIDER local (domyślnie) | openai | anthropic — dostawca domyślny
LLM_TIMEOUT sekundy (domyślnie 120)
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
<DOSTAWCA>_MODEL / _BASE_URL / _API_KEY — konfiguracja konkretnego dostawcy:
LOCAL_MODEL, LOCAL_BASE_URL (klucz zwykle zbędny)
OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_API_KEY
ANTHROPIC_MODEL, ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY
Klucze WYŁĄCZNIE z sekretu — nigdy w repo, w UI ani w logach.
Zgodność wstecz: wspólne `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` nadal działają,
ale stosują się TYLKO do dostawcy domyślnego (LLM_PROVIDER) — czyli konfiguracja
instalacji jednodostawcowej zostaje nietknięta, a pozostali dostawcy jej nie dziedziczą.
LLM_PROVIDER local (domyślnie) | openai | anthropic
LLM_MODEL nazwa modelu (domyślna zależy od dostawcy)
LLM_BASE_URL adres API (domyślnie: lokalny serwer zgodny z OpenAI)
LLM_API_KEY klucz — WYŁĄCZNIE z sekretu; niepotrzebny dla modelu lokalnego
LLM_TIMEOUT sekundy (domyślnie 120)
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
"""
from __future__ import annotations
@@ -40,11 +27,7 @@ PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
_DEFAULT_MODEL = {
LOCAL: "llama3.1:8b",
OPENAI: "gpt-4o-mini",
# Opus 4.8 świadomie zamiast Sonnet 5: Sonnet uruchamia myślenie adaptacyjne,
# gdy pominąć parametr `thinking`, a jego tokeny liczą się do max_tokens —
# przy ciasnym limicie cała tura wychodziła jako samo myślenie z pustym
# tekstem. To była przyczyna pustych odpowiedzi na Anthropicu.
ANTHROPIC: "claude-opus-4-8",
ANTHROPIC: "claude-sonnet-5",
}
_DEFAULT_URL = {
# Ollama i vLLM wystawiają zgodne API pod /v1
@@ -66,54 +49,20 @@ def timeout() -> float:
return float(os.getenv("LLM_TIMEOUT", "120"))
def setting(provider: str, suffix: str, fallback: str = "") -> str:
"""Ustawienie dostawcy: <DOSTAWCA>_<SUFIKS> → LLM_<SUFIKS> → wbudowana domyślna.
Wspólne `LLM_*` stosuje się WYŁĄCZNIE do dostawcy domyślnego — inaczej adres
lokalnego modelu przejąłby żądania do chmury (i odwrotnie).
"""
specific = os.getenv(f"{provider.upper()}_{suffix}")
if specific:
return specific
if provider == default_provider_name():
generic = os.getenv(f"LLM_{suffix}")
if generic:
return generic
return fallback
def resolve_model(name: str | None = None, model: str | None = None) -> tuple[str, str]:
"""(dostawca, model) BEZ budowania dostawcy — czyli bez wymogu klucza API.
Rozmiar budżetu promptu zależy tylko od okna kontekstu modelu, więc nie może
zależeć od tego, czy klucz jest już skonfigurowany.
"""
provider = (name or default_provider_name()).lower()
if provider not in PROVIDERS:
provider = default_provider_name()
chosen = (model or "").strip() or setting(provider, "MODEL", _DEFAULT_MODEL[provider])
return provider, chosen
def build_provider(name: str | None = None, model: str | None = None) -> LLMProvider:
"""Dostawca modelu. `model` z żądania wygrywa nad konfiguracją — użytkownik
wybiera model w UI, a konfiguracja podaje tylko wartość domyślną."""
def build_provider(name: str | None = None) -> LLMProvider:
name = (name or default_provider_name()).lower()
if name not in PROVIDERS:
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
model = (model or "").strip() or setting(name, "MODEL", _DEFAULT_MODEL[name])
base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
api_key = setting(name, "API_KEY")
model = os.getenv("LLM_MODEL") or _DEFAULT_MODEL[name]
base_url = os.getenv("LLM_BASE_URL") or _DEFAULT_URL[name]
api_key = os.getenv("LLM_API_KEY", "")
if name in (OPENAI, ANTHROPIC) and not api_key:
raise LLMError(
f"Brak klucza dla dostawcy {name} — ustaw {name.upper()}_API_KEY "
f"(z sekretu). Model lokalny klucza nie wymaga."
)
if name == ANTHROPIC:
return AnthropicProvider(base_url, model, api_key, timeout())
if name == OPENAI:
if not api_key:
raise LLMError("Brak LLM_API_KEY — dostawca openai wymaga klucza.")
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
leaves_lan=True)
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
-133
View File
@@ -1,133 +0,0 @@
"""Okna kontekstu modeli i planowanie budżetu tokenów (LOG-30/31).
Po co to istnieje: horoskop MA powstać niezależnie od objętości promptu. Żeby to
zagwarantować, trzeba wiedzieć dwie rzeczy o każdym modelu — ile zmieści na
wejściu (okno kontekstu) i ile maksymalnie wypisze na wyjściu. Bez tego łatwo
wysłać prompt, który wypełnia całe okno i **nie zostawia miejsca na odpowiedź** —
model kończy wtedy na `max_tokens` z pustą albo uciętą treścią.
Zasada naczelna: **zawsze rezerwuj miejsce na odpowiedź.** Budżet promptu liczy
się jako `okno_kontekstu zarezerwowane_wyjście margines`, nigdy odwrotnie.
Wartości są zaszyte jako rozsądne domyślne i nadpisywalne środowiskiem
(`<DOSTAWCA>_CONTEXT_WINDOW`, `<DOSTAWCA>_MAX_OUTPUT`) — modele wychodzą szybciej,
niż aktualizuje się ten plik.
"""
from __future__ import annotations
import os
# model -> (okno kontekstu, maksymalne wyjście) w tokenach
_MODEL_LIMITS: dict[str, tuple[int, int]] = {
# Anthropic
"claude-opus-4-8": (1_000_000, 128_000),
"claude-opus-4-7": (1_000_000, 128_000),
"claude-opus-4-6": (1_000_000, 128_000),
"claude-sonnet-5": (1_000_000, 128_000),
"claude-sonnet-4-6": (1_000_000, 128_000),
"claude-fable-5": (1_000_000, 128_000),
"claude-haiku-4-5": (200_000, 64_000),
# OpenAI
"gpt-4o": (128_000, 16_384),
"gpt-4o-mini": (128_000, 16_384),
"gpt-4.1": (1_000_000, 32_768),
"gpt-4.1-mini": (1_000_000, 32_768),
# lokalne (Ollama/vLLM) — zwykle małe okno, dlatego ostrożna domyślna
"llama3.1": (8_192, 4_096),
"llama3.2": (8_192, 4_096),
"qwen2.5": (32_768, 8_192),
"mistral": (32_768, 8_192),
}
# gdy modelu nie ma w tabeli — zachowawczo, żeby nie obiecywać nieistniejącego okna
_FALLBACK: dict[str, tuple[int, int]] = {
"anthropic": (200_000, 32_000),
"openai": (128_000, 16_384),
"local": (8_192, 4_096),
}
# ile tokenów zostawiamy jako bufor na narzut protokołu i niedokładność liczenia
SAFETY_MARGIN = 2_000
# poniżej tylu tokenów wyjścia nie ma sensu wołać modelu — nie zmieści horoskopu
MIN_OUTPUT = 1_500
# powyżej tylu tokenów promptu ostrzegamy użytkownika (nadal pozwalając wysłać)
WARN_PROMPT_TOKENS = 90_000
def _env_int(provider: str, suffix: str) -> int | None:
raw = os.getenv(f"{provider.upper()}_{suffix}")
if not raw:
return None
try:
value = int(raw)
except ValueError:
return None
return value if value > 0 else None
def limits_for(provider: str, model: str) -> tuple[int, int]:
"""(okno kontekstu, maksymalne wyjście) dla modelu — z nadpisaniem z ENV.
Dopasowanie po prefiksie, bo nazwy modeli lokalnych niosą tag (`llama3.1:8b`).
"""
env_ctx = _env_int(provider, "CONTEXT_WINDOW")
env_out = _env_int(provider, "MAX_OUTPUT")
key = (model or "").strip().lower()
known: tuple[int, int] | None = _MODEL_LIMITS.get(key)
if known is None:
for name, pair in _MODEL_LIMITS.items():
if key.startswith(name):
known = pair
break
if known is None:
known = _FALLBACK.get(provider, _FALLBACK["local"])
return (env_ctx or known[0], env_out or known[1])
def plan(provider: str, model: str, prompt_tokens: int,
want_output: int | None = None) -> dict:
"""Ile tokenów wyjścia zamówić dla promptu tej wielkości.
Zwraca plan z jawną diagnostyką — UI ma z czego zbudować ostrzeżenie, a błąd
ma czym wytłumaczyć, dlaczego się nie udało.
"""
context_window, model_max_output = limits_for(provider, model)
room = context_window - prompt_tokens - SAFETY_MARGIN
target = want_output or model_max_output
max_output = max(0, min(model_max_output, target, room))
warnings: list[str] = []
if prompt_tokens > WARN_PROMPT_TOKENS:
warnings.append(
f"Prompt ma ~{prompt_tokens} tokenów — to dużo. Zapytanie zostanie wysłane, "
f"ale potrwa dłużej i będzie odpowiednio kosztowne."
)
if max_output < MIN_OUTPUT:
warnings.append(
f"Po zmieszczeniu promptu zostaje tylko {max_output} tokenów na odpowiedź "
f"(minimum {MIN_OUTPUT}). Zmniejsz budżet promptu albo wybierz model "
f"z większym oknem kontekstu."
)
return {
"provider": provider,
"model": model,
"context_window": context_window,
"model_max_output": model_max_output,
"prompt_tokens": prompt_tokens,
"max_output": max_output,
"fits": max_output >= MIN_OUTPUT,
"warnings": warnings,
}
def prompt_token_budget(provider: str, model: str, reserve_output: int | None = None) -> int:
"""Ile tokenów promptu wolno wysłać, ZAWSZE zostawiając miejsce na odpowiedź.
To jest podstawa opcji „maksymalny kontekst modelu" w UI.
"""
context_window, model_max_output = limits_for(provider, model)
reserve = reserve_output or model_max_output
return max(0, context_window - reserve - SAFETY_MARGIN)
+36 -228
View File
@@ -5,24 +5,9 @@ vLLM, llama.cpp) i OpenAI mówią **tym samym** protokołem `/chat/completions`,
więc jedna implementacja obsługuje oba — różni je tylko adres i klucz. Anthropic
ma własny kształt `/v1/messages`, stąd druga klasa. Mniej zależności, mniej
powierzchni ataku, pełna kontrola nad tym, co wychodzi z sieci.
**Gwarancja niepustej odpowiedzi.** Horoskop ma powstać niezależnie od objętości
promptu, więc `generate()` nie jest pojedynczym strzałem, tylko pętlą:
1. wyślij turę z policzonym limitem wyjścia,
2. jeśli model urwał na limicie — dopisz turę „kontynuuj" i sklej tekst,
3. jeśli tura nie dała ani znaku tekstu — ponów z podpowiedzią,
4. dopiero brak tekstu po wszystkich próbach jest błędem (z diagnostyką).
Kontynuacja jest pewniejsza niż jedno wielkie żądanie: każda tura mieści się
w timeoucie HTTP, a długość odpowiedzi przestaje być ograniczona jedną turą.
**Anthropic i myślenie.** Modele Claude potrafią mieć włączone myślenie, którego
tokeny liczą się do `max_tokens`. Przy ciasnym limicie cała tura potrafi wyjść
jako same bloki `thinking` z pustym tekstem — dokładnie ten objaw, który
zgłoszono. Traktujemy taką turę jak ucięcie i kontynuujemy, zamiast zwracać pustkę.
"""
from __future__ import annotations
import os
import time
import httpx
@@ -32,23 +17,6 @@ from app.llm.base import Completion, LLMError, LLMProvider
RETRY_STATUSES = {429, 500, 502, 503, 504}
MAX_ATTEMPTS = 3
# ile razy wolno poprosić model o dokończenie urwanej odpowiedzi
MAX_CONTINUATIONS = 12
# ile tokenów zamawiać na jedną turę — mieści się w timeoucie, a pętla i tak
# dociągnie resztę; zbyt duża wartość ryzykuje zerwanie połączenia w trakcie
TURN_TOKENS_CAP = 16_000
_CONTINUE = (
"Kontynuuj dokładnie od miejsca, w którym przerwałeś — nie powtarzaj tego, "
"co już napisałeś, i nie zaczynaj od nowa. Jeśli skończyłeś całą odpowiedź, "
"napisz wyłącznie: KONIEC"
)
_NUDGE = (
"Nie otrzymałem żadnej treści. Napisz odpowiedź zgodnie z powyższym poleceniem, "
"zaczynając od razu od treści horoskopu."
)
_DONE_MARKER = "KONIEC"
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict:
"""POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx."""
@@ -69,8 +37,8 @@ def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) ->
time.sleep(2 ** attempt)
continue
raise LLMError(
f"Model nie odpowiedział w czasie {timeout:.0f}s. Zwiększ LLM_TIMEOUT "
f"albo zmniejsz budżet promptu."
f"Model nie odpowiedział w czasie {timeout:.0f}s. Dłuższe horoskopy "
f"wymagają większego LLM_TIMEOUT albo mniejszego budżetu promptu."
) from e
except httpx.HTTPError as e:
last = e
@@ -81,137 +49,7 @@ def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) ->
raise LLMError(f"Nie udało się wywołać modelu: {last}")
def _merge_usage(total: dict, turn: dict) -> dict:
"""Sumuje zużycie tokenów przez wszystkie tury jednej odpowiedzi."""
for key, value in (turn or {}).items():
if isinstance(value, int):
total[key] = total.get(key, 0) + value
return total
def _join(parts: list[str]) -> str:
return "".join(parts).strip()
def _explain_empty(turns: int, usage: dict, stop: str | None) -> str:
detail = []
if stop:
detail.append(f"powód zakończenia: {stop}")
for key in ("completion_tokens", "output_tokens"):
if usage.get(key) is not None:
detail.append(f"tokeny odpowiedzi: {usage[key]}")
break
suffix = f" ({', '.join(detail)})" if detail else ""
return (
f"Model nie zwrócił żadnej treści po {turns} próbach{suffix}. "
f"Najczęstsza przyczyna: prompt wypełnił okno kontekstu i nie zostało miejsca "
f"na odpowiedź. Zmniejsz budżet promptu albo wybierz model z większym oknem."
)
class _Driver:
"""Wspólna pętla: tura → ewentualna kontynuacja → sklejony tekst.
Podklasy dostarczają tylko `_turn()` — reszta (kontynuacje, ponawianie pustej
tury, sumowanie zużycia) jest identyczna dla obu protokołów.
"""
name: str
model: str
leaves_lan: bool
def _turn(self, messages: list[dict], max_tokens: int):
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
raise NotImplementedError
def generate(self, prompt: str, max_tokens: int, on_event=None) -> Completion:
"""`on_event(dict)` dostaje zdarzenia postępu — UI pokazuje z nich log.
Raportujemy KAŻDĄ turę, bo to ona trwa; bez tego pasek postępu byłby
ozdobnikiem, a nie informacją."""
def emit(kind: str, message: str, **extra):
if on_event:
on_event({"type": kind, "message": message, **extra})
messages: list[dict] = [{"role": "user", "content": prompt}]
parts: list[str] = []
usage: dict = {}
model_name = self.model
remaining = max(max_tokens, 256)
stop: str | None = None
turns = 0
nudged = False
while turns <= MAX_CONTINUATIONS:
turns += 1
budget = max(256, min(remaining, TURN_TOKENS_CAP))
emit("turn_start",
f"Tura {turns}: wysyłam do modelu {self.model} (limit {budget} tokenów)…",
turn=turns)
started = time.monotonic()
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
took = time.monotonic() - started
_merge_usage(usage, turn_usage)
# Odejmujemy tokeny FAKTYCZNIE wyprodukowane, nie zamówiony limit tury.
# Inaczej pierwsza tura zjadałaby cały budżet i urwana odpowiedź nigdy
# nie doczekałaby się kontynuacji — wracałby do użytkownika fragment
# udający całość.
produced = (turn_usage or {}).get("completion_tokens")
if produced is None:
produced = (turn_usage or {}).get("output_tokens")
if produced is None:
produced = max(1, int(len(text) / 3.6))
remaining -= max(1, int(produced))
emit("turn_end",
f"Tura {turns}: odebrano {len(text.strip())} znaków w {took:.1f}s"
+ (" — odpowiedź urwana, poproszę o dokończenie" if truncated else ""),
turn=turns, chars=len(text.strip()), truncated=truncated)
chunk = text.strip()
if chunk:
if chunk.endswith(_DONE_MARKER): # model zgłasza koniec
parts.append(("\n" if parts else "") + chunk[: -len(_DONE_MARKER)].rstrip())
break
parts.append(("\n" if parts else "") + chunk)
if not truncated:
break
elif not truncated:
# pusta i NIE ucięta: jedna próba z podpowiedzią, potem koniec
if nudged or parts:
break
nudged = True
messages = messages + [
{"role": "assistant", "content": ""},
{"role": "user", "content": _NUDGE},
]
continue
# ucięta (także tura złożona z samego myślenia) — poproś o dokończenie
if remaining < 256:
break
messages = [
{"role": "user", "content": prompt},
{"role": "assistant", "content": _join(parts) or ""},
{"role": "user", "content": _CONTINUE},
]
final = _join(parts)
if not final:
raise LLMError(_explain_empty(turns, usage, stop))
emit("generated", f"Gotowe: {len(final)} znaków w {turns} turach.",
chars=len(final), turns=turns)
usage["turns"] = turns
return Completion(text=final, model=model_name, provider=self.name,
leaves_lan=self.leaves_lan, usage=usage)
def count_tokens(self, prompt: str) -> int:
"""Szacunek tokenów promptu. Dostawcy z własnym licznikiem nadpisują."""
return int(len(prompt) / 3.6)
class ChatCompletionsProvider(_Driver, LLMProvider):
class ChatCompletionsProvider(LLMProvider):
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
def __init__(self, name: str, base_url: str, model: str, api_key: str = "",
@@ -229,20 +67,24 @@ class ChatCompletionsProvider(_Driver, LLMProvider):
h["Authorization"] = f"Bearer {self.api_key}"
return h
def _turn(self, messages: list[dict], max_tokens: int):
def generate(self, prompt: str, max_tokens: int) -> Completion:
data = _post_with_retry(
f"{self.base_url}/chat/completions", self._headers(),
{"model": self.model, "max_tokens": max_tokens, "messages": messages},
{
"model": self.model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
},
self.timeout,
)
try:
choice = data["choices"][0]
text = choice["message"].get("content") or ""
text = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as e:
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
stop = choice.get("finish_reason")
return (text, stop == "length", data.get("usage") or {},
data.get("model", self.model), stop)
return Completion(
text=text, model=data.get("model", self.model), provider=self.name,
leaves_lan=self.leaves_lan, usage=data.get("usage") or {},
)
def health(self) -> dict:
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
@@ -255,7 +97,7 @@ class ChatCompletionsProvider(_Driver, LLMProvider):
return info
class AnthropicProvider(_Driver, LLMProvider):
class AnthropicProvider(LLMProvider):
"""Protokół Anthropic `/v1/messages`."""
leaves_lan = True
@@ -268,68 +110,34 @@ class AnthropicProvider(_Driver, LLMProvider):
self.api_key = api_key
self.timeout = timeout
def _headers(self) -> dict:
return {
"Content-Type": "application/json",
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
}
def _thinking(self) -> dict:
"""Konfiguracja myślenia. Domyślnie adaptacyjne — podnosi jakość tekstu.
UWAGA: tokeny myślenia liczą się do `max_tokens`, więc przy ciasnym limicie
cała tura potrafi wyjść jako samo myślenie z pustym tekstem. Pętla
kontynuacji to obsługuje, ale ANTHROPIC_THINKING=off wyłącza myślenie,
gdy zależy nam na przewidywalnym zużyciu tokenów.
"""
mode = os.getenv("ANTHROPIC_THINKING", "adaptive").lower()
if mode in ("off", "disabled", "0", "false"):
return {"thinking": {"type": "disabled"}}
return {
"thinking": {"type": "adaptive"},
"output_config": {"effort": os.getenv("ANTHROPIC_EFFORT", "high")},
}
def _turn(self, messages: list[dict], max_tokens: int):
def generate(self, prompt: str, max_tokens: int) -> Completion:
if not self.api_key:
raise LLMError("Brak ANTHROPIC_API_KEY — dostawca anthropic wymaga klucza.")
payload = {"model": self.model, "max_tokens": max_tokens, "messages": messages}
payload.update(self._thinking())
data = _post_with_retry(f"{self.base_url}/v1/messages", self._headers(),
payload, self.timeout)
raise LLMError("Brak LLM_API_KEY — dostawca anthropic wymaga klucza.")
data = _post_with_retry(
f"{self.base_url}/v1/messages",
{
"Content-Type": "application/json",
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
},
{
"model": self.model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
},
self.timeout,
)
try:
blocks = data["content"]
text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
text = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text")
except (KeyError, TypeError) as e:
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
stop = data.get("stop_reason")
# tura złożona z samego myślenia = budżet poszedł na rozumowanie; traktujemy
# jak ucięcie, żeby pętla poprosiła o treść zamiast zwrócić pustkę
thinking_only = not text.strip() and any(
b.get("type") in ("thinking", "redacted_thinking") for b in blocks
return Completion(
text=text, model=data.get("model", self.model), provider=self.name,
leaves_lan=True, usage=data.get("usage") or {},
)
return (text, stop == "max_tokens" or thinking_only, data.get("usage") or {},
data.get("model", self.model), stop)
def count_tokens(self, prompt: str) -> int:
"""Dokładny licznik Anthropic — nie szacunek. Od tego zależy, czy po
zmieszczeniu promptu zostanie miejsce na odpowiedź."""
if not self.api_key:
return super().count_tokens(prompt)
try:
data = _post_with_retry(
f"{self.base_url}/v1/messages/count_tokens", self._headers(),
{"model": self.model, "messages": [{"role": "user", "content": prompt}]},
min(self.timeout, 30.0),
)
return int(data.get("input_tokens") or super().count_tokens(prompt))
except LLMError:
return super().count_tokens(prompt)
def health(self) -> dict:
return {
"provider": self.name, "model": self.model, "leaves_lan": True,
"status": "ok (klucz ustawiony)" if self.api_key else "brak ANTHROPIC_API_KEY",
"status": "ok (klucz ustawiony)" if self.api_key else "brak LLM_API_KEY",
}
+13 -193
View File
@@ -4,7 +4,6 @@ W górę: udostępnia API dla warstwy prezentacji.
W dół: woła warstwę bazodanową (DataClient).
Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
"""
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
from datetime import datetime
@@ -13,17 +12,12 @@ import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from app import link_crypto, security
from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse
from app.service import QueryService
app = FastAPI(title="astrololo · warstwa logiczna")
service = QueryService()
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
link_crypto.install(app, link_crypto.ENV_PRESENTATION_LOGIC, "logiczna")
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
_engine = None
@@ -43,13 +37,8 @@ class PositionsRequest(BaseModel):
lat: float = 0.0
lon: float = 0.0
objects: list[str] | None = None
house_system: str = "whole_sign" # whole_sign | equal | porphyry (PRYMARNY — pod kosmogram)
house_systems: list[str] | None = None # PRE-05: dodatkowe systemy do porównania obok
aspect_orb: float = 8.0 # PRE-06: orb aspektów (konfigurowalny)
aspect_luminary_bonus: float = 2.0 # PRE-06: dodatkowy orb dla Słońca/Księżyca
aspect_minor: bool = False # PRE-06: dołóż aspekty poboczne (30/45/150°)
house_system: str = "whole_sign" # whole_sign | equal | porphyry
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
@@ -71,11 +60,7 @@ def chart_positions(req: PositionsRequest) -> dict:
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
try:
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac,
house_systems=req.house_systems,
aspect_orb=req.aspect_orb,
aspect_luminary_bonus=req.aspect_luminary_bonus,
aspect_minor=req.aspect_minor)
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if req.stations:
@@ -85,67 +70,9 @@ def chart_positions(req: PositionsRequest) -> dict:
st = find_stations(engine, moment, p["name"])
if st:
p["stations"] = st
if req.tables:
from app.engine.tables import build_tables
chart["tables"] = build_tables(engine, moment, chart)
return chart
class PersonMoment(BaseModel):
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
class SynastryRequest(BaseModel):
"""Dwie osoby (PRE-04). Aspekty liczone MIĘDZY horoskopami, nie w środku."""
person_a: PersonMoment
person_b: PersonMoment
zodiac: str = "tropical"
aspect_orb: float = 8.0
aspect_luminary_bonus: float = 2.0
aspect_minor: bool = False
@app.get("/bases")
def bases() -> dict:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
Przelot do warstwy danych — same METADANE, żadnej treści baz."""
from app.clients.data_client import DataClient
try:
return DataClient().bases()
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
@app.post("/chart/synastry")
def chart_synastry(req: SynastryRequest) -> dict:
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
osoby A vs planeta osoby B). Bez interpretacji z bazy — sama siatka aspektów."""
from app.engine import glyphs as GL
from app.engine.aspects import find_cross_aspects
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
engine = get_engine()
a = build_chart(engine, ChartMoment(when_utc=req.person_a.when_utc,
lat=req.person_a.lat, lon=req.person_a.lon), zodiac=req.zodiac)
b = build_chart(engine, ChartMoment(when_utc=req.person_b.when_utc,
lat=req.person_b.lat, lon=req.person_b.lon), zodiac=req.zodiac)
cross = find_cross_aspects(
a["positions"], b["positions"], orb=req.aspect_orb,
luminary_bonus=req.aspect_luminary_bonus, minor=req.aspect_minor)
for c in cross:
c["glyph"] = GL.aspect_glyph(c["aspect"]) # symbol aspektu (LOG-22)
return {"engine": engine.name,
"person_a": {"positions": a["positions"]},
"person_b": {"positions": b["positions"]},
"aspects": cross}
@app.post("/chart/compare")
def chart_compare(req: PositionsRequest) -> dict:
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
@@ -187,8 +114,7 @@ def chart_report(req: ReportRequest) -> dict:
try:
report = build_report(
chart["positions"], DataClient(),
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
per_object_limit=req.limit, group=req.group,
aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
)
except httpx.HTTPError as e:
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
@@ -201,10 +127,8 @@ class PromptRequest(BaseModel):
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
budget: str = "medium" # concise | medium | extensive | huge | max
budget: str = "medium" # concise | medium | extensive
limit: int = 5000
provider: str | None = None # do wyliczenia budżetu „max" wg okna modelu
model: str | None = None
# tylko dla profilu period:
from_date: str | None = None
to_date: str | None = None
@@ -223,7 +147,7 @@ def chart_prompt(req: PromptRequest) -> dict:
"""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.prompt import CHARS_PER_TOKEN, MAX_BUDGET, build_natal_prompt, build_period_prompt
from app.prompt import build_natal_prompt, build_period_prompt
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
@@ -231,17 +155,6 @@ def chart_prompt(req: PromptRequest) -> dict:
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
data_error = None
# Budżet „maksymalny kontekst modelu": limit znaków liczymy z okna kontekstu
# WYBRANEGO modelu, zawsze po odjęciu miejsca zarezerwowanego na odpowiedź.
budget_chars = None
if req.budget == MAX_BUDGET:
from app.llm.factory import resolve_model
from app.llm.limits import prompt_token_budget
# celowo bez build_provider(): budżet zależy TYLKO od okna kontekstu modelu,
# więc nie może wymagać skonfigurowanego klucza API
provider_name, model_name = resolve_model(req.provider, req.model)
budget_chars = int(prompt_token_budget(provider_name, model_name) * CHARS_PER_TOKEN)
# Warstwa danych dokłada wyłącznie WSKAZANIA. Wyliczenia (horoskop, oś czasu) są
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
try:
@@ -252,12 +165,11 @@ def chart_prompt(req: PromptRequest) -> dict:
try:
report = build_report(
chart["positions"], DataClient(),
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
per_object_limit=req.limit,
aspects=chart.get("aspects"), per_object_limit=req.limit,
)
except httpx.HTTPError as e:
data_error = f"Warstwa danych niedostępna: {e}"
out = build_natal_prompt(chart, report, req.budget, label, budget_chars)
out = build_natal_prompt(chart, report, req.budget, label)
elif req.profile == "period":
if not (req.from_date and req.to_date):
@@ -277,7 +189,7 @@ def chart_prompt(req: PromptRequest) -> dict:
except httpx.HTTPError as e:
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
out = build_period_prompt(chart, events, req.from_date, req.to_date,
req.budget, label, budget_chars)
req.budget, label)
else:
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
except ValueError as e: # nieznany budżet
@@ -290,7 +202,8 @@ def chart_prompt(req: PromptRequest) -> dict:
class HoroscopeRequest(PromptRequest):
"""Jak PromptRequest (niesie już provider i model) + limit wyjścia (LOG-31)."""
"""Jak PromptRequest + wybór dostawcy modelu (LOG-31)."""
provider: str | None = None # local (dom.) | openai | anthropic
max_tokens: int | None = None
@@ -303,26 +216,12 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
"""
from app.llm.base import LLMError
from app.llm.factory import build_provider
from app.llm.limits import plan
from app.llm.factory import build_provider, max_tokens
out = chart_prompt(req) # ten sam prompt co w podglądzie
try:
provider = build_provider(req.provider, req.model)
# Ile tokenów ma naprawdę ten prompt i ile zostaje na odpowiedź. Anthropic
# liczy dokładnie (własny endpoint), reszta szacuje — od tego zależy, czy
# w oknie kontekstu w ogóle zmieści się miejsce na horoskop.
prompt_tokens = provider.count_tokens(out["prompt"])
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
out["token_plan"] = budget
if budget["warnings"]:
out["warnings"] = budget["warnings"]
if not budget["fits"]:
out["llm_error"] = " ".join(budget["warnings"])
return out
result = provider.generate(out["prompt"], budget["max_output"])
provider = build_provider(req.provider)
result = provider.generate(out["prompt"], req.max_tokens or max_tokens())
except LLMError as e:
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
out["llm_error"] = str(e)
@@ -338,85 +237,6 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
return out
@app.post("/chart/horoscope/stream")
def chart_horoscope_stream(req: HoroscopeRequest):
"""To samo co /chart/horoscope, ale strumieniuje POSTĘP w trakcie pracy.
Pisanie horoskopu trwa minutami — bez sygnału aplikacja wygląda na zawieszoną.
Strumień (NDJSON, jedna linia = jedno zdarzenie) niesie RZECZYWISTE etapy:
budowę promptu, limity modelu i każdą turę generowania. Ostatnie zdarzenie
(`result`) ma identyczny kształt co odpowiedź zwykłego endpointu.
"""
from fastapi.responses import StreamingResponse
from app.progress import stream
def work(emit) -> dict:
from app.llm.base import LLMError
from app.llm.factory import build_provider
from app.llm.limits import plan
emit({"type": "stage", "message": "Liczę horoskop i szukam wskazań w bazach…"})
out = chart_prompt(req)
st = out.get("stats", {})
emit({"type": "stage", "message":
f"Prompt gotowy: {st.get('chars', 0)} znaków, "
f"wskazań {st.get('included', 0)}"
+ (f", pominięto {st['omitted']}" if st.get("omitted") else "")})
if out.get("data_error"):
emit({"type": "warn", "message": out["data_error"]})
try:
provider = build_provider(req.provider, req.model)
emit({"type": "stage", "message":
f"Dostawca: {provider.name}, model: {provider.model}"
+ ("" if not provider.leaves_lan else " — dane opuszczają sieć")})
emit({"type": "stage", "message": "Liczę tokeny promptu…"})
prompt_tokens = provider.count_tokens(out["prompt"])
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
out["token_plan"] = budget
emit({"type": "stage", "message":
f"Prompt {prompt_tokens} tok. · okno modelu {budget['context_window']} · "
f"na odpowiedź {budget['max_output']}"})
for warning in budget["warnings"]:
emit({"type": "warn", "message": warning})
if budget["warnings"]:
out["warnings"] = budget["warnings"]
if not budget["fits"]:
out["llm_error"] = " ".join(budget["warnings"])
return out
result = provider.generate(out["prompt"], budget["max_output"], on_event=emit)
except LLMError as e:
emit({"type": "warn", "message": f"Model zawiódł: {e}"})
out["llm_error"] = str(e)
return out
out.update(horoscope=result.text, provider=result.provider, model=result.model,
leaves_lan=result.leaves_lan, usage=result.usage)
return out
return StreamingResponse(
stream(work),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)
@app.get("/llm/models")
def llm_models() -> dict:
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
To nie jest lista zamknięta: pole modelu jest tekstowe, więc można wpisać
dowolny identyfikator, do którego konto ma dostęp.
"""
from app.llm.catalog import catalog
from app.llm.factory import _DEFAULT_MODEL
return {"providers": catalog(), "defaults": dict(_DEFAULT_MODEL)}
@app.get("/llm/health")
def llm_health(provider: str | None = None) -> dict:
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
-70
View File
@@ -1,70 +0,0 @@
"""Strumień postępu długiej operacji (NDJSON).
Po co: pisanie horoskopu trwa — czasem minuty. Bez sygnału aplikacja wygląda na
zawieszoną. Zamiast udawanego paska postępu strumieniujemy **rzeczywiste**
zdarzenia z kolejnych etapów, żeby log pokazywał to, co faktycznie się dzieje.
Dlaczego NDJSON, a nie SSE: `EventSource` w przeglądarce obsługuje wyłącznie GET,
a to jest POST z ciałem. Strumień „jedna linia = jeden obiekt JSON" czyta się
zwykłym `fetch()` i jest trywialny do sparsowania.
Dlaczego wątek: właściwa praca (silnik, baza, model) jest synchroniczna. Puszczamy
ją w wątku roboczym, a generator odpompowuje kolejkę zdarzeń — dzięki temu
zdarzenia docierają w trakcie pracy, a nie dopiero na końcu.
"""
from __future__ import annotations
import json
import queue
import threading
import traceback
from collections.abc import Iterator
from typing import Any, Callable
_HEARTBEAT_SECONDS = 10.0
_DONE = object()
def line(kind: str, message: str, **extra: Any) -> str:
return json.dumps({"type": kind, "message": message, **extra}, ensure_ascii=False) + "\n"
def stream(work: Callable[[Callable[[dict], None]], dict]) -> Iterator[str]:
"""Uruchamia `work(emit)` w wątku i strumieniuje zdarzenia w czasie rzeczywistym.
`work` dostaje funkcję `emit(zdarzenie)` i zwraca końcowy wynik, który leci
jako ostatnie zdarzenie typu `result`. Wyjątek zamienia się w zdarzenie `error`
— połączenie nigdy nie urywa się bez wyjaśnienia.
"""
events: queue.Queue = queue.Queue()
def emit(event: dict) -> None:
events.put(event)
def run() -> None:
try:
result = work(emit)
events.put({"type": "result", "message": "Gotowe.", "result": result})
except Exception as e: # noqa: BLE001 — zgłaszamy KAŻDY błąd
events.put({
"type": "error",
"message": f"{type(e).__name__}: {e}",
"detail": traceback.format_exc(limit=3),
})
finally:
events.put(_DONE)
worker = threading.Thread(target=run, daemon=True)
worker.start()
while True:
try:
event = events.get(timeout=_HEARTBEAT_SECONDS)
except queue.Empty:
# cisza dłuższa niż heartbeat: dajemy znak życia, żeby pośredniki
# (proxy, load balancer) nie uznały połączenia za martwe
yield line("ping", "")
continue
if event is _DONE:
break
yield json.dumps(event, ensure_ascii=False) + "\n"
+5 -23
View File
@@ -28,14 +28,8 @@ BUDGETS: dict[str, int] = {
"concise": 4_000,
"medium": 12_000,
"extensive": 30_000,
"huge": 120_000,
# „maksymalny kontekst modelu" — wyliczany dynamicznie z okna kontekstu
# wybranego modelu, ZAWSZE po odjęciu miejsca zarezerwowanego na odpowiedź.
# Wartość poniżej jest tylko zapasem, gdy limity modelu są nieznane.
"max": 400_000,
}
DEFAULT_BUDGET = "medium"
MAX_BUDGET = "max"
MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji)
CHARS_PER_TOKEN = 4.0 # zgrubny szacunek tokenów do podglądu w UI
@@ -200,11 +194,6 @@ def _chart_section(chart: dict, moment_label: str | None) -> str:
if meta:
lines.append(" · ".join(meta))
# Model interpretuje domy, więc musi wiedzieć, że podział pochodzi z innego
# systemu, niż zamówiono — inaczej napisze „Twój Placidus" o Porphyrym.
for w in chart.get("house_warnings") or []:
lines.append(f"\n{w}")
if chart.get("positions"):
lines.append("\n## Pozycje")
for p in chart["positions"]:
@@ -295,16 +284,11 @@ def _assemble(task: str, chart_sec: str, ind_sec: str, output: str) -> str:
return "\n\n".join([task, chart_sec, ind_sec, output, f"# ZASTRZEŻENIE\n{DISCLAIMER}"])
def _budget_chars(budget: str, budget_chars: int | None = None) -> int:
"""Limit znaków promptu. `budget_chars` nadpisuje tabelę — używane dla opcji
„maksymalny kontekst modelu", gdzie limit zależy od wybranego modelu i musi
być policzony po odjęciu miejsca zarezerwowanego na odpowiedź."""
def _budget_chars(budget: str) -> int:
if budget not in BUDGETS:
raise ValueError(
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
)
if budget_chars and budget_chars > 0:
return budget_chars
return BUDGETS[budget]
@@ -321,10 +305,9 @@ def _finish(prompt: str, budget: str, limit: int, stats: dict, profile: str) ->
def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
moment_label: str | None = None,
budget_chars: int | None = None) -> dict:
moment_label: str | None = None) -> dict:
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
limit = _budget_chars(budget, budget_chars)
limit = _budget_chars(budget)
chart_sec = _chart_section(chart, moment_label)
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
@@ -333,10 +316,9 @@ def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
def build_period_prompt(chart: dict, events: list[dict], from_date: str, to_date: str,
budget: str = DEFAULT_BUDGET, moment_label: str | None = None,
budget_chars: int | None = None) -> dict:
budget: str = DEFAULT_BUDGET, moment_label: str | None = None) -> dict:
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
limit = _budget_chars(budget, budget_chars)
limit = _budget_chars(budget)
chart_sec = _chart_section(chart, moment_label)
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date}{to_date}**."
-50
View File
@@ -1,50 +0,0 @@
"""Uwierzytelnianie międzywarstwowe (LOG-32).
Warstwa logiczna oddaje treść baz interpretacyjnych, więc samo zalogowanie w
prezentacji nie wystarczy — bez tego kontrolera wystarczyłoby uderzyć w logikę
z pominięciem UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
musi go przynieść w nagłówku X-Astrololo-Token.
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
starcie leci ostrzeżenie.
"""
from __future__ import annotations
import logging
import os
import secrets
from fastapi import Request
from fastapi.responses import JSONResponse
log = logging.getLogger("astrololo.security")
HEADER = "X-Astrololo-Token"
PUBLIC_PATHS = frozenset({"/health"})
def token() -> str:
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
return os.getenv("INTERNAL_TOKEN", "")
def enabled() -> bool:
return bool(token())
def install(app, layer: str) -> None:
if not enabled():
log.warning(
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or not enabled():
return await call_next(request)
got = request.headers.get(HEADER, "")
if not secrets.compare_digest(got, token()):
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
status_code=401)
return await call_next(request)
+3 -29
View File
@@ -114,24 +114,17 @@ def build_report(
positions: list[dict],
data: DataSource,
aspects: list[dict] | None = None,
parallels: list[dict] | None = None,
per_object_limit: int = 5000,
group: bool = False,
) -> dict:
"""positions: pozycje z build_chart (name, sign, direction, house).
Dla każdego obiektu fasety: „w znaku", „w domu", „w aspekcie" (dla każdego
aspektu głównego z listy `aspects`) oraz „paralela deklinacji" (dla paraleli
z listy `parallels` — LOG-07). Duplikaty (ten sam sygnifikator i opis) są
odsiewane wewnątrz każdej fasety.
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty
(ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety.
"""
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
# W bazie paralela deklinacji zapisana jest jako fraza „P. Dec." (nie token [).
# UWAGA: samo „Dec." w bazie to CZĘSTO dekanat („3rd Dec. of [Gem"), więc
# szukamy dokładnie „P. Dec.", inaczej sypnęłoby fałszywymi trafieniami.
PARALLEL_MARK = "P. Dec."
items: list[dict] = []
provider = None
for p in positions:
@@ -187,25 +180,6 @@ def build_report(
"count": len(asp_samples), "samples": asp_samples,
})
# paralele deklinacji (LOG-07) — baza zna je jako „P. Dec.". Tylko `parallel`:
# kontrparaleli i antyscji baza nie opisuje osobnym znacznikiem.
for par in (parallels or []):
if par.get("type") != "parallel" or name not in (par.get("obj1"), par.get("obj2")):
continue
other = par["obj2"] if par["obj1"] == name else par["obj1"]
if other not in PLANET_ABBR:
continue
other_tok = "[" + PLANET_ABBR[other]
par_samples = _facet_samples(rows, [PARALLEL_MARK, other_tok])
if not par_samples: # pokazujemy tylko z trafieniami
continue
facets.append({
"type": "parallel", "label": f"paralela deklinacji z {other}",
"token": f"{PARALLEL_MARK} + {other_tok}",
"orb": par.get("orb"), "allowed": par.get("allowed"),
"count": len(par_samples), "samples": par_samples,
})
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
for f in facets:
f["score"] = score_facet(f)
-2
View File
@@ -4,5 +4,3 @@ httpx>=0.28
pydantic>=2.10
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
skyfield>=1.49
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
+1 -80
View File
@@ -1,62 +1,5 @@
"""Testy aspektów (LOG-06) — czysta matematyka."""
from app.engine.aspects import RIGID_PAIRS, find_aspects, find_cross_aspects, separation
# ── synastria: aspekty MIĘDZY dwoma horoskopami (PRE-04) ──
def test_cross_aspects_between_two_charts():
a = [{"name": "Sun", "decimal": 10.0}, {"name": "Moon", "decimal": 100.0}]
b = [{"name": "Sun", "decimal": 12.0}, {"name": "Mars", "decimal": 70.0}]
pairs = {(c["obj1"], c["obj2"], c["aspect"]) for c in find_cross_aspects(a, b)}
assert ("Sun", "Sun", "conjunction") in pairs # A.Słońce ↔ B.Słońce (2°)
assert ("Sun", "Mars", "sextile") in pairs # A.Słońce(10) ↔ B.Mars(70) = 60°
def test_cross_aspect_obj1_is_person_a_obj2_person_b():
a = [{"name": "Venus", "decimal": 10.0}]
b = [{"name": "Mars", "decimal": 12.0}]
c = find_cross_aspects(a, b)[0]
assert c["obj1"] == "Venus" and c["obj2"] == "Mars"
def test_cross_aspects_do_not_apply_rigid_pair_filter():
"""NN osoby A vs SN osoby B to REALNY aspekt między ludźmi — nie wycinamy go
jak pary sztywnej w jednym horoskopie."""
a = [{"name": "North Node", "decimal": 10.0}]
b = [{"name": "South Node", "decimal": 12.0}]
assert find_cross_aspects(a, b) # niepuste
def test_cross_aspects_no_applying_flag():
"""Dwa natale — brak wspólnego czasu, więc bez applying/separating."""
a = [{"name": "Sun", "decimal": 10.0, "speed": 1.0}]
b = [{"name": "Moon", "decimal": 12.0, "speed": 13.0}]
assert "as" not in find_cross_aspects(a, b)[0]
# ── konfigurowalne aspekty i orby (PRE-06) ──
def test_minor_aspects_only_with_flag():
"""Domyślnie tylko główne; `minor=True` dokłada poboczne (30/45/150°)."""
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 40.0}] # 30° = półsekstyl
assert find_aspects(pos) == [] # główne: brak
assert [a["aspect"] for a in find_aspects(pos, minor=True)] == ["semisextile"]
def test_orb_is_configurable():
"""Węższy orb odsiewa aspekt, który szerszy łapie."""
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 78.0}] # 68° = sextile dev 8°
assert find_aspects(pos, orb=5.0) == []
assert [a["aspect"] for a in find_aspects(pos, orb=10.0)] == ["sextile"]
def test_luminary_bonus_widens_orb_for_lights():
"""Bonus dolicza orb tylko gdy w parze jest Słońce/Księżyc."""
lights = [{"name": "Sun", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}] # dev 9° od sextile
assert find_aspects(lights, orb=8.0, luminary_bonus=0.0) == [] # 9 > 8
assert [a["aspect"] for a in find_aspects(lights, orb=8.0, luminary_bonus=2.0)] == ["sextile"] # 9 < 10
plain = [{"name": "Venus", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}]
assert find_aspects(plain, orb=8.0, luminary_bonus=2.0) == [] # brak światła → bez bonusu
from app.engine.aspects import find_aspects, separation
def test_separation_wraparound():
@@ -112,28 +55,6 @@ def test_separating_when_moving_apart():
assert a["applying"] is False and a["as"] == "S"
def test_rigid_pair_nodes_not_reported():
# SN = NN + 180° z definicji — trywialna opozycja, nie aspekt
pos = [
{"name": "North Node", "decimal": 42.0, "speed": -0.053},
{"name": "South Node", "decimal": 222.0, "speed": -0.053},
{"name": "Sun", "decimal": 42.5, "speed": 0.96},
]
pairs = {frozenset((a["obj1"], a["obj2"])) for a in find_aspects(pos)}
assert frozenset({"North Node", "South Node"}) not in pairs
# aspekty węzłów do innych obiektów zostają nietknięte
assert frozenset({"Sun", "North Node"}) in pairs
assert frozenset({"Sun", "South Node"}) in pairs
def test_no_rigid_pairs_in_real_chart(own_engine, reference_moment):
from app.engine.chart import build_chart
chart = build_chart(own_engine, reference_moment)
found = [a for a in chart["aspects"] if frozenset((a["obj1"], a["obj2"])) in RIGID_PAIRS]
assert not found, f"trywialne aspekty par sztywnych w horoskopie: {found}"
def test_no_as_flag_without_speeds():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert "as" not in find_aspects(pos)[0]
-67
View File
@@ -2,9 +2,6 @@
Waliduje względem astro.com dla horoskopu referencyjnego.
"""
import pytest
from app.engine import houses as H
from app.engine.chart import build_chart
@@ -28,67 +25,3 @@ def test_house_systems_available(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, system)
assert chart["house_system"] == system
assert len(chart["cusps"]) == 12
# ── wiele systemów domów naraz (PRE-05/LOG-05) ──
def test_multiple_house_systems_side_by_side(own_engine, reference_moment):
"""Prymarny zostaje w cusps/house_system; `house_systems` niesie pełen zestaw
do porównania, z prymarnym NA POCZĄTKU i bez duplikatów."""
chart = build_chart(own_engine, reference_moment, "whole_sign",
house_systems=["equal", "porphyry", "whole_sign"])
assert chart["house_system"] == "whole_sign" # prymarny bez zmian
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal", "porphyry"]
for h in chart["house_systems"]:
assert len(h["cusps"]) == 12
def test_single_system_has_no_comparison_block(own_engine, reference_moment):
"""Bez dodatkowych systemów nie zaśmiecamy wyniku."""
assert "house_systems" not in build_chart(own_engine, reference_moment, "whole_sign")
def test_object_gets_a_house_in_every_system(own_engine, reference_moment):
"""Sedno porównania: każdy obiekt ma dom w KAŻDYM systemie, a prymarny jest
spójny z polem `.house`."""
chart = build_chart(own_engine, reference_moment, "whole_sign", house_systems=["porphyry"])
sun = next(p for p in chart["positions"] if p["name"] == "Sun")
assert set(sun["houses"]) == {"whole_sign", "porphyry"}
assert sun["houses"]["whole_sign"] == sun["house"]
def test_primary_always_first_even_if_not_listed(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, "equal", house_systems=["porphyry"])
systems = [h["system"] for h in chart["house_systems"]]
assert systems[0] == "equal" and "porphyry" in systems
def test_unknown_extra_system_is_ignored(own_engine, reference_moment):
"""Nieznana nazwa systemu jest po prostu pomijana, nie wywala horoskopu.
(Placidus był tu kiedyś przykładem systemu nieobsługiwanego — od Etapu 2
jest liczony, więc przykładem musi być nazwa, której naprawdę nie znamy.)"""
chart = build_chart(own_engine, reference_moment, "whole_sign",
house_systems=["nie-ma-takiego-systemu", "equal"])
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal"]
@pytest.mark.parametrize("system", H.SYSTEMS)
def test_every_advertised_system_builds_a_chart(own_engine, reference_moment, system):
"""Każdy system z houses.SYSTEMS musi przejść przez build_chart. Pilnuje,
żeby rozszerzenie SYSTEMS bez przepięcia chart.py nie dało 500 przy wyborze
systemu, który jest na liście, ale nie w użytej funkcji."""
chart = build_chart(own_engine, reference_moment, system)
assert chart["house_system"] == system
assert len(chart["cusps"]) == 12
assert all(1 <= p["house"] <= 12 for p in chart["positions"])
def test_multiple_systems_side_by_side_cover_all(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, H.WHOLE_SIGN,
house_systems=list(H.SYSTEMS))
blocks = chart["house_systems"]
assert blocks[0]["system"] == H.WHOLE_SIGN # prymarny zawsze pierwszy
assert {b["system"] for b in blocks} == set(H.SYSTEMS)
for p in chart["positions"]:
assert set(p["houses"]) == set(H.SYSTEMS)
-114
View File
@@ -1,114 +0,0 @@
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
Sedno: glify muszą być KOMPLETNE (każdy obiekt/znak/aspekt, który liczymy, ma
symbol), DWUKIERUNKOWE (symbol → tekst z powrotem) i TEKSTOWE, nigdy emoji
(DAN-18) — inaczej na kosmogramie wyjdą kolorowe kwadraty zamiast czarno-białych
symboli.
"""
import pytest
from app.engine import glyphs as G
from app.engine.aspects import MAJOR
from app.engine.formats import SIGNS
from app.engine.models import DEFAULT_OBJECTS
VS15, VS16 = "", ""
def _all_glyphs():
return (list(G.PLANET.values()) + list(G.POINT.values()) + list(G.LOT.values())
+ list(G.SIGN.values()) + list(G.ASPECT.values()) + [G.RETROGRADE])
# ------------------------------------------------------------- kompletność
def test_every_default_object_has_a_glyph():
"""Każdy obiekt z kanonicznego zestawu musi mieć symbol — inaczej na kole
zostanie dziura."""
missing = [name for name in DEFAULT_OBJECTS if G.glyph_for(name) is None]
assert not missing, f"obiekty bez glifu: {missing}"
def test_every_sign_has_a_glyph():
missing = [s for s in SIGNS if G.sign_glyph(s) is None]
assert not missing, f"znaki bez glifu: {missing}"
def test_every_major_aspect_has_a_glyph():
missing = [a for a in MAJOR if G.aspect_glyph(a) is None]
assert not missing, f"aspekty główne bez glifu: {missing}"
def test_fortune_and_retrograde_from_requirement():
"""Wymóg wprost wymienia ⊗ (Fortuna) i ℞ (retrogradacja)."""
assert G.glyph_for("Fortune") == ""
assert G.RETROGRADE == ""
# ------------------------------------------------------ DAN-18: tekst, nie emoji
def test_no_glyph_uses_emoji_variation_selector():
"""VS16 (U+FE0F) wymusiłby prezentację emoji — nie może go być NIGDZIE."""
offenders = [g for g in _all_glyphs() if VS16 in g]
assert not offenders, f"glify z wariantem emoji (FE0F): {offenders!r}"
def test_signs_force_text_presentation():
"""Znaki zodiaku mają domyślnie wariant emoji → MUSZĄ nieść VS15."""
for s in SIGNS:
assert VS15 in G.sign_glyph(s), f"{s} bez wymuszenia tekstu (VS15)"
def test_venus_mars_force_text_but_sun_does_not():
"""♀/♂ mają wariant emoji (VS15 konieczny); ☉ nie ma (VS15 zbędny)."""
assert VS15 in G.glyph_for("Venus")
assert VS15 in G.glyph_for("Mars")
assert VS15 not in G.glyph_for("Sun") # bez zaśmiecania niepotrzebnym selektorem
# ---------------------------------------------------------- dwukierunkowość
def test_object_round_trip():
for name in DEFAULT_OBJECTS:
assert G.name_for_glyph(G.glyph_for(name)) == name
def test_sign_round_trip():
for s in SIGNS:
assert G.sign_for_glyph(G.sign_glyph(s)) == s
def test_aspect_round_trip():
for a in MAJOR:
assert G.aspect_for_glyph(G.aspect_glyph(a)) == a
def test_reverse_tolerates_missing_variant_selector():
"""Symbol wklejony bez VS15 też musi się rozpoznać — bo z zewnątrz przyjdzie
dowolna forma (DAN-17: łańcuch znosi każdy Unicode)."""
assert G.sign_for_glyph("") == "Pisces" # ♓ bez VS15
assert G.sign_for_glyph("" + VS15) == "Pisces" # ♓ z VS15
assert G.name_for_glyph("") == "Venus" # ♀ bez VS15
# --------------------------------------------------------- glifikacja tekstu
def test_glyphify_matches_requirement_example():
"""Przykład z wymagań: [Sa [Pis 26°08' [conj [PF -> ♄ ♓ 26°08' ☌ ⊗."""
out = G.glyphify("[Sa [Pis 26°08' [conj [PF")
assert G.glyph_for("Saturn") in out
assert G.sign_glyph("Pisces") in out
assert G.aspect_glyph("conjunction") in out
assert G.glyph_for("Fortune") in out
assert "26°08'" in out # stopnie nietknięte
def test_glyphify_handles_retrograde_and_leaves_unknown():
out = G.glyphify("[Ma Rx w [Xyz")
assert G.glyph_for("Mars") in out
assert G.RETROGRADE in out
assert "[Xyz" in out # nieznany token bez zmian
def test_glyphify_empty_is_safe():
assert G.glyphify("") == ""
-157
View File
@@ -1,8 +1,4 @@
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
import math
import pytest
from app.engine import houses as H
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
@@ -43,156 +39,3 @@ def test_assign_house_whole_sign():
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90120°)
assert H.assign_house(100.0, cusps) == 1 # w Raku
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
# ── regresje wykryte przez porównanie z wyrocznią (tests/oracle) ──────────
def test_ascendant_branch_beyond_polar_circle():
"""REGRESJA: powyżej koła podbiegunowego `atan2` wybierał NIEWŁAŚCIWY punkt
przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent zamiast Ascendenta,
czyli błąd o 180°. Planety lądowały w PRZECIWNYCH domach dla całej północnej
Skandynawii. Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze
w półkolu (0°,180°) na wschód od MC."""
eps = 23.4393
for lat in (67.0, 70.0, 85.0, -70.0):
for ramc in (60.0, 100.0, 200.0, 270.0, 300.0):
asc = H.compute_asc(ramc, eps, lat)
mc = H.compute_mc(ramc, eps)
offset = (asc - mc) % 360.0
assert 0.0 <= offset <= 180.0, (
f"Asc poza półkolem wschodzącym: lat={lat} ramc={ramc} offset={offset:.4f}")
def test_whole_sign_is_deterministic_at_sign_boundary():
"""REGRESJA: Ascendent o włos od granicy znaku (359,999999999976 vs 1e-10 —
ta sama wartość, różne strony) przerzucał cały dom I o 30°. Ten sam horoskop
na innej maszynie dawał inny wynik."""
below = H.cusps(359.999999999976, 270.0, H.WHOLE_SIGN)
above = H.cusps(1e-10, 270.0, H.WHOLE_SIGN)
assert below[0] == above[0] == 0.0, (below[0], above[0])
def test_polar_circle_moves_with_obliquity():
"""Granica dziedziny Placidusa/Kocha NIE jest stałą 66,56° — zależy od ε,
a ε zmienia się z datą. Testy brzegowe muszą ją liczyć per data."""
assert H.polar_circle(23.4393) == pytest.approx(66.5607, abs=1e-4) # dziś
assert H.polar_circle(23.747) == pytest.approx(66.253, abs=1e-4) # 370 p.n.e.
# ── systemy egzotyczne o zamkniętym wzorze (Etap 1) ──────────────────────
# Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które
# muszą zachodzić także bez swissepha (czyli w każdym środowisku).
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus")
@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
-91
View File
@@ -1,91 +0,0 @@
"""Okna kontekstu i planowanie budżetu tokenów (LOG-30/31).
Naczelna zasada, której pilnują te testy: **zawsze zostaje miejsce na odpowiedź**.
Prompt nigdy nie może wypełnić całego okna kontekstu, bo wtedy model kończy na
`max_tokens` z pustą albo uciętą treścią — to był zgłoszony błąd.
"""
import pytest
from app.llm import limits
def test_known_models_have_documented_limits():
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
assert ctx == 1_000_000 and out == 128_000
ctx, out = limits.limits_for("anthropic", "claude-haiku-4-5")
assert ctx == 200_000 and out == 64_000
def test_local_model_tag_is_matched_by_prefix():
"""Modele lokalne niosą tag (`llama3.1:8b`) — dopasowanie musi to znieść."""
assert limits.limits_for("local", "llama3.1:8b") == limits.limits_for("local", "llama3.1")
def test_unknown_model_falls_back_conservatively():
ctx, out = limits.limits_for("local", "jakis-egzotyczny-model")
assert ctx == 8_192 and out == 4_096
def test_env_overrides_win(monkeypatch):
"""Modele wychodzą szybciej, niż aktualizuje się tabela."""
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "131072")
monkeypatch.setenv("LOCAL_MAX_OUTPUT", "8192")
assert limits.limits_for("local", "llama3.1:8b") == (131_072, 8_192)
def test_env_override_ignores_garbage(monkeypatch):
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "nie-liczba")
assert limits.limits_for("local", "llama3.1:8b")[0] == 8_192
# ------------------------------------------------- rezerwa miejsca na odpowiedź
def test_prompt_budget_always_reserves_room_for_answer():
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
budget = limits.prompt_token_budget("anthropic", "claude-opus-4-8")
assert budget + out + limits.SAFETY_MARGIN <= ctx
assert budget > 0
def test_small_context_model_still_leaves_room():
budget = limits.prompt_token_budget("local", "llama3.1:8b")
ctx, out = limits.limits_for("local", "llama3.1:8b")
assert budget + out + limits.SAFETY_MARGIN <= ctx
def test_plan_shrinks_output_when_prompt_is_huge():
"""Duży prompt nie może dostać pełnego okna wyjścia — musi się zmieścić."""
ctx, model_out = limits.limits_for("local", "llama3.1:8b")
p = limits.plan("local", "llama3.1:8b", prompt_tokens=6_000)
assert p["max_output"] <= ctx - 6_000 - limits.SAFETY_MARGIN
assert p["max_output"] < model_out
def test_plan_reports_not_fitting_instead_of_failing_silently():
p = limits.plan("local", "llama3.1:8b", prompt_tokens=8_000)
assert p["fits"] is False
assert any("odpowied" in w for w in p["warnings"])
def test_plan_warns_above_90k_but_still_fits():
"""Powyżej 90 tys. tokenów ostrzegamy — ale wysłanie MA być nadal możliwe."""
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=120_000)
assert p["fits"] is True, "duży prompt nadal musi dać się wysłać"
assert p["max_output"] >= limits.MIN_OUTPUT
assert any("dużo" in w for w in p["warnings"])
def test_no_warning_below_threshold():
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=10_000)
assert p["warnings"] == [] and p["fits"] is True
def test_requested_output_is_capped_by_model_maximum():
p = limits.plan("anthropic", "claude-haiku-4-5", prompt_tokens=1_000, want_output=999_999)
assert p["max_output"] == 64_000
@pytest.mark.parametrize("tokens", [0, 1_000, 50_000, 200_000, 900_000])
def test_plan_never_returns_negative_output(tokens):
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=tokens)
assert p["max_output"] >= 0
-401
View File
@@ -1,401 +0,0 @@
"""Szyfrowanie łącza między warstwami (PRE-16).
Sedno: przez to łącze płyną surowe wiersze oryginalnych baz interpretacyjnych.
Testy nie sprawdzają więc tylko, czy „coś się zaszyfrowało i odszyfrowało"
sprawdzają, czy tajnego tekstu FAKTYCZNIE NIE MA w bajtach lecących po sieci
oraz czy każda znana droga na skróty (brak szyfrowania, obcy klucz, podmieniony
bajt, przeklejenie na inny endpoint, odtworzenie po czasie) kończy się odmową.
"""
import json
import pathlib
import time
import pytest
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from starlette.testclient import TestClient
from app import link_crypto
from app.link_crypto import ENV_LOGIC_DATA, ENV_PRESENTATION_LOGIC, Link, LinkError
SECRET = "Saturn w VII domu — opis z oryginalnej bazy interpretacyjnej"
KEY_A = "11" * 32 # hex, 32 bajty
KEY_B = "22" * 32
class Payload(BaseModel):
question: str
def _app(link: Link | None) -> FastAPI:
app = FastAPI()
if link is not None:
app.add_middleware(link_crypto.LinkCryptoMiddleware, link=link, layer="testowa")
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/search")
def search(payload: Payload):
return {"echo": payload.question, "interpretation": SECRET}
@app.get("/catalog")
def catalog():
return {"models": ["a", "b"]}
@app.post("/stream")
def stream():
def lines():
for i in range(4):
yield json.dumps({"step": i, "note": SECRET}).encode() + b"\n"
return StreamingResponse(lines(), media_type="application/x-ndjson")
return app
@pytest.fixture
def link():
return Link(link_crypto.parse_key(KEY_A))
# ------------------------------------------------------------------- klucze
def test_key_accepts_hex_and_base64():
import base64
raw = bytes(range(32))
assert link_crypto.parse_key(raw.hex()) == raw
assert link_crypto.parse_key(base64.b64encode(raw).decode()) == raw
def test_key_of_wrong_length_is_rejected_loudly():
"""Krótki klucz to nie „słabsze szyfrowanie", tylko błąd konfiguracji."""
with pytest.raises(LinkError, match="32"):
link_crypto.parse_key("aabb")
def test_key_that_is_neither_hex_nor_base64_is_rejected():
with pytest.raises(LinkError, match="hex"):
link_crypto.parse_key("!!! to nie jest klucz !!!")
def test_key_from_env_is_lazy(monkeypatch):
monkeypatch.delenv(ENV_PRESENTATION_LOGIC, raising=False)
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) is None
monkeypatch.setenv(ENV_PRESENTATION_LOGIC, KEY_A)
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) == bytes.fromhex(KEY_A)
def test_directions_use_different_subkeys(link):
"""Żądanie i odpowiedź nie dzielą klucza — powtórzenie jednorazówki w jedną
stronę nie osłabia drugiej."""
stamp = link_crypto.stamp_now()
sealed = link.seal(link_crypto.REQUEST, "/search", stamp, 0, b"tajne")
with pytest.raises(LinkError):
link.open(link_crypto.RESPONSE, "/search", stamp, 0, sealed)
# --------------------------------------------------------- podstawowy obieg
def test_round_trip_delivers_plaintext_to_the_app(link):
with TestClient(_app(link)) as client:
got = link_crypto.call_json(client, "POST", "http://testserver/search",
payload={"question": "Saturn"}, link=link)
assert got["echo"] == "Saturn"
assert got["interpretation"] == SECRET
def test_get_without_body_also_works(link):
"""GET nie ma ciała, ale i tak pieczętujemy pustą kopertę — to ona dowodzi,
że pytający ma klucz, i ona wymusza zaszyfrowanie odpowiedzi."""
with TestClient(_app(link)) as client:
got = link_crypto.call_json(client, "GET", "http://testserver/catalog", link=link)
assert got == {"models": ["a", "b"]}
def test_without_key_traffic_stays_plaintext(link):
"""Dev bez sekretów ma działać jak dotąd — inaczej nikt nie odpali projektu lokalnie."""
with TestClient(_app(None)) as client:
got = link_crypto.call_json(client, "POST", "http://testserver/search",
payload={"question": "Saturn"}, link=None)
assert got["interpretation"] == SECRET
def test_health_stays_open_for_kubernetes_probes(link):
"""Sondy k8s klucza nie mają. Gdyby /health wymagał szyfrowania, literówka
w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji."""
with TestClient(_app(link)) as client:
assert client.get("http://testserver/health").json() == {"status": "ok"}
# ------------------------------------------- czy na kablu naprawdę nic nie widać
def _raw_exchange(client, link, payload):
"""Wysyła zapieczętowane żądanie i zwraca SUROWE bajty obu stron."""
stamp = link_crypto.stamp_now()
body = link_crypto.frame_out(
link.seal(link_crypto.REQUEST, "/search", stamp, 0, json.dumps(payload).encode()))
response = client.request(
"POST", "http://testserver/search", content=body,
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
link_crypto.HEADER_TS: stamp,
"Content-Type": link_crypto.CONTENT_TYPE})
return body, response
def test_request_bytes_do_not_contain_the_question(link):
with TestClient(_app(link)) as client:
body, _ = _raw_exchange(client, link, {"question": "Saturn w VII"})
assert b"Saturn" not in body
assert b"question" not in body
def test_response_bytes_do_not_contain_the_interpretation(link):
"""To jest właściwy powód istnienia całego modułu."""
with TestClient(_app(link)) as client:
_, response = _raw_exchange(client, link, {"question": "Saturn"})
assert response.status_code == 200
assert SECRET.encode() not in response.content
assert b"interpretation" not in response.content
assert response.headers[link_crypto.HEADER_ENC] == link_crypto.VERSION
assert response.headers["content-type"] == link_crypto.CONTENT_TYPE
# ------------------------------------------------------- drogi na skróty i ataki
def test_plaintext_request_is_refused_when_key_is_set(link):
"""Fail-closed: regresja po stronie klienta nie może oznaczać cichego
powrotu do jawnego ruchu."""
with TestClient(_app(link)) as client:
response = client.post("http://testserver/search", json={"question": "Saturn"})
assert response.status_code == 400
assert SECRET.encode() not in response.content
def test_foreign_key_cannot_read_the_link(link):
"""Klucze są osobne dla każdej pary warstw — przejęcie jednego nie otwiera drugiej."""
intruder = Link(link_crypto.parse_key(KEY_B))
with TestClient(_app(link)) as client:
stamp = link_crypto.stamp_now()
body = link_crypto.frame_out(
intruder.seal(link_crypto.REQUEST, "/search", stamp, 0, b'{"question":"x"}'))
response = client.request(
"POST", "http://testserver/search", content=body,
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
link_crypto.HEADER_TS: stamp,
"Content-Type": link_crypto.CONTENT_TYPE})
assert response.status_code == 400
assert SECRET.encode() not in response.content
def test_single_flipped_bit_is_rejected(link):
"""GCM uwierzytelnia, więc nie ma wariantu „odszyfrowało się, ale zmienione"."""
with TestClient(_app(link)) as client:
stamp = link_crypto.stamp_now()
sealed = bytearray(link.seal(link_crypto.REQUEST, "/search", stamp, 0,
b'{"question":"x"}'))
sealed[-1] ^= 0x01
response = client.request(
"POST", "http://testserver/search",
content=link_crypto.frame_out(bytes(sealed)),
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
link_crypto.HEADER_TS: stamp,
"Content-Type": link_crypto.CONTENT_TYPE})
assert response.status_code == 400
def test_frame_cannot_be_replayed_against_another_endpoint(link):
"""Ścieżka wchodzi do materiału uwierzytelnianego, więc podsłuchanej koperty
nie da się przekleić tam, gdzie odpowiedź byłaby ciekawsza."""
stamp = link_crypto.stamp_now()
sealed = link.seal(link_crypto.REQUEST, "/catalog", stamp, 0, b"")
with pytest.raises(LinkError):
link.open(link_crypto.REQUEST, "/search", stamp, 0, sealed)
def test_frames_cannot_be_reordered(link):
"""Numer ramki jest uwierzytelniony — przestawienie kolejności w strumieniu
to błąd, a nie po cichu pomieszany horoskop."""
stamp = link_crypto.stamp_now()
second = link.seal(link_crypto.RESPONSE, "/stream", stamp, 1, b"druga")
with pytest.raises(LinkError):
link.open(link_crypto.RESPONSE, "/stream", stamp, 0, second)
def test_stale_frame_is_refused(link, monkeypatch):
"""Bez okna czasowego podsłuchane żądanie dałoby się odtworzyć kiedykolwiek."""
old = f"{time.time() - link_crypto.MAX_SKEW_SECONDS - 60:.3f}"
with TestClient(_app(link)) as client:
body = link_crypto.frame_out(
link.seal(link_crypto.REQUEST, "/search", old, 0, b'{"question":"x"}'))
response = client.request(
"POST", "http://testserver/search", content=body,
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
link_crypto.HEADER_TS: old,
"Content-Type": link_crypto.CONTENT_TYPE})
assert response.status_code == 400
def test_truncated_stream_is_an_error_not_silent_loss(link):
stamp = link_crypto.stamp_now()
full = link_crypto.frame_out(link.seal(link_crypto.RESPONSE, "/x", stamp, 0, b"abc"))
with pytest.raises(LinkError, match="urwana"):
link.open_all(link_crypto.RESPONSE, "/x", stamp, full[:-2])
def test_absurd_frame_length_does_not_allocate(link):
"""Zadeklarowana długość pochodzi z sieci — nie wolno jej wierzyć na słowo."""
import struct
with pytest.raises(LinkError, match="rozmiar"):
list(link_crypto.frames_in(struct.pack(">I", 2 ** 31) + b"nic"))
# ---------------------------------------------------------- odpowiedź strumieniowa
def test_streaming_response_survives_encryption(link):
"""Okno postępu dostaje kolejne linie na żywo — muszą dojść po kolei
i w komplecie, mimo że każda jedzie w osobnej kopercie."""
with TestClient(_app(link)) as client:
stamp = link_crypto.stamp_now()
body = link_crypto.frame_out(link.seal(link_crypto.REQUEST, "/stream", stamp, 0, b""))
with client.stream("POST", "http://testserver/stream", content=body,
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
link_crypto.HEADER_TS: stamp,
"Content-Type": link_crypto.CONTENT_TYPE}) as response:
chunks = list(link_crypto.open_response_stream(response, link))
steps = [json.loads(line) for line in b"".join(chunks).splitlines()]
assert [s["step"] for s in steps] == [0, 1, 2, 3]
assert all(s["note"] == SECRET for s in steps)
def test_stream_lines_helper_round_trips_ndjson(link):
"""Wyższa warstwa (`stream_lines`) — dokładnie tej używa klient okna postępu.
Regresja, którą pilnuje ten test: strumień horoskopu powstał na gałęzi, która
nie widziała szyfrowania, więc szedł surowym httpx. Po scaleniu z PRE-16 przy
włączonym łączu żądanie było odrzucane (400) albo odpowiedź wracała jako
nieczytelne ramki. Tu sprawdzamy, że helper zwraca CZYSTE linie NDJSON i że
tajny opis nie przecieka po drodze."""
with TestClient(_app(link)) as client:
lines = list(link_crypto.stream_lines(
client, "http://testserver/stream", payload=None, link=link))
steps = [json.loads(line) for line in lines]
assert [s["step"] for s in steps] == [0, 1, 2, 3]
assert all(s["note"] == SECRET for s in steps)
def test_stream_lines_plaintext_path_still_works(link):
"""Bez klucza (dev) strumień ma działać jak dotąd — surowy, bez szyfrowania."""
with TestClient(_app(None)) as client:
lines = list(link_crypto.stream_lines(
client, "http://testserver/stream", payload=None, link=None))
assert [json.loads(line)["step"] for line in lines] == [0, 1, 2, 3]
def test_stream_request_without_key_is_refused_when_required(link):
"""Fail-closed obejmuje też strumień: nieszyfrowane żądanie na łączu z kluczem
dostaje odmowę, a nie cichy jawny przelot tajnych danych."""
with TestClient(_app(link)) as client:
response = client.post("http://testserver/stream", json={})
assert response.status_code == 400
assert SECRET.encode() not in response.content
def test_stream_lines_client_is_fail_closed_when_required(monkeypatch, link):
"""Klient strumienia też nie wypuszcza jawnego żądania — jak `call`. Bez tego
ciało (dane urodzenia) poszłoby w eter, zanim serwer zdążyłby odmówić."""
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
sent = []
class Tripwire:
def stream(self, *args, **kwargs):
sent.append(args)
raise AssertionError("strumień NIE powinien opuścić procesu")
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
list(link_crypto.stream_lines(Tripwire(), "http://logic/chart/horoscope/stream",
payload={"lat": 50.0}, link=None))
assert not sent, "żądanie strumienia wyszłoby jawnym tekstem"
def test_incremental_unframing_handles_split_frames(link):
"""Ramka potrafi rozjechać się między dwa odczyty z gniazda — składamy ją
w buforze, zamiast zakładać, że każdy kawałek to komplet."""
stamp = link_crypto.stamp_now()
stream = b"".join(link.seal_stream(link_crypto.RESPONSE, "/x", stamp,
[b"raz", b"dwa", b"trzy"]))
buffer = bytearray()
opened, seq = [], 0
for i in range(0, len(stream), 5): # ciachamy w poprzek ramek
buffer += stream[i:i + 5]
for frame in link_crypto.unframe_incremental(buffer):
opened.append(link.open(link_crypto.RESPONSE, "/x", stamp, seq, frame))
seq += 1
assert opened == [b"raz", b"dwa", b"trzy"]
assert not buffer, "bufor musi zostać pusty — inaczej gdzieś zgubiliśmy ramkę"
# ----------------------------------------------- trzy kopie muszą być identyczne
def test_all_services_share_the_same_module():
"""Moduł jest skopiowany do KAŻDEJ usługi, która szyfruje łącze (nie mają wspólnej
biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako
„nie da się odszyfrować" — więc pilnujemy tego testem."""
root = pathlib.Path(__file__).resolve().parents[3]
copies = {svc: (root / "services" / svc / "app" / "link_crypto.py")
for svc in ("presentation", "logic", "data", "render")}
missing = [svc for svc, path in copies.items() if not path.is_file()]
assert not missing, f"brak modułu w warstwach: {missing}"
contents = {svc: path.read_bytes() for svc, path in copies.items()}
assert len(set(contents.values())) == 1, (
"kopie link_crypto.py rozjechały się między warstwami: "
+ ", ".join(f"{svc}={len(body)}B" for svc, body in contents.items())
)
def test_env_names_are_two_distinct_keys():
"""Wymóg wprost: osobny klucz dla pary prezentacja-logika i logika-dane."""
assert ENV_PRESENTATION_LOGIC != ENV_LOGIC_DATA
# ------------------------------------------------- klient też musi być fail-closed
#
# To wyszło dopiero z podsłuchu prawdziwego gniazda, nie z testów: przy kliencie
# BEZ klucza serwer owszem odmawiał, ale pytanie leciało po drodze otwartym
# tekstem. Odpowiedź była chroniona — zapytanie już nie.
def test_client_without_key_sends_nothing_when_encryption_required(monkeypatch):
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
sent = []
class Tripwire:
def request(self, *args, **kwargs):
sent.append(args)
raise AssertionError("żądanie NIE powinno opuścić procesu")
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
link_crypto.call(Tripwire(), "POST", "http://logic/search",
payload={"value": "[Sat"}, link=None)
assert not sent, "treść zapytania wyszłaby jawnym tekstem"
def test_plaintext_still_allowed_in_dev(monkeypatch, link):
"""Bez tej flagi lokalne uruchomienie bez sekretów ma dalej działać."""
monkeypatch.delenv(link_crypto.ENV_REQUIRED, raising=False)
with TestClient(_app(None)) as client:
got = link_crypto.call_json(client, "POST", "http://testserver/search",
payload={"question": "Saturn"}, link=None)
assert got["interpretation"] == SECRET
def test_service_refuses_to_start_without_key_when_required(monkeypatch):
"""Pod w CrashLoop widać od razu; usługę, która wstała i nie szyfruje — nie."""
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
monkeypatch.delenv(ENV_LOGIC_DATA, raising=False)
with pytest.raises(LinkError, match=ENV_LOGIC_DATA):
link_crypto.install(FastAPI(), ENV_LOGIC_DATA, "danych")
+2 -290
View File
@@ -3,8 +3,6 @@
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
deterministyczne i nic nie wychodzi na zewnątrz.
"""
import json
import httpx
import pytest
@@ -117,7 +115,7 @@ def test_anthropic_generates(monkeypatch):
def test_anthropic_requires_key():
with pytest.raises(LLMError, match="ANTHROPIC_API_KEY"):
with pytest.raises(LLMError, match="LLM_API_KEY"):
AnthropicProvider("https://api.anthropic.com", "m", "").generate("p", 10)
@@ -135,9 +133,7 @@ def test_default_provider_is_local(monkeypatch):
def test_openai_requires_key(monkeypatch):
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
# komunikat wskazuje ZMIENNĄ DO USTAWIENIA dla tego dostawcy, nie ogólne LLM_API_KEY
with pytest.raises(LLMError, match="OPENAI_API_KEY"):
with pytest.raises(LLMError, match="LLM_API_KEY"):
factory.build_provider("openai")
@@ -151,287 +147,3 @@ def test_env_overrides_model_and_url(monkeypatch):
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
p = factory.build_provider("local")
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
# ---------------------------------------- konfiguracja per dostawca (regresja LOG-31)
# UI pozwala przelaczac dostawce przy kazdym zadaniu, wiec ustawienia JEDNEGO nie moga
# przeciekac na pozostalych. Wczesniej wspolne LLM_BASE_URL/LLM_MODEL kierowaly zadania
# do OpenAI na adres lokalnej Ollamy i prosily Anthropic o model llama.
def _clear(monkeypatch):
for v in ("LLM_PROVIDER", "LLM_MODEL", "LLM_BASE_URL", "LLM_API_KEY",
"LOCAL_MODEL", "LOCAL_BASE_URL", "LOCAL_API_KEY",
"OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY",
"ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
monkeypatch.delenv(v, raising=False)
def test_local_config_does_not_leak_to_cloud(monkeypatch):
"""Sedno bledu: skonfigurowany model lokalny przejmowal zadania do chmury."""
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("LLM_BASE_URL", "http://ollama:11434/v1") # konfiguracja lokalnego
monkeypatch.setenv("LLM_MODEL", "llama3.1:8b")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
local = factory.build_provider("local")
assert local.base_url == "http://ollama:11434/v1" and local.model == "llama3.1:8b"
openai = factory.build_provider("openai")
assert openai.base_url == "https://api.openai.com/v1", "zadanie do OpenAI poszloby do Ollamy"
assert openai.model == "gpt-4o-mini", "OpenAI dostalby nazwe modelu llama"
def test_provider_specific_settings_win(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
p = factory.build_provider("anthropic")
assert p.model == "claude-opus-4-8" and p.api_key == "sk-ant"
def test_generic_vars_apply_only_to_default_provider(monkeypatch):
"""Zgodnosc wstecz: wspolne LLM_* konfiguruja dostawce domyslnego i tylko jego."""
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "openai")
monkeypatch.setenv("LLM_API_KEY", "sk-generic")
monkeypatch.setenv("LLM_MODEL", "gpt-4o")
assert factory.build_provider("openai").model == "gpt-4o"
assert factory.build_provider("local").model == "llama3.1:8b" # nie dziedziczy
def test_cloud_without_key_is_rejected_clearly(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "local")
for name in ("openai", "anthropic"):
with pytest.raises(LLMError, match=f"{name.upper()}_API_KEY"):
factory.build_provider(name)
def test_local_needs_no_key(monkeypatch):
_clear(monkeypatch)
assert factory.build_provider("local").api_key == ""
# ------------------------------------------- pusta odpowiedz modelu (cicha awaria)
# Regresja: model potrafi oddac pusta tresc (prompt zjadl caly kontekst ->
# finish_reason=length, completion_tokens=0). Wczesniej generate() zwracalo pusty
# tekst BEZ bledu, widok nic nie renderowal i uzytkownik dostawal pusta strone
# bez zadnego wyjasnienia. Pusta odpowiedz MUSI byc bledem.
def test_empty_completion_raises_instead_of_silent_blank(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"model": "llama3.1:8b",
"choices": [{"message": {"content": ""}, "finish_reason": "length"}],
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
})))
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
def test_empty_completion_explains_context_window(monkeypatch):
"""Komunikat ma prowadzic do przyczyny, a nie tylko stwierdzac fakt."""
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": " "}, "finish_reason": "length"}],
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
})))
with pytest.raises(LLMError) as ei:
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
msg = str(ei.value)
assert "kontekstu" in msg and "budżet" in msg
assert "powód zakończenia: length" in msg # diagnostyka w tresci bledu
def test_whitespace_only_is_treated_as_empty(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": "\n\n \t "}}],
})))
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100)
def test_anthropic_empty_completion_raises(monkeypatch):
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"model": "claude-x", "content": [], "stop_reason": "max_tokens",
"usage": {"input_tokens": 9000, "output_tokens": 0},
})))
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
def test_normal_response_still_passes(monkeypatch):
"""Straznik nie moze psuc poprawnej odpowiedzi."""
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
"choices": [{"message": {"content": "Horoskop."}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 100, "completion_tokens": 20},
})))
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100).text == "Horoskop."
# ------------------------------------- kontynuacja: horoskop MA powstac zawsze
# Sedno wymagania: niezaleznie od objetosci promptu i limitu wyjscia, pelna tresc
# ma wrocic do uzytkownika. Model urwany na max_tokens jest proszony o dokonczenie
# w ramach tej samej rozmowy, a kawalki sa sklejane.
def _scripted(responses):
"""Transport oddajacy kolejne odpowiedzi z listy (po jednej na ture)."""
seq = list(responses)
seen = []
def handler(request):
seen.append(request)
return httpx.Response(200, json=seq.pop(0) if seq else seq_last)
seq_last = responses[-1]
return handler, seen
def _chat(text, finish):
return {"choices": [{"message": {"content": text}, "finish_reason": finish}],
"usage": {"completion_tokens": 10}}
def test_truncated_answer_is_continued_and_joined(monkeypatch):
handler, seen = _scripted([
_chat("Czesc pierwsza.", "length"),
_chat("Czesc druga. KONIEC", "stop"),
])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
assert "Czesc pierwsza." in out.text and "Czesc druga." in out.text
assert "KONIEC" not in out.text # znacznik nie trafia do horoskopu
assert out.usage["turns"] == 2
def test_continuation_asks_in_same_conversation(monkeypatch):
"""Kontynuacja musi isc jako kolejna tura rozmowy, a ostatnia wiadomosc MUSI
byc od uzytkownika — Claude odrzuca prefill w turze asystenta (400)."""
handler, seen = _scripted([_chat("Poczatek", "length"), _chat("Reszta", "stop")])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
msgs = json.loads(seen[1].content)["messages"]
assert msgs[-1]["role"] == "user", "ostatnia wiadomosc nie moze byc prefillem asystenta"
assert msgs[1]["role"] == "assistant" and "Poczatek" in msgs[1]["content"]
def test_anthropic_thinking_only_turn_is_continued(monkeypatch):
"""DOKLADNIE zgloszony objaw: cala tura poszla na myslenie, tekst pusty.
Wczesniej konczylo sie to pusta strona; teraz pytamy o tresc dalej."""
seq = [
{"content": [{"type": "thinking", "thinking": ""}], "stop_reason": "max_tokens",
"usage": {"output_tokens": 2000}},
{"content": [{"type": "text", "text": "Horoskop urodzeniowy..."}],
"stop_reason": "end_turn", "usage": {"output_tokens": 500}},
]
def handler(request):
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 40000)
assert out.text == "Horoskop urodzeniowy..."
assert out.usage["turns"] == 2
def test_anthropic_sends_thinking_config(monkeypatch):
"""Bez jawnego `thinking` Sonnet 5 wlacza myslenie sam — konfigurujemy to wprost."""
seen = []
def handler(request):
seen.append(json.loads(request.content))
return httpx.Response(200, json={"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn"})
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
monkeypatch.delenv("ANTHROPIC_THINKING", raising=False)
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
assert seen[0]["thinking"] == {"type": "adaptive"}
seen.clear()
monkeypatch.setenv("ANTHROPIC_THINKING", "off")
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
assert seen[0]["thinking"] == {"type": "disabled"}
def test_complete_answer_does_not_loop(monkeypatch):
"""Straznik nie moze mnozyc zapytan, gdy model skonczyl normalnie."""
calls = {"n": 0}
def handler(request):
calls["n"] += 1
return httpx.Response(200, json=_chat("Gotowe.", "stop"))
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 40000)
assert out.text == "Gotowe." and calls["n"] == 1
# ------------------------------------------- wybor modelu przez uzytkownika (UI)
def test_model_from_request_wins_over_config(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
p = factory.build_provider("anthropic", "claude-fable-5")
assert p.model == "claude-fable-5", "wybor z UI musi wygrac nad konfiguracja"
def test_blank_model_falls_back_to_configured_default(monkeypatch):
_clear(monkeypatch)
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-sonnet-5")
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
assert factory.build_provider("anthropic", " ").model == "claude-sonnet-5"
def test_resolve_model_needs_no_api_key(monkeypatch):
"""Budzet promptu zalezy od okna kontekstu modelu — nie moze wymagac klucza.
Wczesniej liczenie budzetu szlo przez build_provider(), ktory bez klucza
rzuca bledem, wiec „maksymalny kontekst" cicho spadal do wartosci zapasowej.
"""
_clear(monkeypatch)
provider, model = factory.resolve_model("anthropic", "claude-haiku-4-5")
assert (provider, model) == ("anthropic", "claude-haiku-4-5")
with pytest.raises(LLMError): # samo zbudowanie nadal wymaga klucza
factory.build_provider("anthropic", "claude-haiku-4-5")
def test_max_budget_differs_between_models(monkeypatch):
"""Sedno funkcji: wieksze okno = wiekszy budzet promptu."""
from app.llm.limits import prompt_token_budget
_clear(monkeypatch)
opus = prompt_token_budget(*factory.resolve_model("anthropic", "claude-opus-4-8"))
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
assert opus > haiku > local > 0
def test_turn_budget_counts_produced_not_requested(monkeypatch):
"""Regresja: odejmowanie ZAMOWIONEGO limitu tury zamiast wyprodukowanych
tokenow konczylo petle po jednej turze — urwany fragment wracal jako calosc."""
seq = [_chat("Fragment 1. ", "length"), _chat("Fragment 2. ", "length"),
_chat("Zakonczenie. KONIEC", "stop")]
def handler(request):
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
# budzet 8000 < TURN_TOKENS_CAP: przy starej logice byla dokladnie jedna tura
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 8000)
assert out.usage["turns"] == 3, "urwana odpowiedz musi byc kontynuowana"
assert "Fragment 1." in out.text and "Zakonczenie." in out.text
def test_generate_reports_progress_events(monkeypatch):
"""Log w UI ma pokazywac RZECZYWISTE tury, nie udawany pasek postepu."""
seq = [_chat("Czesc. ", "length"), _chat("Reszta. KONIEC", "stop")]
monkeypatch.setattr(httpx, "Client", _mock_client(
lambda r: httpx.Response(200, json=seq.pop(0) if seq else seq[-1])))
events = []
ChatCompletionsProvider("local", "http://x/v1", "m").generate(
"p", 8000, on_event=events.append)
kinds = [e["type"] for e in events]
assert kinds.count("turn_start") == 2 and kinds.count("turn_end") == 2
assert kinds[-1] == "generated"
assert any("urwana" in e["message"] for e in events if e["type"] == "turn_end")
-157
View File
@@ -1,157 +0,0 @@
"""Aspekty pozazodiakalne — paralele deklinacji i antyscja (LOG-07).
Walidacja wobec faktów NIEZALEŻNYCH od naszego kodu:
* deklinacja Słońca 30.04.1984 ≈ +14.9° (almanach; Słońce w ~10° Byka, koniec
kwietnia, deklinacja rośnie ku przesileniu letniemu),
* antyscja to czysta arytmetyka odbicia względem osi przesileń — pary znaków
(Rak↔Bliźnięta, Baran↔Panna) i inwolucja dają się sprawdzić na piechotę,
* węzły są z definicji w kontrparaleli (SN = NN+180° na ekliptyce → deklinacja
przeciwna), więc muszą być odsiane jako kontakt bez informacji.
"""
from datetime import datetime, timezone
import pytest
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.engine.out_of_zodiac import (
antiscion,
contra_antiscion,
declination,
find_antiscia,
find_declination_aspects,
is_out_of_bounds,
)
@pytest.fixture(scope="module")
def krakow():
return ChartMoment(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
lat=50.0647, lon=19.9450)
@pytest.fixture(scope="module")
def chart(own_engine, krakow):
return build_chart(own_engine, krakow)
# ----------------------------------------------------------------- deklinacja
def test_sun_declination_matches_almanac(chart):
"""Deklinacja Słońca 30.04.1984 ≈ +14.9° (wartość z almanachu)."""
sun = next(p for p in chart["positions"] if p["name"] == "Sun")
assert 14.6 < sun["declination"] < 15.1
def test_declination_uses_latitude_not_only_longitude():
"""Pełny wzór: przy tej samej długości różna szerokość → różna deklinacja.
Gdyby liczyć z samej długości (β=0), obie wyszłyby identyczne."""
eps = 23.44
on_ecliptic = declination(60.0, 0.0, eps) # β = 0
above = declination(60.0, 5.0, eps) # 5° na północ od ekliptyki
assert abs(above - on_ecliptic) > 3.0 # szerokość realnie zmienia deklinację
def test_obliquity_is_reasonable_for_1984(chart):
assert 23.43 < chart["obliquity"] < 23.45
def test_out_of_bounds_flag():
eps = 23.44
assert is_out_of_bounds(24.0, eps) is True # poza zakresem Słońca
assert is_out_of_bounds(20.0, eps) is False
assert is_out_of_bounds(-24.5, eps) is True # także na południe
# --------------------------------------------------------------- antyscja (wzór)
def test_antiscion_is_reflection_across_solstitial_axis():
assert antiscion(0.0) == pytest.approx(180.0) # 0° Barana → 0° Wagi
assert antiscion(105.0) == pytest.approx(75.0) # 15° Raka → 15° Bliźniąt
assert antiscion(90.0) == pytest.approx(90.0) # 0° Raka leży NA osi
def test_antiscion_is_an_involution():
for lon in (12.3, 88.0, 200.5, 359.9):
assert antiscion(antiscion(lon)) == pytest.approx(lon)
def test_contra_antiscion_across_equinoctial_axis():
assert contra_antiscion(0.0) == pytest.approx(0.0) # 0° Barana leży NA osi
assert contra_antiscion(30.0) == pytest.approx(330.0) # 0° Byka → 0° Ryb (odbicie)
def test_sign_pairs_are_classic():
"""Antyscja parami znaków: Baran↔Panna, Byk↔Lew, Bliźnięta↔Rak."""
# 5° Barana (5°) → 175° = 25° Panny
assert antiscion(5.0) == pytest.approx(175.0)
# 10° Byka (40°) → 140° = 20° Lwa
assert antiscion(40.0) == pytest.approx(140.0)
# --------------------------------------------------- wykrywanie paraleli/antyscji
def test_parallel_and_contraparallel_detection():
bodies = [
{"name": "A", "declination": 12.0},
{"name": "B", "declination": 12.4}, # paralela z A (0.4°)
{"name": "C", "declination": -11.6}, # kontrparalela z A (|12-11.6|=0.4)
{"name": "D", "declination": 5.0}, # nic (daleko)
]
res = {(r["obj1"], r["obj2"]): r for r in find_declination_aspects(bodies, orb=1.0)}
assert res[("A", "B")]["type"] == "parallel"
assert res[("A", "C")]["type"] == "contraparallel"
assert ("A", "D") not in res
def test_orb_is_configurable():
bodies = [{"name": "A", "declination": 10.0}, {"name": "B", "declination": 11.5}]
assert find_declination_aspects(bodies, orb=1.0) == [] # 1.5° > 1.0°
assert len(find_declination_aspects(bodies, orb=2.0)) == 1 # mieści się
def test_nodes_definitional_contraparallel_is_filtered(chart):
"""SN = NN+180° na ekliptyce → deklinacja przeciwna ZAWSZE. To kontakt bez
informacji, więc pary NN/SN nie może być w wynikach."""
decl = {p["name"]: p["declination"] for p in chart["positions"]}
# najpierw potwierdzamy, że deklinacje faktycznie są przeciwne
assert decl["North Node"] == pytest.approx(-decl["South Node"], abs=0.05)
# a mimo to para NN/SN nie pojawia się na liście paraleli
pairs = {frozenset((r["obj1"], r["obj2"])) for r in chart["parallels"]}
assert frozenset(("North Node", "South Node")) not in pairs
def test_antiscia_detection_with_constructed_longitudes():
# A na 40°, B na 138° → antyscja A leży na 140°, więc B jest 2° od niej
bodies = [{"name": "A", "decimal": 40.0}, {"name": "B", "decimal": 138.0}]
assert find_antiscia(bodies, orb=1.0) == [] # 2° > 1°
hit = find_antiscia(bodies, orb=3.0)
assert len(hit) == 1 and hit[0]["type"] == "antiscion"
def test_contra_antiscion_detection():
# A na 40°, kontrantyscja A = 320°; B na 320.5° → kontrantyscja w orbie
bodies = [{"name": "A", "decimal": 40.0}, {"name": "B", "decimal": 320.5}]
hit = find_antiscia(bodies, orb=1.0)
assert len(hit) == 1 and hit[0]["type"] == "contra_antiscion"
# ------------------------------------------------------ spójność z build_chart
def test_chart_exposes_parallels_and_antiscia(chart):
assert "parallels" in chart and "antiscia" in chart
assert "obliquity" in chart
# znany, ciasny kontakt z tego horoskopu: Uran ~paralela~ Neptun (mid-80s)
un = next((r for r in chart["parallels"]
if {r["obj1"], r["obj2"]} == {"Uranus", "Neptune"}), None)
assert un is not None and un["type"] == "parallel"
def test_declination_is_zodiac_independent(own_engine, krakow):
"""Deklinacja jest fizyczna — nie zmienia się z wyborem zodiaku."""
tropical = build_chart(own_engine, krakow, zodiac="tropical")
sidereal = build_chart(own_engine, krakow, zodiac="sidereal_lahiri")
dt = {p["name"]: p["declination"] for p in tropical["positions"]}
ds = {p["name"]: p["declination"] for p in sidereal["positions"]}
for name in dt:
assert dt[name] == pytest.approx(ds[name], abs=1e-6)
-65
View File
@@ -1,65 +0,0 @@
"""Token międzywarstwowy (LOG-32).
Warstwa logiczna oddaje treść baz, więc musi odrzucać żądania z pominięciem UI.
"""
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
from app import security
TOKEN = "tajny-token-testowy"
def _app() -> FastAPI:
app = FastAPI()
security.install(app, "testowa")
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/secret")
def secret():
return {"rows": ["treść z bazy"]}
return app
@pytest.fixture
def guarded(monkeypatch):
monkeypatch.setenv("INTERNAL_TOKEN", TOKEN)
return TestClient(_app())
@pytest.fixture
def open_app(monkeypatch):
monkeypatch.delenv("INTERNAL_TOKEN", raising=False)
return TestClient(_app())
def test_rejects_request_without_token(guarded):
assert guarded.get("/secret").status_code == 401
def test_rejects_wrong_token(guarded):
r = guarded.get("/secret", headers={security.HEADER: "zly"})
assert r.status_code == 401
assert "treść z bazy" not in r.text
def test_accepts_correct_token(guarded):
r = guarded.get("/secret", headers={security.HEADER: TOKEN})
assert r.status_code == 200
assert r.json()["rows"] == ["treść z bazy"]
def test_health_stays_public(guarded):
"""Sonda k8s nie zna tokenu — /health musi działać bez niego."""
assert guarded.get("/health").status_code == 200
def test_disabled_when_token_unset(open_app):
"""Brak konfiguracji = zgodność wstecz (dev), nie blokada."""
assert not security.enabled()
assert open_app.get("/secret").status_code == 200
@@ -61,42 +61,6 @@ def test_aspect_facet():
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
def test_parallel_facet_hits_db_and_avoids_decanate_trap():
"""Paralela deklinacji (LOG-07): baza zna ją jako „P. Dec.". Pułapka: samo
„Dec." to często DEKANAT („3rd Dec. of [Gem"), który NIE może wpaść do paraleli."""
positions = [
{"name": "Mercury", "sign": "Taurus", "direction": "D", "house": 3},
{"name": "Saturn", "sign": "Scorpio", "direction": "Rx", "house": 9},
]
parallels = [{"obj1": "Mercury", "obj2": "Saturn", "type": "parallel",
"orb": 0.3, "allowed": 1.0}]
data = FakeData({"[Me": [
{"significator": "[Me at B. in P. Dec. [Sa and [Ma", "actioneffect": "dyscyplina umysłu"},
{"significator": "3rd Dec. of [Gem on Asc.", "actioneffect": "TO DEKANAT, nie paralela"},
{"significator": "[Me [conj [Sa", "actioneffect": "inny aspekt, nie paralela"},
]})
par = [f for f in build_report(positions, data, parallels=parallels)["objects"][0]["facets"]
if f["type"] == "parallel"]
assert par and par[0]["count"] == 1
assert "Saturn" in par[0]["label"]
assert all("Dec. of" not in s["significator"] for s in par[0]["samples"]), "dekanat przeciekł"
def test_contraparallel_gets_no_db_facet():
"""Baza nie ma osobnego znacznika dla kontrparaleli — nie zmyślamy fasety."""
positions = [
{"name": "Mercury", "sign": "Taurus", "direction": "D", "house": 3},
{"name": "Saturn", "sign": "Scorpio", "direction": "Rx", "house": 9},
]
parallels = [{"obj1": "Mercury", "obj2": "Saturn", "type": "contraparallel",
"orb": 0.3, "allowed": 1.0}]
data = FakeData({"[Me": [
{"significator": "[Me at B. in P. Dec. [Sa", "actioneffect": "paralela, nie kontr-"},
]})
types = [f["type"] for f in build_report(positions, data, parallels=parallels)["objects"][0]["facets"]]
assert "parallel" not in types
def test_grouping_by_effect():
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
data = FakeData({"[Ma": [
-216
View File
@@ -1,216 +0,0 @@
"""Tabele pomocnicze horoskopu (LOG-23).
Wartości referencyjne dla horoskopu 30.04.1984 09:20 UTC, Kraków (50.0647N, 19.9450E)
sprawdzone wobec faktów NIEZALEŻNYCH od naszego kodu:
* 30.04.1984 to poniedziałek → władca dnia Księżyc; 5. godzina poniedziałku
w porządku chaldejskim to Słońce (Mo, Sa, Ju, Ma, Su),
* wschód/zachód dla Krakowa końcem kwietnia ≈ 5:18 / 19:57 czasu lokalnego
(CEST = UTC+2), czyli 03:18 / 17:57 UTC,
* pełnia poprzedzająca urodzenie: 15.04.1984 ok. 19:11 UTC.
"""
from datetime import datetime, timezone
import pytest
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.engine.tables import (
build_tables,
critical_degrees,
dwadasamsa,
element_of,
moon_phase,
navamsa,
planetary_hours,
prenatal_syzygy,
quality_of,
tally,
)
@pytest.fixture(scope="module")
def krakow():
return ChartMoment(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
lat=50.0647, lon=19.9450)
@pytest.fixture(scope="module")
def tables(own_engine, krakow):
return build_tables(own_engine, krakow, build_chart(own_engine, krakow))
# ------------------------------------------------------ żywioły i jakości
def test_element_and_quality_mapping():
assert element_of("Aries") == "Fire" and element_of("Cancer") == "Water"
assert quality_of("Aries") == "Cardinal" and quality_of("Taurus") == "Fixed"
assert quality_of("Gemini") == "Mutable"
def test_tally_matches_hand_count(tables):
"""Ręcznie przeliczone dla horoskopu referencyjnego (10 planet + Asc)."""
base = tables["tally"]["with_modern_10_plus_asc"]
assert base["elements"] == {"Fire": 4, "Earth": 4, "Air": 0, "Water": 3}
assert base["qualities"] == {"Cardinal": 4, "Fixed": 6, "Mutable": 1}
assert base["total"] == 11
def test_missing_element_detected(tables):
"""Klasyczne „no air" — podstawa pod scoring siły (LOG-21)."""
assert tables["tally"]["missing_elements"] == ["Air"]
assert tables["tally"]["missing_qualities"] == []
def test_tally_variants_differ_by_object_count(tables):
t = tables["tally"]
assert t["classical_7"]["total"] == 7
assert t["with_modern_10"]["total"] == 10
assert t["classical_7_plus_asc"]["total"] == 8
assert t["with_modern_10_plus_asc"]["total"] == 11
def test_tally_sums_equal_counted_objects(tables):
for variant in ("classical_7", "with_modern_10", "with_modern_10_plus_asc"):
v = tables["tally"][variant]
assert sum(v["elements"].values()) == v["total"]
assert sum(v["qualities"].values()) == v["total"]
# ---------------------------------------------------------- faza Księżyca
def test_moon_phase_reference_is_balsamic_new(tables):
"""Nów wypadł 1.05.1984, więc 30.04 Księżyc jest tuż przed nowiem."""
mp = tables["moon_phase"]
assert mp["phase"] == "New Moon"
assert 340.0 < mp["angle"] < 360.0
assert mp["illumination"] < 0.05
assert mp["waxing"] is False # elongacja > 180 = ubywa
@pytest.mark.parametrize("angle,expected", [
(0.0, "New Moon"), (90.0, "First Quarter"), (180.0, "Full Moon"),
(270.0, "Last Quarter"), (46.0, "Waxing Crescent"), (300.0, "Waning Crescent"),
])
def test_moon_phase_buckets(angle, expected):
assert moon_phase(0.0, angle)["phase"] == expected
def test_moon_phase_illumination_extremes():
assert moon_phase(0.0, 0.0)["illumination"] == 0.0
assert moon_phase(0.0, 180.0)["illumination"] == 1.0
assert moon_phase(0.0, 90.0)["illumination"] == pytest.approx(0.5)
# ------------------------------------------------------- stopnie krytyczne
def test_critical_degrees_reference(tables):
found = {c["name"]: c["flags"] for c in tables["critical_degrees"]}
assert "Jupiter" in found # Cap 12°57' -> 13° kardynalny
assert any("13" in f for f in found["Jupiter"])
assert "Pluto" in found # Sco 0°28' -> wejście w znak
def test_anaretic_degree_flagged():
flags = critical_degrees([{"name": "X", "sign": "Leo", "decimal": 149.5,
"in_sign": "Leo 29°30'"}])
assert flags and any("anaretyczny" in f for f in flags[0]["flags"])
def test_no_flags_for_ordinary_degree():
assert critical_degrees([{"name": "X", "sign": "Leo", "decimal": 135.0,
"in_sign": "Leo 15°"}]) == []
# --------------------------------------------------------------- podziały
def test_dwadasamsa_starts_from_own_sign():
"""12. część liczy się OD znaku, w którym stoi punkt."""
assert dwadasamsa(0.0) == pytest.approx(0.0) # Ari 0 -> Ari
assert dwadasamsa(2.5) == pytest.approx(30.0) # Ari 2°30' -> Tau 0
assert dwadasamsa(30.0) == pytest.approx(30.0) # Tau 0 -> Tau
def test_navamsa_classic_starts():
"""Znaki kardynalne zaczynają od siebie, stałe od 9. znaku."""
assert navamsa(0.0) == pytest.approx(0.0) # Ari -> Ari
assert navamsa(30.0) == pytest.approx(270.0) # Tau -> Cap (9. od Byka)
assert navamsa(60.0) == pytest.approx(180.0) # Gem -> Lib
def test_divisional_covers_all_positions(tables, own_engine, krakow):
chart = build_chart(own_engine, krakow)
assert len(tables["divisional"]) == len(chart["positions"])
# ------------------------------------------------- dzień i godziny planetarne
def test_planetary_day_ruler_is_moon_on_monday(tables):
"""30.04.1984 to poniedziałek → władcą dnia jest Księżyc."""
assert tables["planetary_hours"]["day_ruler"] == "Moon"
def test_planetary_hour_matches_chaldean_sequence(tables):
"""Poniedziałek: 1=Mo, 2=Sa, 3=Ju, 4=Ma, 5=Su — urodzenie w 5. godzinie dnia."""
ph = tables["planetary_hours"]
assert ph["hour_number"] == 5
assert ph["hour_ruler"] == "Sun"
assert ph["daytime"] is True
def test_sunrise_sunset_match_krakow_late_april(tables):
"""Wschód ≈ 03:18 UTC, zachód ≈ 17:57 UTC (5:18 i 19:57 czasu lokalnego)."""
ph = tables["planetary_hours"]
assert ph["period_start"].startswith("1984-04-30T03:1")
assert ph["period_end"].startswith("1984-04-30T17:5")
def test_planetary_hours_are_unequal_and_complete(tables):
"""Godziny są nierówne: wiosną dzienna trwa dłużej niż 60 minut."""
ph = tables["planetary_hours"]
assert ph["hour_length_minutes"] > 60.0
assert len(ph["hours"]) == 12
assert sum(1 for h in ph["hours"] if h["current"]) == 1
def test_polar_night_returns_none(own_engine):
"""Za kołem podbiegunowym w grudniu Słońce nie wschodzi — brak godzin."""
polar = ChartMoment(when_utc=datetime(2024, 12, 21, 12, 0, tzinfo=timezone.utc),
lat=78.0, lon=15.0)
assert planetary_hours(own_engine, polar) is None
# ------------------------------------------------------- syzygia prenatalna
def test_prenatal_syzygy_is_april_1984_full_moon(tables):
"""Rzeczywista pełnia: 15.04.1984 ok. 19:11 UTC."""
s = tables["prenatal_syzygy"]
assert s["type"] == "full_moon"
assert s["when_utc"].startswith("1984-04-15T19:1")
def test_prenatal_syzygy_precedes_birth_within_a_cycle(tables):
days = tables["prenatal_syzygy"]["days_before_birth"]
assert 0 < days < 29.6, "syzygia musi być w ostatnim cyklu przed urodzeniem"
def test_prenatal_syzygy_elongation_is_at_target(own_engine, krakow):
"""W znalezionym momencie elongacja MUSI wynosić 0° albo 180°."""
from app.engine.formats import norm360
s = prenatal_syzygy(own_engine, krakow)
when = datetime.fromisoformat(s["when_utc"])
pts = {p.name: p.longitude for p in
own_engine.positions(ChartMoment(when_utc=when, lat=krakow.lat, lon=krakow.lon),
["Sun", "Moon"])}
elong = norm360(pts["Moon"] - pts["Sun"])
target = 0.0 if s["type"] == "new_moon" else 180.0
assert abs(((elong - target + 180.0) % 360.0) - 180.0) < 0.02
# ------------------------------------------------------------------ całość
def test_build_tables_light_skips_numeric_search(own_engine, krakow):
"""heavy=False pomija to, co wymaga szukania numerycznego."""
light = build_tables(own_engine, krakow, build_chart(own_engine, krakow), heavy=False)
assert "tally" in light and "moon_phase" in light
assert "planetary_hours" not in light and "prenatal_syzygy" not in light
-606
View File
@@ -1,606 +0,0 @@
"""Kosmogram — koło horoskopowe jako SVG (PRE-12).
Rysujemy po stronie serwera, bez zależności zewnętrznych (spójne z CSP): czysty
string SVG wstawiany do strony. Gotowe: pierścień znaków, podział na domy, osie
oraz OBIEKTY (glify, stopień w znaku, retrogradacja). Linie aspektów w kolejnym
kroku.
Geometria (konwencja astrologiczna):
* Ascendent po LEWEJ (godzina 9), długość ekliptyczna rośnie PRZECIWNIE do
ruchu wskazówek zegara — jak na niebie i w każdym programie.
* Punkt o długości λ trafia na kąt φ = 180° Asc). Zakotwiczenie:
λ=Asc → lewo, λ=Asc+90° → dół, λ=Asc+270° (≈MC) → góra. W SVG y rośnie w dół,
co ta formuła już uwzględnia (sin dodatni = w dół).
Dane bierzemy z wyniku /chart/positions (warstwa logiki): `angles` (z długością
`decimal`), `cusps` (z `decimal` — dołożone pod ten widok) i `sign_glyphs`
(pierścień 12 znaków z glifami — LOG-22). Prezentacja niczego nie liczy sama.
"""
from __future__ import annotations
import math
from html import escape
# ── promienie (viewBox 470×470, środek 235,235) ─────────────────────────
# Płótno jest większe od koła CELOWO: margines poza obwodem mieści etykiety osi
# (AC/DC/MC/IC). Wcześniej siedziały wewnątrz, w pierścieniu obiektów, i zasłaniały
# glify w ciasnych skupiskach — widać było na renderze.
_CX = _CY = 235.0
_VIEW = 470
R_OUT = 210.0 # zewnętrzny okrąg
R_AXIS_LABEL = 222.0 # etykiety osi — w marginesie, poza kołem
R_ZOD = 178.0 # wewnętrzna krawędź pasa znaków
R_SIGN = 194.0 # promień glifów znaków (środek pasa)
R_TICK = 172.0 # koniec drobnych podziałek stopni (do wewnątrz od pasa)
R_HUB = 48.0 # centralne kółko — tu kończą się szprychy domów i osie
R_HNUM = 64.0 # promień numerów domów (tuż przy piaście)
R_PLANET = 150.0 # promień glifów obiektów
R_LOT = 116.0 # promień glifów Lotów — pod pierścieniem planet (to nie ciała)
R_DEG = 130.0 # promień podpisu stopnia (pod glifem)
R_MARK = R_ZOD - 7.0 # koniec kreski wskazującej PRAWDZIWĄ pozycję na pasie
# Minimalny odstęp kątowy między glifami obiektów. O ciasnocie decyduje NIE glif,
# tylko podpis stopnia — leży bliżej środka (r=130), gdzie ten sam kąt to mniej
# pikseli: 8° daje tam ~18 px, a dwuznakowy podpis z ℞ zajmuje ~16 px. Przy 7°
# (pierwsze podejście) podpisy w skupiskach zlewały się — widać było na renderze.
# Bez rozsuwania w ogóle ciasne układy (u nas Merkury i Wenus 0,4°!) rysują się
# jeden na drugim.
MIN_SEP = 8.0
# muted kolory żywiołów — czytelne na ciemnym tle, spójne z paletą aplikacji
_ELEMENT_COLOR = ["#d98a6a", "#8fae7a", "#cfc06a", "#6aa6c9"] # ogień, ziemia, powietrze, woda
# ── motywy ───────────────────────────────────────────────────────────────
# „screen" korzysta ze zmiennych CSS strony, więc koło samo dostraja się do
# motywu aplikacji. „print" MUSI mieć konkretne kolory: SVG idzie wtedy do
# osobnego konwertera (rsvg → PDF, PRE-24), który o naszym arkuszu nic nie wie
# i zmiennych `var(--…)` nie rozwiąże — wyszłyby czarne albo żadne kreski.
# Przy okazji odwracamy kontrast: raport drukuje się na białym papierze.
# Kolor linii aspektu koduje jego CHARAKTER, jak w klasycznych programach:
# niebieski = harmonijny (trygon, sekstyl), czerwony = napięty (kwadratura,
# opozycja), zielony = koniunkcja (neutralna, wzmacnia). To pierwszy sygnał, jaki
# łapie oko — dwa kolory na środku koła mówią „gdzie łatwo, gdzie tarcie".
_ASPECT_COLORS_SCREEN = {
"conjunction": "#8fae7a", "sextile": "#6aa6c9", "trine": "#6aa6c9",
"square": "#cf6a6a", "opposition": "#cf6a6a",
"semisextile": "#6aa6c9", "semisquare": "#cf6a6a", "quincunx": "#cf6a6a",
}
_ASPECT_COLORS_PRINT = {
"conjunction": "#3f6b33", "sextile": "#1f5c80", "trine": "#1f5c80",
"square": "#a83232", "opposition": "#a83232",
"semisextile": "#1f5c80", "semisquare": "#a83232", "quincunx": "#a83232",
}
_THEMES = {
"screen": {"line": "var(--line)", "ink": "var(--ink)", "muted": "var(--muted)",
"accent": "var(--accent)", "disc": "#12132a", "retro": "#ff9b6a",
"elements": _ELEMENT_COLOR, "aspects": _ASPECT_COLORS_SCREEN},
"print": {"line": "#b9bcc9", "ink": "#111318", "muted": "#5d6270",
"accent": "#5a49c8", "disc": "#ffffff", "retro": "#b2452a",
# ciemniejsze warianty żywiołów — na białym tle jasne byłyby niewidoczne
"elements": ["#a8442a", "#3f6b33", "#8a6d10", "#1f5c80"],
"aspects": _ASPECT_COLORS_PRINT},
}
# Polskie nazwy aspektów — tylko do dymków; obliczenia trzymają nazwy angielskie.
_ASPECT_PL = {
"conjunction": "koniunkcja", "sextile": "sekstyl", "square": "kwadratura",
"trine": "trygon", "opposition": "opozycja", "semisextile": "półsekstyl",
"semisquare": "półkwadratura", "quincunx": "kwinkunks",
}
# Glify aspektów (te same znaki co w warstwie logiki: engine/glyphs.py ASPECT).
# Trzymamy je lokalnie, bo prezentacja ma już u siebie kolory i polskie nazwy
# aspektów — dzięki temu aspektarian jest samowystarczalny i nie zależy od tego,
# czy pojedynczy rekord aspektu niesie glif.
_ASPECT_GLYPH = {
"conjunction": "", "opposition": "", "trine": "", "square": "",
"sextile": "", "semisextile": "", "quincunx": "", "semisquare": "",
}
def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
"""Grubość i przezroczystość linii aspektu wg orbu.
Im ciaśniejszy aspekt, tym mocniejsza linia — bo tym silniej działa. Poniżej
1° POGRUBIAMY wyraźnie (wprost z wymagania): najściślejsze aspekty mają się
rzucać w oczy. Szerokie, na granicy orbu, ledwo majaczą — żeby nie zamieniać
środka koła w plątaninę."""
allowed = allowed or 8.0
frac = max(0.0, 1.0 - orb / allowed) # 1 = dokładny, 0 = na krawędzi orbu
if orb < 1.0:
return 2.0, 0.95 # ciasny — pogrubiony
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
phi = math.radians(180.0 - (lon - asc))
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
def _line(x1: float, y1: float, x2: float, y2: float, stroke: str, w: float = 1.0,
opacity: float = 1.0, title: str = "") -> str:
attrs = (f'x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"')
# <title> jako dziecko daje natywny dymek po najechaniu — bez JS-a.
if title:
return f'<line {attrs}><title>{escape(title)}</title></line>'
return f'<line {attrs}/>'
# Stack fontów z glifami astrologicznymi. Na stronie dostarcza go klasa CSS
# `.glyph`, ale w wariancie do druku SVG jedzie do SAMODZIELNEGO konwertera, który
# naszego arkusza nie widzi — wtedy trzeba wpisać font wprost do elementu, inaczej
# rysunek wychodzi BEZ symboli (konwerter bierze domyślny font, a ten ich nie ma).
GLYPH_FONT = ("Apple Symbols, Segoe UI Symbol, Noto Sans Symbols2, "
"DejaVu Sans, sans-serif")
def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "",
font: str = "", title: str = "", weight: str = "") -> str:
c = f' class="{cls}"' if cls else ""
f = f' font-family="{font}"' if font else ""
w = f' font-weight="{weight}"' if weight else ""
t = f"<title>{escape(title)}</title>" if title else "" # dymek po najechaniu
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
f' text-anchor="middle" dominant-baseline="central"{c}{f}{w}>{t}{escape(s)}</text>')
def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]:
"""Rozsuwa kąty tak, by sąsiedzi mieli co najmniej `min_sep`, zachowując
KOLEJNOŚĆ obiektów na kole i trzymając je blisko prawdziwych pozycji.
Metoda: relaksacja po okręgu — każdą zbyt ciasną parę rozpychamy po połowie
brakującego odstępu i powtarzamy, aż nic się nie rusza. Symetryczne pchnięcie
sprawia, że skupisko rozkłada się wokół swojego środka, zamiast dryfować
w jedną stronę.
Gdy obiektów jest tyle, że nie da się ich rozsunąć (n·min_sep ≥ 360°),
rozkładamy je równomiernie — lepiej równo niż w nieskończonej pętli.
"""
n = len(lons)
if n < 2:
return list(lons)
order = sorted(range(n), key=lambda i: lons[i] % 360.0)
a = [lons[i] % 360.0 for i in order]
if n * min_sep >= 360.0:
a = [(a[0] + i * 360.0 / n) % 360.0 for i in range(n)]
else:
# ROZWIJAMY kąty do osi monotonicznej. To nie kosmetyka: gdy liczyliśmy
# odstęp modulo 360, przesunięcie, które przerzuciło obiekt ZA sąsiada,
# dawało lukę ~359,9° zamiast ujemnej — algorytm uznawał, że jest luzem,
# i kończył z dwoma glifami na sobie (Wenus lądowała na Księżycu).
# Na osi rozwiniętej ujemna luka zostaje ujemna i zawsze się ją wyłapie.
for i in range(1, n):
while a[i] < a[i - 1]:
a[i] += 360.0
for _ in range(passes):
moved = False
for i in range(n - 1):
gap = a[i + 1] - a[i]
if gap < min_sep - 1e-9:
push = (min_sep - gap) / 2.0
a[i] -= push
a[i + 1] += push
moved = True
closing = (a[0] + 360.0) - a[n - 1] # domknięcie okręgu
if closing < min_sep - 1e-9:
push = (min_sep - closing) / 2.0
a[n - 1] -= push
a[0] += push
moved = True
if not moved:
break
out = [0.0] * n
for slot, original in enumerate(order):
out[original] = a[slot] % 360.0
return out
def _delta(a: float, b: float) -> float:
"""Najmniejsza różnica kątów w [0,180]."""
return abs(((a - b + 180.0) % 360.0) - 180.0)
def render(chart: dict, theme: str = "screen") -> 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.
"""
T = _THEMES.get(theme, _THEMES["screen"])
# tylko dla druku — na stronie font podaje CSS (.glyph)
glyph_font = GLYPH_FONT if theme == "print" else ""
angles = chart.get("angles")
cusps = chart.get("cusps")
signs = chart.get("sign_glyphs")
if not (angles and cusps and signs) or "decimal" not in (cusps[0] if cusps else {}):
return ""
asc = float(angles["Asc"]["decimal"])
parts: list[str] = []
# tło i okręgi
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_OUT}" fill="{T["disc"]}" stroke="{T["line"]}" stroke-width="1.5"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_ZOD}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_HUB}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
# ── linie aspektów (LOG-06) w środku koła ──────────────────────────
# Łączą PRAWDZIWE pozycje obiektów (nie rozsunięte glify — aspekt dotyczy
# tego, gdzie planeta faktycznie stoi) na okręgu piasty. Rysowane jako
# PIERWSZE, pod resztą: obiekty siedzą na R_PLANET=150, daleko od piasty
# (R_HUB=48), więc się nie stykają, a osie i szprychy lądują na wierzchu.
pos_by_name = {p["name"]: float(p["decimal"])
for p in (chart.get("positions") or []) if p.get("decimal") is not None}
for asp in (chart.get("aspects") or []):
la = pos_by_name.get(asp.get("obj1"))
lb = pos_by_name.get(asp.get("obj2"))
if la is None or lb is None:
continue
color = T["aspects"].get(asp.get("aspect"), T["muted"])
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
ax1, ay1 = _pt(la, asc, R_HUB)
bx1, by1 = _pt(lb, asc, R_HUB)
tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} '
f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°')
parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
# 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)
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)
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
gx, gy = _pt(b + 15.0, asc, R_SIGN)
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
# szprychy domów (od pasa do piasty) + numery domów w środku każdego domu
n = len(cusps)
for i, c in enumerate(cusps):
lon = float(c["decimal"])
x1, y1 = _pt(lon, asc, R_ZOD)
x2, y2 = _pt(lon, asc, R_HUB)
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
nxt = float(cusps[(i + 1) % n]["decimal"])
span = (nxt - lon) % 360.0 or 360.0
mid = lon + span / 2.0
hx, hy = _pt(mid, asc, R_HNUM)
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
# Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy
# są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer;
# w systemach kwadratowych ta liczba realnie coś mówi.
deg_in_sign = lon % 30.0
if deg_in_sign >= 0.5:
cx1, cy1 = _pt(lon, asc, R_ZOD - 9)
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
# osie: AscDsc i MCIC przez całe koło, wyróżnione akcentem
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
lon_a = float(angles[a]["decimal"])
ax, ay = _pt(lon_a, asc, R_ZOD)
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
# ── obiekty: glify na pozycjach, stopień w znaku, retrogradacja ─────
# Glify rozsuwamy, ale kreska na pasie zostaje w PRAWDZIWEJ pozycji —
# inaczej wykres kłamałby o tym, gdzie planeta faktycznie stoi. Gdy glif
# jest przesunięty, łączymy go z kreską cienką linią odniesienia.
objects = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
true_lons = [float(p["decimal"]) for p in objects]
draw_lons = spread(true_lons)
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
retro = obj.get("direction") == "Rx"
mx1, my1 = _pt(true_lon, asc, R_ZOD)
mx2, my2 = _pt(true_lon, asc, R_MARK)
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
if _delta(draw_lon, true_lon) > 0.4:
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
gx, gy = _pt(draw_lon, asc, R_PLANET)
tip = obj.get("name") or ""
if obj.get("in_sign"):
tip += f" · {obj['in_sign']}"
if obj.get("house"):
tip += f" · dom {obj['house']}"
if retro:
tip += " · retrogradacja"
parts.append(_text(gx, gy, obj.get("glyph") or "", size=16,
fill=T["ink"], cls="glyph", font=glyph_font, title=tip))
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
dx, dy = _pt(draw_lon, asc, R_DEG)
label = f"{int(true_lon % 30)}" + ("" if retro else "")
parts.append(_text(dx, dy, label, size=8,
fill=T["retro"] if retro else T["muted"]))
# ── Lots (LOG-08): tylko te z glifem (Fortuna ⊗) ──────────────────
# Rysowane NA PIASTOWEJ stronie planet (R_LOT < R_PLANET) i akcentem, żeby
# od razu było widać, że to punkt wyliczony, nie ciało niebieskie. Bez
# rozsuwania — Lotów z glifem jest mało, a i tak siedzą pod pierścieniem planet.
for lot in (chart.get("lots") or []):
glyph = lot.get("glyph")
if not glyph or lot.get("longitude") is None:
continue
lon = float(lot["longitude"])
mx1, my1 = _pt(lon, asc, R_ZOD)
mx2, my2 = _pt(lon, asc, R_MARK)
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
lx, ly = _pt(lon, asc, R_LOT)
tip = lot.get("name") or ""
if lot.get("in_sign"):
tip += f" · {lot['in_sign']}"
if lot.get("house"):
tip += f" · dom {lot['house']}"
parts.append(_text(lx, ly, glyph, size=13, fill=T["accent"],
cls="glyph", font=glyph_font, title=tip))
body = "".join(parts)
return (f'<svg class="wheel" viewBox="0 0 {_VIEW} {_VIEW}" role="img" '
f'aria-label="Kosmogram — koło horoskopowe" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
ASP_CELL = 30.0 # bok komórki siatki aspektów
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
"""Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG.
Klasyczny „schodkowy" układ: glify obiektów biegną po przekątnej, a każda
komórka POD nią to aspekt między obiektem ze swojego wiersza i ze swojej
kolumny — albo pusto, gdy pary nie łączy żaden aspekt (brak też coś mówi).
Koło pokazuje GEOMETRIĘ aspektów, aspektarian — ich TABELĘ na jeden rzut oka.
Kolory i pogrubienie ciasnych aspektów są SPÓJNE z liniami na kole (ten sam
motyw i te same barwy). Zwraca '' przy mniej niż dwóch obiektach.
theme='print' — konkretne kolory + font glifów wprost w elementach, bo SVG
trafia wtedy do samodzielnego konwertera (PDF), który arkusza CSS nie widzi.
"""
T = _THEMES.get(theme, _THEMES["screen"])
glyph_font = GLYPH_FONT if theme == "print" else ""
objects = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
n = len(objects)
if n < 2:
return ""
# aspekt po parze NAZW — kolejność obiektów w parze nie ma znaczenia
by_pair: dict = {}
for a in (chart.get("aspects") or []):
by_pair[frozenset((a.get("obj1"), a.get("obj2")))] = a
S = ASP_CELL
pad = 1.0
dim = n * S + 2 * pad
parts: list[str] = []
for i, oi in enumerate(objects):
# przekątna: glif obiektu na delikatnym tle — kręgosłup siatki
dx = pad + i * S
dy = pad + i * S
parts.append(
f'<rect x="{dx:.1f}" y="{dy:.1f}" width="{S}" height="{S}" '
f'fill="{T["accent"]}" opacity="0.14" stroke="{T["line"]}" stroke-width="0.6"/>')
dtip = oi.get("name") or ""
if oi.get("in_sign"):
dtip += f" · {oi['in_sign']}"
parts.append(_text(dx + S / 2, dy + S / 2, oi.get("glyph") or "", size=15,
fill=T["ink"], cls="glyph", font=glyph_font, title=dtip))
# komórki aspektów w tym wierszu: kolumny j < i (dolny-lewy trójkąt)
for j in range(i):
oj = objects[j]
cx = pad + j * S
cy = pad + i * S
parts.append(
f'<rect x="{cx:.1f}" y="{cy:.1f}" width="{S}" height="{S}" '
f'fill="none" stroke="{T["line"]}" stroke-width="0.6"/>')
asp = by_pair.get(frozenset((oi.get("name"), oj.get("name"))))
if not asp:
continue
name = asp.get("aspect")
glyph = _ASPECT_GLYPH.get(name)
if not glyph:
continue
orb = float(asp.get("orb", 0.0))
tight = orb < 1.0 # jak na kole: ciasny = wyróżniony
color = T["aspects"].get(name, T["muted"])
tip = (f'{oi.get("name")} {_ASPECT_PL.get(name, name)} {oj.get("name")} · '
f'orb {orb:.2f}°')
if asp.get("as"):
tip += " · " + ("aplikacyjny" if asp["as"] == "A" else "separacyjny")
parts.append(_text(cx + S / 2, cy + S / 2, glyph, size=17 if tight else 14,
fill=color, cls="glyph", font=glyph_font, title=tip,
weight="bold" if tight else ""))
body = "".join(parts)
return (f'<svg class="aspectarian" viewBox="0 0 {dim:.1f} {dim:.1f}" role="img" '
f'aria-label="Aspektarian — siatka aspektów obiekt na obiekt" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
# ── deklinacja i antyscja (LOG-07 → kosmogram etap 6) ─────────────────────
# Polskie nazwy zjawisk pozazodiakalnych — tylko do dymków.
_OOZ_PL = {
"parallel": "paralela", "contraparallel": "kontrparalela",
"antiscion": "antyscja", "contra_antiscion": "kontrantyscja",
}
def _dashed(x1: float, y1: float, x2: float, y2: float, stroke: str,
w: float = 0.8, opacity: float = 0.9, dash: str = "4 3") -> str:
return (f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}" '
f'stroke-dasharray="{dash}"/>')
_DECL_W, _DECL_H = 470.0, 300.0
_DPX0, _DPX1 = 54.0, 452.0 # obszar rysunku w poziomie
_DPY0, _DPY1 = 30.0, 262.0 # góra (+Rmax) i dół (Rmax)
def render_declination(chart: dict, theme: str = "screen") -> str:
"""Wykres deklinacji (LOG-07, etap 6). Pionowa skala deklinacji: obiekty na
tej samej wysokości są w PARALELI (działa jak koniunkcja poza ekliptyką), na
lustrzanych względem równika — w KONTRPARALELI (jak opozycja). Zwrotniki (±ε)
to granica „poza zakresem" (OOB): ciało za nimi ma większą deklinację niż
kiedykolwiek Słońce. Kolory łączników jak na kole. '' przy < 2 obiektach."""
T = _THEMES.get(theme, _THEMES["screen"])
glyph_font = GLYPH_FONT if theme == "print" else ""
objs = [p for p in (chart.get("positions") or []) if p.get("declination") is not None]
if len(objs) < 2:
return ""
eps = float(chart.get("obliquity") or 23.4367)
maxabs = max(abs(float(p["declination"])) for p in objs)
Rmax = max(eps + 3.0, maxabs + 2.0) # skala mieści zwrotniki i OOB
def y_of(dec: float) -> float:
return _DPY0 + (Rmax - dec) / (2.0 * Rmax) * (_DPY1 - _DPY0)
parts: list[str] = []
# strefy OOB (za zwrotnikami) — delikatne tło
yN, yS = y_of(eps), y_of(-eps)
parts.append(f'<rect x="{_DPX0:.1f}" y="{_DPY0:.1f}" width="{_DPX1 - _DPX0:.1f}" '
f'height="{yN - _DPY0:.1f}" fill="{T["retro"]}" opacity="0.07"/>')
parts.append(f'<rect x="{_DPX0:.1f}" y="{yS:.1f}" width="{_DPX1 - _DPX0:.1f}" '
f'height="{_DPY1 - yS:.1f}" fill="{T["retro"]}" opacity="0.07"/>')
# równik (0°) i zwrotniki (±ε)
yeq = y_of(0.0)
parts.append(_line(_DPX0, yeq, _DPX1, yeq, T["line"], 1.0, 0.9))
parts.append(_text(_DPX0 - 8, yeq, "", size=8, fill=T["muted"]))
for d, lab in ((eps, f"+{eps:.0f}°"), (-eps, f"{eps:.0f}°")):
yy = y_of(d)
parts.append(_dashed(_DPX0, yy, _DPX1, yy, T["accent"], 0.8, 0.8))
parts.append(_text(_DPX0 - 8, yy, lab, size=8, fill=T["accent"]))
parts.append(_text((_DPX0 + _DPX1) / 2, 16,
"deklinacja — poza ±ε (zwrotniki) obiekt jest OOB", size=8, fill=T["muted"]))
# X równomiernie wg POSORTOWANEJ deklinacji — paralele lądują obok siebie
order = sorted(range(len(objs)), key=lambda i: float(objs[i]["declination"]))
n = len(order)
xy: dict = {}
for slot, idx in enumerate(order):
x = _DPX0 + (slot + 0.5) / n * (_DPX1 - _DPX0)
xy[objs[idx]["name"]] = (x, y_of(float(objs[idx]["declination"])))
# łączniki paralel/kontrparalel (zielony/czerwony — jak koniunkcja/opozycja)
for par in (chart.get("parallels") or []):
a = xy.get(par.get("obj1")); b = xy.get(par.get("obj2"))
if not a or not b:
continue
kind = par.get("type")
color = T["aspects"]["conjunction"] if kind == "parallel" else T["aspects"]["opposition"]
orb = float(par.get("orb", 0.0))
w, op = _aspect_pen(orb, par.get("allowed"))
tip = f'{par.get("obj1")} {_OOZ_PL.get(kind, kind)} {par.get("obj2")} · orb {orb:.2f}°'
parts.append(_line(a[0], a[1], b[0], b[1], color, w, op, title=tip))
# glify na wysokości ich deklinacji + podpis stopnia; OOB wyróżnione
for p in objs:
x, y = xy[p["name"]]
oob = bool(p.get("out_of_bounds"))
dec = float(p["declination"])
tip = f'{p.get("name")} · dekl. {dec:+.2f}°' + (" · OOB" if oob else "")
parts.append(_text(x, y, p.get("glyph") or "", size=15,
fill=T["retro"] if oob else T["ink"],
cls="glyph", font=glyph_font, title=tip))
parts.append(_text(x, y + 12, f"{dec:+.1f}", size=7,
fill=T["retro"] if oob else T["muted"]))
body = "".join(parts)
return (f'<svg class="declination" viewBox="0 0 {_DECL_W:.0f} {_DECL_H:.0f}" role="img" '
f'aria-label="Wykres deklinacji — paralele i out-of-bounds" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
_ANT_W, _ANT_H = 470.0, 150.0
_ANT_X0, _ANT_X1 = 22.0, 448.0
_ANT_Y = 100.0 # oś ekliptyki
def render_antiscia(chart: dict, theme: str = "screen") -> str:
"""Oś antyscji (LOG-07, etap 6). Ekliptyka rozwinięta w poziomą oś; pionowo
zaznaczona OŚ PRZESILEŃ (0° Raka / 0° Koziorożca) — względem niej odbija się
antyscja, a względem osi równonocy (0° Barana / Wagi) — kontrantyscja. Pary
połączone łukiem (zielony antyscja / czerwony kontrantyscja). '' przy < 2
obiektach."""
T = _THEMES.get(theme, _THEMES["screen"])
glyph_font = GLYPH_FONT if theme == "print" else ""
objs = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
if len(objs) < 2:
return ""
def x_of(lon: float) -> float:
return _ANT_X0 + (lon % 360.0) / 360.0 * (_ANT_X1 - _ANT_X0)
parts: list[str] = []
parts.append(_line(_ANT_X0, _ANT_Y, _ANT_X1, _ANT_Y, T["line"], 1.0, 0.9))
signs = chart.get("sign_glyphs") or []
for i in range(12):
xb = x_of(i * 30.0)
parts.append(_line(xb, _ANT_Y - 5, xb, _ANT_Y + 5, T["line"], 0.6, 0.6))
if i < len(signs):
parts.append(_text(x_of(i * 30.0 + 15.0), _ANT_Y + 17, signs[i].get("glyph") or "",
size=11, fill=T["muted"], cls="glyph", font=glyph_font))
# osie odbicia: przesileń (90/270) mocno, równonocy (0/180) delikatnie
for lon, col, op in ((90.0, T["accent"], 0.9), (270.0, T["accent"], 0.9),
(0.0, T["muted"], 0.5), (180.0, T["muted"], 0.5)):
xx = x_of(lon)
parts.append(_dashed(xx, _ANT_Y - 44, xx, _ANT_Y + 24, col, 0.9, op))
parts.append(_text((_ANT_X0 + _ANT_X1) / 2, 15,
"oś przesileń — antyscja · oś równonocy — kontrantyscja",
size=8, fill=T["muted"]))
lon_by = {p["name"]: float(p["decimal"]) for p in objs}
for row in (chart.get("antiscia") or []):
la = lon_by.get(row.get("obj1")); lb = lon_by.get(row.get("obj2"))
if la is None or lb is None:
continue
kind = row.get("type")
color = T["aspects"]["conjunction"] if kind == "antiscion" else T["aspects"]["opposition"]
xa, xb = x_of(la), x_of(lb)
orb = float(row.get("orb", 0.0))
tip = f'{row.get("obj1")} {_OOZ_PL.get(kind, kind)} {row.get("obj2")} · orb {orb:.2f}°'
midx = (xa + xb) / 2.0
top = _ANT_Y - 24 - abs(xb - xa) * 0.05
parts.append(f'<path d="M {xa:.1f} {_ANT_Y - 6:.1f} Q {midx:.1f} {top:.1f} '
f'{xb:.1f} {_ANT_Y - 6:.1f}" fill="none" stroke="{color}" '
f'stroke-width="1.2" opacity="0.9"><title>{escape(tip)}</title></path>')
for p in objs:
x = x_of(float(p["decimal"]))
parts.append(_text(x, _ANT_Y - 12, p.get("glyph") or "", size=12, fill=T["ink"],
cls="glyph", font=glyph_font,
title=f'{p.get("name")} · {p.get("in_sign", "")}'.strip(" ·")))
body = "".join(parts)
return (f'<svg class="antiscia" viewBox="0 0 {_ANT_W:.0f} {_ANT_H:.0f}" role="img" '
f'aria-label="Oś antyscji — odbicia względem przesileń i równonocy" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
@@ -5,44 +5,23 @@ opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
"""
from __future__ import annotations
import os
from typing import Any
import httpx
from app import link_crypto
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza prezentacja↔logika. Czytany przy każdym wywołaniu, bo
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
return link_crypto.Link(key) if key else None
class LogicClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/")
def _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
with httpx.Client(timeout=timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(),
link=_link())
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
return self._post("/api/query", payload, settings.http_timeout)
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.post(f"{self.base_url}/api/query", json=payload)
r.raise_for_status()
return r.json()
def positions(
self,
@@ -53,11 +32,6 @@ class LogicClient:
house_system: str = "whole_sign",
stations: bool = False,
zodiac: str = "tropical",
tables: bool = False,
house_systems: list[str] | None = None,
aspect_orb: float = 8.0,
aspect_luminary_bonus: float = 2.0,
aspect_minor: bool = False,
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
@@ -66,58 +40,44 @@ class LogicClient:
"lon": lon,
"objects": objects,
"house_system": house_system,
"house_systems": house_systems,
"aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus,
"aspect_minor": aspect_minor,
"stations": stations,
"zodiac": zodiac,
"tables": tables,
}
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
return self._post("/chart/positions", payload, timeout)
def synastry(
self, person_a: dict, person_b: dict, zodiac: str = "tropical",
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0, aspect_minor: bool = False,
) -> dict[str, Any]:
"""Synastria dwóch osób (PRE-04) — aspekty między horoskopami."""
payload = {
"person_a": person_a, "person_b": person_b, "zodiac": zodiac,
"aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus,
"aspect_minor": aspect_minor,
}
return self._post("/chart/synastry", payload, settings.http_timeout)
# stacje wymagają root-findów — dłuższy timeout
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
r = client.post(f"{self.base_url}/chart/positions", json=payload)
r.raise_for_status()
return r.json()
def report(
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
) -> dict[str, Any]:
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/chart/report", json=payload)
r.raise_for_status()
return r.json()
def prompt(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
provider: str | None = None, model: str | None = None,
) -> dict[str, Any]:
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
`provider` jest potrzebny dla budżetu „maksymalny kontekst modelu": limit
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt."""
payload: dict[str, Any] = {
"profile": profile, "when_utc": when_utc_iso,
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
"model": model,
"lat": lat, "lon": lon, "budget": budget,
}
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return self._post("/chart/prompt", payload, max(settings.http_timeout, 60.0))
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
r = client.post(f"{self.base_url}/chart/prompt", json=payload)
r.raise_for_status()
return r.json()
def horoscope(
self, profile: str, when_utc_iso: str, lat: float, lon: float,
budget: str = "medium", provider: str | None = None, model: str | None = None,
budget: str = "medium", provider: str | None = None,
from_date: str | None = None, to_date: str | None = None,
) -> dict[str, Any]:
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
@@ -130,39 +90,12 @@ class LogicClient:
}
if provider:
payload["provider"] = provider
if model:
payload["model"] = model
if from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
return self._post("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
def horoscope_stream(self, payload: dict[str, Any]):
"""Strumień postępu pisania horoskopu (NDJSON) — przekazywany do przeglądarki.
Timeout jest długi, bo generowanie trwa; strumień i tak niesie heartbeat,
więc cisza na łączu nie zostanie wzięta za zerwanie.
Idzie przez szyfrowane łącze jak reszta ruchu w dół (PRE-16). Bez tego przy
włączonym `LINK_ENCRYPTION_REQUIRED` serwer odrzuciłby nieszyfrowane żądanie
i okno postępu przestałoby działać. `stream_lines` pieczętuje żądanie i
odszyfrowuje odpowiedź ramka po ramce, zachowując dostarczanie na żywo.
"""
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
yield from link_crypto.stream_lines(
client, f"{self.base_url}/chart/horoscope/stream",
payload=payload, headers=_auth_headers(), link=_link())
def bases(self) -> dict[str, Any]:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
headers=_auth_headers(), link=_link())
def llm_models(self) -> dict[str, Any]:
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}/llm/models",
headers=_auth_headers(), link=_link())
with httpx.Client(timeout=max(settings.http_timeout, 300.0)) as client:
r = client.post(f"{self.base_url}/chart/horoscope", json=payload)
r.raise_for_status()
return r.json()
def timeline(
self, when_utc_iso: str, lat: float, lon: float,
@@ -173,4 +106,7 @@ class LogicClient:
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
"from_date": from_date, "to_date": to_date, "interpret": interpret,
}
return self._post("/chart/timeline", payload, max(settings.http_timeout, 60.0))
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
r = client.post(f"{self.base_url}/chart/timeline", json=payload)
r.raise_for_status()
return r.json()
@@ -1,52 +0,0 @@
"""Klient HTTP do usługi render (PRE-24).
Jedyny punkt styku prezentacji z usługą składającą PDF. Idzie po szyfrowanym
łączu, tak jak reszta ruchu wewnątrz klastra — z WŁASNYM, trzecim kluczem
(`LINK_KEY_PRESENTATION_RENDER`). Osobny klucz, bo przez to łącze leci CAŁY
raport: dane urodzeniowe i opisy z baz. Przejęcie go nie może otwierać łącza do
logiki ani do danych.
"""
from __future__ import annotations
import os
import httpx
from app import link_crypto
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza prezentacja↔render. Czytany przy każdym wywołaniu, bo
konfiguracja może się zmienić bez restartu procesu."""
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_RENDER)
return link_crypto.Link(key) if key else None
class RenderClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.render_url).rstrip("/")
def pdf(self, report: dict) -> bytes:
"""Raport → bajty PDF. Timeout długi: XeLaTeX na obszernym tekście
potrzebuje chwili, a raport bywa na kilkadziesiąt stron."""
with httpx.Client(timeout=max(settings.http_timeout, 180.0)) as client:
return link_crypto.call(client, "POST", f"{self.base_url}/pdf",
payload=report, headers=_auth_headers(),
link=_link())
def health(self) -> dict:
"""/health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
nie mają, a nie przechodzi tędy nic z raportu."""
import json
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
r.raise_for_status()
return json.loads(r.content)
-2
View File
@@ -11,8 +11,6 @@ from dataclasses import dataclass, field
@dataclass
class Settings:
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
# Usługa składająca PDF (PRE-24) — osobny komponent, bo dźwiga TeX Live.
render_url: str = field(default_factory=lambda: os.getenv("RENDER_URL", "http://localhost:8004"))
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
@@ -1,36 +0,0 @@
"""Katalog systemów domów dla warstwy prezentacji — JEDNO źródło nazw.
Nazwy były zaszyte w czterech szablonach naraz (selektor na „Horoskop", selektor
na „Skompiluj", checkboxy porównania, nagłówki tabeli). Przy trzech systemach
uchodziło to na sucho; przy dziesięciu rozjazd jest kwestią czasu — wystarczy
dopisać system w jednym miejscu i zapomnieć o trzech pozostałych.
Kolejność jest celowa: najpierw dwa najpowszechniejsze (Placidus i Whole Sign),
potem reszta. `LIMITED` oznacza systemy, które powyżej koła podbiegunowego nie
istnieją i podlegają jawnemu fallbackowi — UI ma o tym uprzedzać ZANIM ktoś
policzy horoskop dla Tromsø, a nie dopiero w ostrzeżeniu przy wyniku.
"""
from __future__ import annotations
HOUSE_SYSTEMS: list[tuple[str, str]] = [
("placidus", "Placidus"),
("whole_sign", "Whole Sign"),
("koch", "Koch"),
("equal", "Equal"),
("porphyry", "Porphyry"),
("regiomontanus", "Regiomontanus"),
("campanus", "Campanus"),
("alcabitus", "Alcabitus"),
("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)
-525
View File
@@ -1,525 +0,0 @@
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
zrzut z węzła), miał je w całości.
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
podatne na modyfikację".
**Dwa niezależne klucze**, po jednym na parę rozmówców:
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
losowej jednorazówki w jedną stronę nie osłabia drugiej.
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import struct
import time
from typing import Iterable, Iterator
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
log = logging.getLogger("astrololo.link")
MAGIC = b"AL1"
VERSION = "v1"
NONCE_BYTES = 12
KEY_BYTES = 32 # AES-256
LENGTH_PREFIX = 4
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
MAX_SKEW_SECONDS = 300.0
HEADER_ENC = "X-Astrololo-Enc"
HEADER_TS = "X-Astrololo-Enc-Ts"
CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
# jej klucza nie może otwierać łącza do logiki ani do danych.
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res"
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
PUBLIC_PATHS = frozenset({"/health"})
class LinkError(Exception):
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
# --------------------------------------------------------------------- klucze
def parse_key(raw: str) -> bytes:
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
text = raw.strip()
if not text:
raise LinkError("pusty klucz łącza")
try:
key = bytes.fromhex(text)
except ValueError:
try:
key = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError) as exc:
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
if len(key) != KEY_BYTES:
raise LinkError(
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
f"`openssl rand -hex 32`"
)
return key
def key_from_env(env_name: str) -> bytes | None:
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
raw = os.getenv(env_name, "")
return parse_key(raw) if raw.strip() else None
def encryption_required() -> bool:
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
"""
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
def _subkey(link_key: bytes, direction: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
info=b"astrololo/link/" + direction,
).derive(link_key)
class Link:
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
def __init__(self, link_key: bytes) -> None:
self._by_direction = {
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
}
# ---------------------------------------------------------- pojedyncza ramka
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
return b"|".join([MAGIC, direction, path.encode("utf-8"),
stamp.encode("ascii"), str(seq).encode("ascii")])
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
plaintext: bytes) -> bytes:
nonce = os.urandom(NONCE_BYTES)
sealed = self._by_direction[direction].encrypt(
nonce, plaintext, self._aad(direction, path, stamp, seq))
return MAGIC + nonce + sealed
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika astrololo")
body = frame[len(MAGIC):]
if len(body) <= NONCE_BYTES:
raise LinkError("ramka za krótka")
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
try:
return self._by_direction[direction].decrypt(
nonce, sealed, self._aad(direction, path, stamp, seq))
except InvalidTag as exc:
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
# ------------------------------------------------------------ strumień ramek
def seal_stream(self, direction: bytes, path: str, stamp: str,
chunks: Iterable[bytes]) -> Iterator[bytes]:
for seq, chunk in enumerate(chunks):
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
def open_stream(self, direction: bytes, path: str, stamp: str,
raw: bytes) -> Iterator[bytes]:
for seq, frame in enumerate(frames_in(raw)):
yield self.open(direction, path, stamp, seq, frame)
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
return b"".join(self.open_stream(direction, path, stamp, raw))
# ---------------------------------------------------------------- ramkowanie
def frame_out(payload: bytes) -> bytes:
return struct.pack(">I", len(payload)) + payload
def frames_in(raw: bytes) -> Iterator[bytes]:
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
offset = 0
while offset < len(raw):
if offset + LENGTH_PREFIX > len(raw):
raise LinkError("urwana ramka (brak nagłówka długości)")
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
offset += LENGTH_PREFIX
if offset + size > len(raw):
raise LinkError("urwana ramka (za mało danych)")
yield raw[offset:offset + size]
offset += size
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
rozjechać się między dwa odczyty.
"""
while True:
if len(buffer) < LENGTH_PREFIX:
return
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
if size > MAX_FRAME:
raise LinkError("ramka ponad dopuszczalny rozmiar")
if len(buffer) < LENGTH_PREFIX + size:
return
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
del buffer[:LENGTH_PREFIX + size]
yield frame
# ------------------------------------------------------------- świeżość ruchu
def stamp_now() -> str:
return f"{time.time():.3f}"
def check_stamp(stamp: str) -> None:
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
odtworzyć w dowolnym momencie w przyszłości."""
try:
sent = float(stamp)
except (TypeError, ValueError) as exc:
raise LinkError("brak albo błędny znacznik czasu") from exc
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
# =========================================================== strona serwerowa
class LinkCryptoMiddleware:
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
"""
def __init__(self, app, link: Link | None, layer: str) -> None:
self.app = app
self.link = link
self.layer = layer
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
return await self.app(scope, receive, send)
path = scope["path"]
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
if headers.get(HEADER_ENC.lower()) != VERSION:
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
# chcemy po cichu wrócić do jawnego ruchu.
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
self.layer, path)
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
stamp = headers.get(HEADER_TS.lower(), "")
try:
check_stamp(stamp)
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
except LinkError as exc:
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
scope = dict(scope)
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
def _sealing_send(self, send, path: str):
state: dict = {"stamp": "", "seq": 0}
async def sealing(message):
if message["type"] == "http.response.start":
state["stamp"] = stamp_now()
keep = [(k, v) for k, v in message.get("headers", [])
if k.lower() not in (b"content-length", b"content-type")]
message = dict(message)
message["headers"] = keep + [
(b"content-type", CONTENT_TYPE.encode()),
(HEADER_ENC.lower().encode(), VERSION.encode()),
(HEADER_TS.lower().encode(), state["stamp"].encode()),
]
return await send(message)
if message["type"] == "http.response.body":
chunk = message.get("body", b"")
sealed = b""
if chunk:
sealed = frame_out(self.link.seal(
RESPONSE, path, state["stamp"], state["seq"], chunk))
state["seq"] += 1
return await send({"type": "http.response.body", "body": sealed,
"more_body": message.get("more_body", False)})
return await send(message)
return sealing
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
kept.append((b"content-length", str(length).encode()))
if length:
kept.append((b"content-type", b"application/json"))
return kept
async def _read_body(receive) -> bytes:
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.disconnect":
raise LinkError("rozłączenie w trakcie odbioru żądania")
body += message.get("body", b"")
if not message.get("more_body", False):
return bytes(body)
def _replay(body: bytes, original):
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
"""
delivered = False
async def receive():
nonlocal delivered
if delivered:
return await original()
delivered = True
return {"type": "http.request", "body": body, "more_body": False}
return receive
async def _refuse(send, detail: str) -> None:
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
await send({"type": "http.response.start", "status": 400, "headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode()),
]})
await send({"type": "http.response.body", "body": payload})
def install(app, env_name: str, layer: str):
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
link_key = key_from_env(env_name)
if link_key is None and encryption_required():
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
f"warstwa {layer} nie wystartuje bez klucza łącza"
)
if link_key is None:
log.warning(
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
"ruch wewnątrz sieci.", env_name, layer,
)
return None
link = Link(link_key)
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
return link
# ============================================================ strona kliencka
def call(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
się niejasnym błędem odszyfrowania.
"""
import json as _json
import httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
# sygnifikatory, o które pytamy bazę.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
)
response = client.request(method, url, json=payload, headers=request_headers)
response.raise_for_status()
return response.content
path = httpx.URL(url).path
stamp = stamp_now()
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
"Content-Type": CONTENT_TYPE})
response = client.request(method, url, content=body, headers=request_headers)
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
log.error("łącze %s odmówiło: %s", path, response.text[:200])
response.raise_for_status()
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
reply_stamp = response.headers.get(HEADER_TS, "")
check_stamp(reply_stamp)
return link.open_all(RESPONSE, path, reply_stamp, response.content)
def call_json(client, method: str, url: str, *, payload=None,
headers: dict[str, str] | None = None, link: Link | None):
import json as _json
return _json.loads(call(client, method, url, payload=payload,
headers=headers, link=link))
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
w buforze zamiast zakładać, że każdy kawałek to komplet.
"""
if link is None:
yield from response.iter_bytes()
return
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
stamp = response.headers.get(HEADER_TS, "")
check_stamp(stamp)
path = response.request.url.path
buffer = bytearray()
seq = 0
for chunk in response.iter_bytes():
buffer += chunk
for frame in unframe_incremental(buffer):
yield link.open(RESPONSE, path, stamp, seq, frame)
seq += 1
if buffer:
raise LinkError("strumień urwał się w połowie ramki")
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
link: Link | None) -> Iterator[str]:
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
dopiero na znakach nowej linii.
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
działał bez zmian.
"""
import json as _json
import httpx as _httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
)
with client.stream("POST", url, json=payload, headers=request_headers) as response:
response.raise_for_status()
for text_line in response.iter_lines():
if text_line:
yield text_line
return
path = _httpx.URL(url).path
stamp = stamp_now()
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
with client.stream("POST", url, content=body, headers=request_headers) as response:
response.raise_for_status()
buffer = bytearray()
for plain in open_response_stream(response, link):
buffer += plain
while True:
nl = buffer.find(b"\n")
if nl < 0:
break
text_line = bytes(buffer[:nl])
del buffer[:nl + 1]
if text_line:
yield text_line.decode("utf-8")
if buffer:
yield bytes(buffer).decode("utf-8")
+17 -389
View File
@@ -7,24 +7,17 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
/significators".
"""
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from pathlib import Path
import httpx
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
from app import geocode, security
from app import geocode
from app.clients.logic_client import LogicClient
from app.config import DEFAULT_LOCATION_LABEL, default_form
@@ -32,34 +25,6 @@ app = FastAPI(title="astrololo · warstwa prezentacji")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
# ── cache-busting statyki (PRE-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
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
@@ -73,27 +38,6 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
return utc.isoformat(), label
def _llm_catalog() -> dict:
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
try:
return logic.llm_models()
except httpx.HTTPError:
return {"providers": {}, "defaults": {}}
def _report_records(report: dict | None) -> int:
"""Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17).
Liczymy próbki we wszystkich fasetach: to one 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 (
@@ -108,54 +52,33 @@ def _logic_error(e: Exception) -> str:
def chart_form(request: Request):
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/", response_class=HTMLResponse)
def chart_compute(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
aspect_orb: float = Form(8.0), # PRE-06: konfigurowalny orb aspektów
aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
stations: bool = Form(False),
zodiac: str = Form("tropical"),
tables: bool = Form(False),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system,
"house_systems": house_systems, "aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables}
form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
"zodiac": zodiac}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=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,
house_system=house_system, stations=stations, zodiac=zodiac,
)
# 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 # kosmogram (PRE-12), SVG po stronie serwera
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
@@ -163,204 +86,6 @@ def chart_compute(
return templates.TemplateResponse(request, "chart.html", ctx)
# ---------------- Skompiluj: zbiorczy raport (PRE-23) ----------------
@app.get("/compile", response_class=HTMLResponse)
def compile_form(request: Request):
return templates.TemplateResponse(
request, "compile.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/compile", response_class=HTMLResponse)
def compile_build(
request: Request,
person: str = Form(""),
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu
aspect_orb: float = Form(8.0), # PRE-06 — te same ustawienia aspektów
aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
zodiac: str = Form("tropical"),
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
):
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
Horoskop liczymy ponownie (czysta funkcja wejścia tanio powtórzyć, bez
trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które
wybrano przy horoskopie (stacje, tabele, porównanie domów) inaczej
podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między
zakładkami przez formsync.
"""
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system,
"house_systems": house_systems, "aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, 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["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "compile.html", ctx)
@app.post("/compile/pdf")
def compile_pdf(payload: dict):
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
Wejście z przeglądarki, bo części od AI (interpretacja natalna i predykcje)
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
zawierał dokładnie to, co widać na stronie.
Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVGPDF nie zna
naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
"""
from fastapi.responses import Response
from app.clients.render_client import RenderClient
data = payload.get("data") or {}
try:
iso_utc, label = _build_utc(
str(data.get("date") or ""), str(data.get("time") or ""),
float(data.get("tz_offset") or 0.0),
)
except (ValueError, TypeError) as e:
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
figures: list[dict] = []
warnings: list[str] = []
try:
chart = logic.positions(
when_utc_iso=iso_utc,
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
house_system=str(data.get("house_system") or "whole_sign"),
house_systems=[s for s in (data.get("house_systems") or []) if s],
aspect_orb=float(data.get("aspect_orb") or 8.0),
aspect_luminary_bonus=float(data.get("aspect_luminary_bonus") or 2.0),
aspect_minor=bool(data.get("aspect_minor")),
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
zodiac=str(data.get("zodiac") or "tropical"),
)
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_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
):
if svg:
figures.append({"svg": svg, "caption": caption})
except httpx.HTTPError as e:
# Brak rysunków nie może zablokować raportu — tekst jest ważniejszy.
data = {**data, "wheel_error": _logic_error(e)}
report = {
"person": payload.get("person") or "",
"data": {**data, "moment_utc": label},
"figures": figures,
"warnings": warnings,
"natal": payload.get("natal") or {},
"predictions": payload.get("predictions") or [],
}
try:
pdf = RenderClient().pdf(report)
except httpx.HTTPError as e:
return JSONResponse(
{"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
status_code=502,
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
# ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ----------------
@app.get("/settings", response_class=HTMLResponse)
def settings_view(request: Request):
"""Podgląd baz dostępnych na udziale + które biorą udział w interpretacji.
Same metadane (nazwa, rozmiar, data) bez treści baz. Przełączniki
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):
@@ -379,8 +104,6 @@ def significators_search(
ctx: dict = {"form": form, "result": None, "error": None}
try:
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
# ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści
security.audit_records(request, len((ctx["result"] or {}).get("rows") or []))
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "significators.html", ctx)
@@ -391,15 +114,13 @@ def significators_search(
def interpret_form(request: Request):
return templates.TemplateResponse(
request, "interpret.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/interpret", response_class=HTMLResponse)
def interpret_run(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
@@ -409,44 +130,25 @@ def interpret_run(
action: str = Form("report"),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
"llm_provider": llm_provider, "llm_model": llm_model}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
"llm_catalog": _llm_catalog()}
"llm_provider": llm_provider}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
if action == "export":
# Tabela robocza do Excela (DAN-23/PRE-10) — pobranie pliku, nie strona.
from fastapi.responses import Response
from app.report_export import report_to_xlsx
report = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
# Eksport wynosi NAJWIĘCEJ treści baz naraz — tym bardziej ma zostawiać
# ślad w dzienniku (PRE-17): sama liczba rekordów, nigdy treść.
security.audit_records(request, _report_records(report))
return Response(
content=report_to_xlsx(report),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
)
if action == "prompt":
ctx["prompt_result"] = logic.prompt(
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
provider=llm_provider, model=llm_model,
)
elif action == "horoscope":
ctx["prompt_result"] = logic.horoscope(
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, provider=llm_provider, model=llm_model,
budget=prompt_budget, provider=llm_provider,
)
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:
@@ -459,15 +161,13 @@ def interpret_run(
def timeline_form(request: Request):
return templates.TemplateResponse(
request, "timeline.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
"llm_catalog": _llm_catalog()},
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/timeline", response_class=HTMLResponse)
def timeline_run(
request: Request,
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
@@ -478,13 +178,11 @@ def timeline_run(
action: str = Form("timeline"),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
"llm_provider": llm_provider, "llm_model": llm_model}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
"llm_catalog": _llm_catalog()}
"llm_provider": llm_provider}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
@@ -492,12 +190,11 @@ def timeline_run(
ctx["prompt_result"] = logic.prompt(
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, from_date=from_date, to_date=to_date,
provider=llm_provider, model=llm_model,
)
elif action == "horoscope":
ctx["prompt_result"] = logic.horoscope(
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
budget=prompt_budget, provider=llm_provider, model=llm_model,
budget=prompt_budget, provider=llm_provider,
from_date=from_date, to_date=to_date,
)
else:
@@ -512,61 +209,6 @@ def timeline_run(
return templates.TemplateResponse(request, "timeline.html", ctx)
# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
@app.post("/horoscope/stream")
def horoscope_stream(
profile: str = Form("natal"),
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
prompt_budget: str = Form("medium"),
llm_provider: str = Form("local"),
llm_model: str = Form(""),
from_date: str = Form(""),
to_date: str = Form(""),
):
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby
przeładowanie strony jedno źródło prawdy dla wyglądu wyniku.
"""
from fastapi.responses import StreamingResponse
try:
iso_utc, _ = _build_utc(date, time, tz_offset)
except ValueError as e:
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
payload: dict = {
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
}
if profile == "period" and from_date and to_date:
payload["from_date"], payload["to_date"] = from_date, to_date
def relay():
try:
for raw in logic.horoscope_stream(payload):
try:
event = json.loads(raw)
except ValueError:
continue
if event.get("type") == "result":
html = templates.get_template("_prompt_result.html").render(
prompt_result=event.get("result") or {}
)
event["html"] = html
yield json.dumps(event, ensure_ascii=False) + "\n"
except httpx.HTTPError as e:
yield json.dumps({"type": "error", "message": _logic_error(e)},
ensure_ascii=False) + "\n"
return StreamingResponse(relay(), media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
@app.get("/geocode")
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
@@ -586,20 +228,6 @@ def geocode_reverse(lat: float, lon: float):
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/timezone")
def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"):
"""Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03).
Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404,
gdy strefy nie da się ustalić front zostawia wtedy ręczny offset."""
from app import timezone as tzmod
res = tzmod.resolve(lat, lon, date, time)
if not res:
raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.")
return res
@app.get("/health")
def health() -> dict:
return {"status": "ok", "layer": "presentation"}
@@ -1,56 +0,0 @@
"""Eksport raportu interpretacji do Excela — „tabela robocza" (DAN-23 / PRE-10).
Raport z logiki jest zagnieżdżony: obiekt fasety próbki (dopasowania z bazy).
Astrolog chce z tym PRACOWAĆ w Excelu: filtrować, sortować, zaznaczać, usuwać
więc spłaszczamy go do JEDNEJ płaskiej tabeli (wiersz = jedno dopasowanie) z
auto-filtrem i zamrożonym nagłówkiem. Bez wykresów i ozdób to materiał roboczy.
Świadomie budujemy plik TU, w prezentacji (jak PDF idzie przez usługę render):
warstwa danych/logiki liczy, prezentacja formatuje wyjście.
"""
from __future__ import annotations
import io
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font
HEADERS = ["Obiekt", "Faseta", "Typ", "Token", "Sygnifikator", "Rozwinięcie", "Opis / efekt"]
_WIDTHS = [16, 24, 10, 20, 22, 28, 72] # „Opis" najszerszy — tam jest treść
def _rows(report: dict):
"""Spłaszcza obiekt→fasety→próbki na wiersze płaskiej tabeli."""
for o in report.get("objects") or []:
name = o.get("name") or ""
for f in o.get("facets") or []:
label, typ, token = f.get("label") or "", f.get("type") or "", f.get("token") or ""
for s in (f.get("samples") or []):
yield [name, label, typ, token,
s.get("significator") or "", s.get("expanded") or "", s.get("effect") or ""]
def report_to_xlsx(report: dict) -> bytes:
"""Raport (dict z /chart/report) → bajty pliku .xlsx z jedną tabelą roboczą."""
wb = Workbook()
ws = wb.active
ws.title = "Interpretacje"
ws.append(HEADERS)
for c in ws[1]:
c.font = Font(bold=True)
for row in _rows(report):
ws.append(row)
# Auto-filtr + zamrożony nagłówek = filtrowanie/sortowanie od razu w Excelu (PRE-10).
ws.auto_filter.ref = f"A1:{chr(64 + len(HEADERS))}{ws.max_row}"
ws.freeze_panes = "A2"
for i, w in enumerate(_WIDTHS, start=1):
ws.column_dimensions[chr(64 + i)].width = w
for row in ws.iter_rows(min_row=2): # opis bywa długi — zawijamy
row[6].alignment = Alignment(wrap_text=True, vertical="top")
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
-279
View File
@@ -1,279 +0,0 @@
"""Ochrona dostępu do aplikacji (LOG-32).
Rdzeniem produktu oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
uwierzytelnienia oznacza, że każdy w sieci może je wypompować bez udziału
jakiegokolwiek modelu językowego. Ten moduł zamyka drogę.
Trzy mechanizmy:
* **HTTP Basic** wejście do aplikacji; włącza się, gdy jakiekolwiek konta.
* **limit żądań** hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań);
rozliczany per adres klienta, a za odwrotnym proxy po TRUST_PROXY=true
per adres z nagłówka, nie per adres proxy (patrz `client_ip`).
* **konta imienne + dziennik audytowy** (PRE-17) patrz niżej.
KONTA IMIENNE (PRE-17). Jedno wspólne hasło nie mówi, KTO sięgał do baz, a
odebranie dostępu jednej osobie wymagało zmiany hasła wszystkim. `APP_USERS`
trzyma listę kont `login:sekret` sekret najlepiej jako HASH scrypt (stdlib,
bez nowych zależności): `scrypt$<sól_hex>$<hash_hex>`, patrz scripts/make_user.py.
Odebranie dostępu = usunięcie wpisu. Gdy `APP_USERS` jest ustawione, wspólne
APP_PASSWORD PRZESTAJE działać inaczej stare hasło cicho zostawiałoby tylne
wejście, czyli dokładnie problem, który to wymaganie zamyka.
DZIENNIK AUDYTOWY (PRE-17). Każde żądanie do chronionej ścieżki zostawia wpis:
kto, kiedy, jaka ścieżka, status, ile rekordów (gdy handler to poda przez
`audit_records`). To jedyny sposób, by zauważyć POWOLNE wypompowywanie bazy przez
osobę uprawnioną pojedyncze zapytanie wygląda niewinnie, suma już nie.
Świadomie NIE logujemy treści żądań, promptów ani rekordów logi to kolejny
nośnik wycieku; liczby i metadane wystarczą do wykrycia nadużycia.
UWAGA: bez kont ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev). Wtedy przy
starcie leci głośne ostrzeżenie żeby nikt nie wdrożył tego w przekonaniu, że
jest chroniony.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import logging
import os
import secrets
import time
from collections import deque
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse
log = logging.getLogger("astrololo.security")
MAX_TRACKED_CLIENTS_DEFAULT = 4096
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
# konfiguracja czytana leniwie — testy i restart mogą ją zmienić bez importu modułu
def app_user() -> str:
return os.getenv("APP_USER", "astrololo")
def app_password() -> str:
return os.getenv("APP_PASSWORD", "")
# ── konta imienne (PRE-17) ───────────────────────────────────────────────
# Parametry scrypt: rozsądny kompromis czas/pamięć dla logowania do aplikacji
# wewnętrznej. Zmiana wymaga przeliczenia haseł (hash niesie tylko sól).
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
_HASH_PREFIX = "scrypt$"
def hash_password(password: str, salt: bytes | None = None) -> str:
"""Hasło → wpis do APP_USERS: `scrypt$<sól_hex>$<hash_hex>`."""
salt = salt or secrets.token_bytes(16)
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
return f"{_HASH_PREFIX}{salt.hex()}${dk.hex()}"
def accounts() -> dict[str, str]:
"""Konta z `APP_USERS` (`login:sekret`, po przecinku). Gdy pusto — zgodność
wstecz: pojedyncze konto z APP_USER/APP_PASSWORD."""
out: dict[str, str] = {}
for entry in os.getenv("APP_USERS", "").split(","):
entry = entry.strip()
if not entry:
continue
user, sep, secret = entry.partition(":")
if sep and user.strip() and secret.strip():
out[user.strip()] = secret.strip()
if out:
return out
return {app_user(): app_password()} if app_password() else {}
def _secret_matches(spec: str, password: str) -> bool:
"""Sekret konta vs podane hasło. Hash scrypt albo (zgodność wstecz) jawne."""
if spec.startswith(_HASH_PREFIX):
try:
_, salt_hex, hash_hex = spec.split("$", 2)
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
except ValueError:
return False
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
return hmac.compare_digest(actual, expected)
# Porównujemy BAJTY, nie tekst: `compare_digest` na stringach rzuca TypeError
# przy znakach spoza ASCII, więc hasło z polskimi literami wywracało logowanie
# błędem 500 zamiast zwykłej odmowy (błąd zastany, złapany testem).
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
def rate_limit_per_min() -> int:
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
def trust_proxy() -> bool:
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
PUBLIC_PATHS = frozenset({"/health"})
PUBLIC_PREFIXES = ("/static/",)
_hits: dict[str, deque[float]] = {}
def auth_enabled() -> bool:
return bool(accounts())
def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
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 None
try:
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
user, _, password = raw.partition(":")
except (binascii.Error, UnicodeDecodeError, IndexError):
return None
spec = accounts().get(user)
if spec is None:
return None
return user if _secret_matches(spec, password) else None
def client_ip(request: Request) -> str:
"""Adres, po którym rozliczamy limit żądań.
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS PRE-16)
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
wyklika limit, odcięłaby pozostałe.
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY bo inaczej wystarczyłoby
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
klient, więc nie znaczą nic.
"""
peer = request.client.host if request.client else "?"
if not trust_proxy():
return peer
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
last = forwarded.rsplit(",", 1)[-1].strip()
if last:
return last
return request.headers.get("x-real-ip", "").strip() or peer
def _rate_limited(client: str) -> bool:
cap = rate_limit_per_min()
if cap <= 0:
return False
now = time.monotonic()
window = _hits.get(client)
if window is None:
if len(_hits) >= MAX_TRACKED_CLIENTS:
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
window = _hits[client] = deque()
while window and now - window[0] > 60.0:
window.popleft()
if len(window) >= cap:
return True
window.append(now)
return False
# ── dziennik audytowy (PRE-17) ───────────────────────────────────────────
audit_log = logging.getLogger("astrololo.audit")
def _setup_audit_logging() -> None:
"""Gwarantuje, że wpisy audytowe FAKTYCZNIE wychodzą na stdout.
Domyślna konfiguracja uvicorna nie obsługuje naszych loggerów, więc wpisy na
poziomie INFO ginęły dziennik istniał w kodzie, ale był pusty (sprawdzone
na żywym serwerze). Niewidoczny dziennik jest gorszy niż jego brak: daje
złudzenie kontroli. Własny handler na stdout, bo w k8s to stamtąd zbierane
logi. `propagate=False` żeby wpis nie dublował się przez korzeń.
"""
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
if not audit_log.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
audit_log.addHandler(handler)
audit_log.propagate = False
def audit_records(request: Request, count: int) -> None:
"""Handler zgłasza, ILE rekordów baz oddał — trafi do wpisu audytowego.
Sama liczba, nigdy treść. Bez tego dziennik pokazywałby tylko ktoś wchodził",
a nie ktoś wyniósł 5000 rekordów" — a to drugie jest sygnałem nadużycia."""
try:
request.state.audit_records = int(count)
except (TypeError, ValueError):
pass
def _audit(request: Request, user: str, status: int, ms: float) -> None:
records = getattr(request.state, "audit_records", None)
audit_log.info(
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
user or "-", client_ip(request), request.method, request.url.path,
status, "-" if records is None else records, ms,
)
def install(app) -> None:
"""Podpina ochronę pod wszystkie ścieżki poza /health i /static."""
_setup_audit_logging()
if not auth_enabled():
log.warning(
"UWAGA: brak kont (APP_USERS / APP_PASSWORD) — aplikacja jest OTWARTA "
"dla każdego, kto ma do niej dostęp sieciowy, wraz z treścią baz "
"interpretacyjnych."
)
elif os.getenv("APP_USERS", "").strip() and app_password():
# Gdyby wspólne hasło działało obok kont imiennych, zostawiałoby tylne
# wejście bez śladu w dzienniku — czyli problem, który PRE-17 zamyka.
log.warning(
"APP_USERS jest ustawione, więc wspólne APP_PASSWORD NIE działa "
"(konta imienne mają pierwszeństwo). Usuń APP_PASSWORD z konfiguracji."
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if _is_public(request.url.path):
return await call_next(request)
started = time.monotonic()
if _rate_limited(client_ip(request)):
_audit(request, "-", 429, (time.monotonic() - started) * 1000)
return JSONResponse(
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
status_code=429, headers={"Retry-After": "60"},
)
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
-174
View File
@@ -1,174 +0,0 @@
// Zakładka „Skompiluj" — składanie raportu (PRE-23).
//
// Raport ma trzy kawałki liczone w różnych miejscach:
// 1. horoskop — liczy serwer przy wysłaniu tego formularza,
// 2. interpretacja natalna — z zakładki Interpretacje (natal.js),
// 3. predykcje okresowe — z zakładki Kalendarz (predictions.js).
//
// Punkty 2 i 3 mieszkają w magazynie przeglądarki, więc to tutaj je dokładamy.
// Odczytujemy je przez API tamtych modułów, a nie sięgając wprost do
// localStorage — dzięki temu format danych ma JEDNEGO właściciela.
//
// Pokazujemy też, CZEGO BRAKUJE. Bez tego użytkownik złożyłby niekompletny raport
// i dowiedział się o tym dopiero po otwarciu PDF-a.
(function () {
'use strict';
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function readNatal() {
return (window.astrololoNatal && window.astrololoNatal.read()) || null;
}
function readPredictions() {
return (window.astrololoPredictions && window.astrololoPredictions.read()) || [];
}
function item(ok, label, hint) {
return '<li class="' + (ok ? 'rdy-ok' : 'rdy-missing') + '">' +
(ok ? '✓ ' : '✗ ') + esc(label) +
(ok ? '' : ' <span class="muted small">— ' + esc(hint) + '</span>') + '</li>';
}
function renderReadiness(hasChart) {
var host = document.getElementById('readiness');
if (!host) return;
var natal = readNatal();
var preds = readPredictions();
var parts = [
item(hasChart, 'Policzony horoskop', 'wypełnij dane i kliknij „Złóż podsumowanie"'),
item(!!natal, 'Interpretacja natalna',
'wygeneruj ją na zakładce Interpretacje — zapamięta się sama'),
item(preds.length > 0,
'Predykcje okresowe' + (preds.length ? ' (' + preds.length + ')' : ''),
'policz horoskop na wybrany okres w Kalendarzu')
];
host.innerHTML = '<div class="meta">Materiał do raportu</div><ul class="readiness">' +
parts.join('') + '</ul>';
}
function renderNatal() {
var host = document.getElementById('reportNatal');
if (!host) return;
var natal = readNatal();
if (!natal) {
host.innerHTML = '<div class="meta">Interpretacja natalna</div>' +
'<p class="muted small">Brak — wygeneruj ją na zakładce Interpretacje.</p>';
return;
}
host.innerHTML = '<div class="meta">Interpretacja natalna ' +
'<span class="muted small">· zapamiętana ' + esc(String(natal.saved_at).slice(0, 10)) +
'</span></div><div class="report-text">' + esc(natal.text) + '</div>';
}
function renderPredictions() {
var host = document.getElementById('reportPredictions');
if (!host) return;
var preds = readPredictions();
if (!preds.length) {
host.innerHTML = '<div class="meta">Predykcje okresowe</div>' +
'<p class="muted small">Brak — policz horoskop na wybrany okres w Kalendarzu.</p>';
return;
}
var blocks = preds.map(function (p) {
return '<div class="meta">' + esc(p.from_date) + ' ' + esc(p.to_date) + '</div>' +
'<div class="report-text">' + esc(p.text) + '</div>';
}).join('');
host.innerHTML = '<div class="meta">Predykcje okresowe (' + preds.length + ')</div>' + blocks;
}
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
// ── PDF (PRE-24) ────────────────────────────────────────────────────
// Części od AI mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
// do serwera, który dokłada kosmogram i woła usługę render.
function formData() {
function v(name) {
var el = document.querySelector('[name=' + name + ']');
return el ? el.value : '';
}
function checked(name) {
var el = document.querySelector('[name=' + name + ']');
return !!(el && el.checked);
}
function multi(name) {
var out = [];
document.querySelectorAll('[name=' + name + ']:checked').forEach(function (el) { out.push(el.value); });
return out;
}
// Opcje (stacje, tabele, porównanie domów) MUSZĄ jechać do PDF-a — inaczej
// PDF liczyłby okrojony horoskop i miałby braki względem podsumowania.
return {
date: v('date'), time: v('time'), tz_offset: v('tz_offset'),
lat: v('lat'), lon: v('lon'),
house_system: v('house_system'), zodiac: v('zodiac'),
house_systems: multi('house_systems'),
aspect_orb: v('aspect_orb'), aspect_luminary_bonus: v('aspect_luminary_bonus'),
aspect_minor: checked('aspect_minor'),
stations: checked('stations'), tables: checked('tables'),
place: (document.querySelector('#geoSearch') || {}).value || ''
};
}
function download(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'raport.pdf';
document.body.appendChild(a);
a.click();
a.remove();
// Zwalniamy obiekt dopiero po kliknięciu — wcześniej przeglądarka nie zdąży
// rozpocząć pobierania.
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
}
function buildPdf(btn, note) {
var person = (document.querySelector('[name=person]') || {}).value || '';
var payload = {
person: person,
data: formData(),
natal: readNatal() || {},
predictions: readPredictions()
};
btn.disabled = true;
note.textContent = 'Składam PDF — LaTeX potrzebuje chwili przy obszernym tekście…';
fetch('/compile/pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) {
if (!r.ok) {
return r.json().catch(function () { return {}; }).then(function (d) {
throw new Error(d.detail || ('HTTP ' + r.status));
});
}
return r.blob();
}).then(function (blob) {
download(blob);
note.textContent = 'Gotowe — PDF pobrany.';
}).catch(function (e) {
note.textContent = 'Nie udało się złożyć PDF-a: ' + e.message;
}).finally(function () {
btn.disabled = false;
});
}
ready(function () {
if (!document.getElementById('readiness')) return; // nie ta zakładka
// Horoskop jest w dokumencie tylko po wysłaniu formularza.
renderReadiness(!!document.getElementById('reportPerson'));
renderNatal();
renderPredictions();
var btn = document.getElementById('pdfBtn');
var note = document.getElementById('pdfNote');
if (btn && note) btn.addEventListener('click', function () { buildPdf(btn, note); });
});
})();
@@ -1,128 +0,0 @@
// Wspólne dane formularza między zakładkami (PRE-21).
//
// Problem: każdą zakładkę (Horoskop / Interpretacje / Kalendarz) wypełniało się
// od nowa — te same imię, data, godzina, strefa i miejsce. Teraz raz wpisane
// dane wędrują za użytkownikiem.
//
// Dlaczego localStorage, a nie sesja na serwerze: serwer zostaje BEZSTANOWY
// (żadnego magazynu sesji, żadnych danych urodzeniowych trzymanych po stronie
// usługi), a przy okazji dane synchronizują się też między osobnymi kartami
// przeglądarki — zdarzenie `storage` daje to za darmo.
//
// UWAGA na kolejność: ten skrypt ładujemy w <head> z `defer`, więc wykonuje się
// PO sparsowaniu dokumentu, ale PRZED geo.js (skrypty defer idą w kolejności
// dokumentu). Dzięki temu mapa startuje już z odtworzonymi współrzędnymi i nie
// trzeba jej potem przestawiać.
(function () {
'use strict';
var KEY = 'astrololo.form.v1';
// Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć.
// `geoSearch` (nazwa miejsca) nie ma atrybutu name — szukamy po id.
// type: brak = zwykła wartość (.value); 'check' = pojedynczy checkbox (.checked);
// 'multi' = kilka checkboxów o tej samej nazwie (lista zaznaczonych wartości).
// Opcje (stacje, tabele, porównanie domów) są tu, żeby wybór z „Horoskopu"
// wędrował na „Skompiluj" — inaczej podsumowanie liczyłoby się bez nich.
var FIELDS = [
{ key: 'person', sel: 'input[name=person]' },
{ key: 'date', sel: 'input[name=date]' },
{ key: 'time', sel: 'input[name=time]' },
{ key: 'tz_offset', sel: 'input[name=tz_offset]' },
{ key: 'lat', sel: 'input[name=lat]' },
{ key: 'lon', sel: 'input[name=lon]' },
{ key: 'house_system', sel: 'select[name=house_system]' },
{ key: 'zodiac', sel: 'select[name=zodiac]' },
{ key: 'place', sel: '#geoSearch' },
{ key: 'stations', sel: 'input[name=stations]', type: 'check' },
{ key: 'tables', sel: 'input[name=tables]', type: 'check' },
{ key: 'house_systems', name: 'house_systems', type: 'multi' },
{ key: 'aspect_orb', sel: 'input[name=aspect_orb]' },
{ key: 'aspect_luminary_bonus', sel: 'input[name=aspect_luminary_bonus]' },
{ key: 'aspect_minor', sel: 'input[name=aspect_minor]', type: 'check' }
];
function elementsOf(f) {
if (f.type === 'multi') return Array.prototype.slice.call(document.querySelectorAll('[name=' + f.name + ']'));
var el = document.querySelector(f.sel);
return el ? [el] : [];
}
function load() {
try {
return JSON.parse(localStorage.getItem(KEY) || '{}') || {};
} catch (e) {
return {}; // uszkodzony wpis nie może zablokować strony
}
}
function save(state) {
try {
localStorage.setItem(KEY, JSON.stringify(state));
} catch (e) {
/* tryb prywatny albo brak miejsca — trudno, formularz ma działać dalej */
}
}
function apply(state) {
FIELDS.forEach(function (f) {
var els = elementsOf(f);
if (!els.length) return;
var v = state[f.key];
if (v === undefined || v === null) return;
if (f.type === 'multi') {
if (!Array.isArray(v)) return;
els.forEach(function (el) { el.checked = v.indexOf(el.value) !== -1; });
} else if (f.type === 'check') {
els[0].checked = !!v;
} else {
if (v === '') return;
if (els[0].value !== v) els[0].value = v;
}
});
}
function collect() {
var state = load();
FIELDS.forEach(function (f) {
var els = elementsOf(f);
if (!els.length) return; // pola nieobecnego na tej zakładce nie kasujemy
if (f.type === 'multi') {
state[f.key] = els.filter(function (el) { return el.checked; }).map(function (el) { return el.value; });
} else if (f.type === 'check') {
state[f.key] = els[0].checked;
} else {
if (els[0].value === '' || els[0].value === null) return;
state[f.key] = els[0].value;
}
});
return state;
}
// 1) odtwórz to, co użytkownik wpisał gdzie indziej
apply(load());
// 2) zapisuj każdą zmianę (input dla wpisywania, change dla list, checkboxów, kalendarzy)
FIELDS.forEach(function (f) {
elementsOf(f).forEach(function (el) {
['input', 'change'].forEach(function (ev) {
el.addEventListener(ev, function () { save(collect()); });
});
});
});
// 3) zapisz stan po wysłaniu formularza — po przeładowaniu strony pola mają
// pokazywać to, co faktycznie policzyliśmy
document.addEventListener('submit', function () { save(collect()); }, true);
// 4) druga otwarta karta zmieniła dane -> dociągnij je tutaj na żywo
window.addEventListener('storage', function (e) {
if (e.key === KEY) apply(load());
});
// 5) geo.js ustawia lat/lon z mapy poza polami formularza — po takiej zmianie
// też chcemy zapamiętać (zdarzenie leci z „Tu i teraz" oraz z mapy)
document.addEventListener('astrololo:coords', function () {
setTimeout(function () { save(collect()); }, 0);
});
})();
+4 -70
View File
@@ -23,21 +23,6 @@
var resultsEl = document.getElementById('geoResults');
var mapEl = document.getElementById('map');
// ---- strefa czasowa z lokalizacji (PRE-03) ----
// Offset GMT liczymy ze współrzędnych + daty (DST-świadomie), żeby użytkownik
// nie musiał go znać ani pamiętać o czasie letnim. Podpowiedź trafia do pola
// tz_offset (PRE-19), które zostaje edytowalne — to pomoc, nie kaganiec.
var tzEl = document.querySelector('input[name=tz_offset]');
var dateEl = document.querySelector('input[name=date]');
var timeEl = document.querySelector('input[name=time]');
var tzNote = null;
if (tzEl && tzEl.parentNode) {
tzNote = document.createElement('span');
tzNote.className = 'muted small';
tzNote.id = 'tzNote';
tzEl.parentNode.appendChild(tzNote);
}
var num = function (el) { var v = parseFloat(el.value); return isNaN(v) ? 0 : v; };
var curLat = function () { return num(latEl); };
var curLon = function () { return num(lonEl); };
@@ -53,54 +38,18 @@
// Kontener mógł być renderowany zanim ustaliła się jego szerokość — poprawka.
setTimeout(function () { map.invalidateSize(); }, 60);
// Ustawienie `.value` z kodu NIE wywołuje zdarzeń, więc synchronizacja
// między zakładkami (formsync.js) przegapiłaby zmiany z mapy. Zgłaszamy je
// jawnie przez `change`.
function setVal(el, value) {
if (!el) return;
el.value = value;
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function setCoords(lat, lon, fromMap) {
setVal(latEl, lat.toFixed(4));
setVal(lonEl, lon.toFixed(4));
latEl.value = lat.toFixed(4);
lonEl.value = lon.toFixed(4);
marker.setLatLng([lat, lon]);
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
if (fromMap) reverseName(lat, lon);
resolveTz(); // nowe współrzędne → odśwież strefę/offset
}
// Współrzędne + data → offset GMT (DST-świadomy). Wypełnia tz_offset i pokazuje,
// jaką strefę wykryto. Bez pola/daty/biblioteki po cichu pasuje — zostaje
// ręczny offset. Offset trzymamy potem jako stałą liczbę: to on decyduje przy
// przeliczaniu, więc dokładne współrzędne nie przerzucą już czasu letniego.
function resolveTz() {
if (!tzEl || !dateEl || !dateEl.value) return;
var lat = curLat(), lon = curLon();
if (!lat && !lon) return; // 0,0 = brak lokalizacji
var url = '/timezone?lat=' + lat + '&lon=' + lon +
'&date=' + encodeURIComponent(dateEl.value) +
'&time=' + encodeURIComponent((timeEl && timeEl.value) || '12:00');
fetch(url)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) { if (tzNote) tzNote.textContent = ''; return; }
setVal(tzEl, d.offset); // change → formsync podłapie
if (tzNote) tzNote.textContent = 'Wykryto: ' + d.label;
})
.catch(function () {});
}
function reverseName(lat, lon) {
// Czyścimy pole OD RAZU. Dopóki nie znamy nazwy nowego punktu, stara jest
// po prostu nieprawdziwa — pokazywałaby inne miejsce niż współrzędne obok.
// Dotyczy też sytuacji, gdy /reverse nie odpowie (brak sieci): lepiej puste
// pole i poprawne współrzędne niż nazwa, która kłamie.
if (searchEl) setVal(searchEl, '');
fetch('/reverse?lat=' + lat + '&lon=' + lon)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.name && searchEl) setVal(searchEl, d.name); })
.then(function (d) { if (d && d.name) searchEl.value = d.name; })
.catch(function () {});
}
@@ -119,7 +68,7 @@
li.textContent = it.name;
li.addEventListener('click', function () {
setCoords(it.lat, it.lon, false);
setVal(searchEl, it.name);
searchEl.value = it.name;
resultsEl.hidden = true;
});
resultsEl.appendChild(li);
@@ -159,24 +108,9 @@
});
// ---- synchronizacja z „Tu i teraz" ----
// Poza przesunięciem pineski MUSIMY odświeżyć pole tekstowe: po „Tu i teraz"
// zostawała w nim stara, ręcznie wpisana nazwa, więc formularz pokazywał jedno
// miejsce, a współrzędne wskazywały już inne. reverseName pyta o nazwę punktu,
// na który faktycznie ustawiła się mapa.
document.addEventListener('astrololo:coords', function () {
marker.setLatLng([curLat(), curLon()]);
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
reverseName(curLat(), curLon());
resolveTz(); // „Tu i teraz" → nowe miejsce → nowa strefa
});
// Zmiana daty przelicza offset — czas letni zależy od daty (inny w czerwcu
// niż w styczniu), więc bez tego offset zostałby z poprzedniej pory roku.
if (dateEl) dateEl.addEventListener('change', resolveTz);
// Na wejściu podpowiadamy strefę TYLKO gdy offset wygląda na nieustawiony
// (0 lub pusto) — żeby nie nadpisać wartości, którą użytkownik wpisał ręcznie
// i wysłał. Przy każdej późniejszej zmianie miejsca/daty już aktualizujemy.
if (tzEl && (tzEl.value === '' || parseFloat(tzEl.value) === 0)) resolveTz();
});
})();
@@ -1,58 +0,0 @@
// Podpowiedzi modeli zależne od wybranego dostawcy.
//
// Pole modelu jest CELOWO tekstowe (input + datalist), a nie zamkniętym <select>:
// katalog to tylko wygoda, a konto może mieć dostęp do modeli, o których kod nie
// wie. Puste pole = model domyślny dostawcy.
document.addEventListener('DOMContentLoaded', function () {
const providerEl = document.getElementById('llmProvider');
const modelEl = document.getElementById('llmModel');
const listEl = document.getElementById('llmModelList');
const hintEl = document.getElementById('llmModelHint');
const dataEl = document.getElementById('llmCatalog');
if (!providerEl || !modelEl || !listEl || !dataEl) return;
let catalog = {};
let defaults = {};
try {
const parsed = JSON.parse(dataEl.textContent || '{}');
catalog = parsed.providers || {};
defaults = parsed.defaults || {};
} catch (e) {
return; // brak katalogu — pole nadal działa jako wolny tekst
}
const fmt = n =>
n >= 1000000 ? (n / 1000000) + 'M' : (n >= 1000 ? Math.round(n / 1000) + 'k' : String(n));
function refresh(resetValue) {
const provider = providerEl.value;
const models = catalog[provider] || [];
listEl.innerHTML = '';
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.label = m.label + ' · kontekst ' + fmt(m.context_window);
listEl.appendChild(opt);
});
const fallback = defaults[provider] || '';
modelEl.placeholder = fallback ? 'domyślny: ' + fallback : 'domyślny dostawcy';
// po zmianie dostawcy stary model nie ma sensu (np. gpt-4o u Anthropica)
if (resetValue) modelEl.value = '';
if (hintEl) {
const chosen = models.find(m => m.id === modelEl.value.trim());
hintEl.textContent = chosen
? chosen.label + ' — okno kontekstu ' + fmt(chosen.context_window) +
' tokenów, maksymalna odpowiedź ' + fmt(chosen.max_output) + '.'
: 'Zostaw puste, by użyć modelu domyślnego. Możesz też wpisać dowolny ' +
'identyfikator modelu, do którego Twoje konto ma dostęp.';
}
}
providerEl.addEventListener('change', () => refresh(true));
modelEl.addEventListener('input', () => refresh(false));
refresh(false);
});
-97
View File
@@ -1,97 +0,0 @@
// Zapamiętana interpretacja natalna (PRE-23).
//
// Odpowiednik predictions.js, tylko że natalna jest JEDNA — dotyczy momentu
// urodzenia, nie wybranego okresu. Ponowne wygenerowanie podmienia poprzednią.
//
// Po co magazyn: zakładka „Skompiluj" składa raport z trzech kawałków liczonych
// w różnych miejscach (horoskop, interpretacja natalna, predykcje okresowe).
// Bez zapamiętania trzeba by je generować od nowa na jednej stronie.
//
// localStorage, tak jak reszta stanu (PRE-21/22) — serwer zostaje bezstanowy.
(function () {
'use strict';
var KEY = 'astrololo.natal.v1';
function read() {
try {
var v = JSON.parse(localStorage.getItem(KEY) || 'null');
return (v && typeof v === 'object' && v.text) ? v : null;
} catch (e) {
return null; // uszkodzony wpis nie może zablokować strony
}
}
function clear() {
try { localStorage.removeItem(KEY); } catch (e) { /* trudno */ }
}
function val(sel) {
var el = document.querySelector(sel);
return el ? el.value.trim() : '';
}
function save() {
var text = val('#horoscopeText');
if (!text) return false; // nie ma czego zapamiętać
var entry = {
text: text,
person: val('input[name=person]'),
date: val('input[name=date]'),
time: val('input[name=time]'),
tz_offset: val('input[name=tz_offset]'),
lat: val('input[name=lat]'),
lon: val('input[name=lon]'),
saved_at: new Date().toISOString()
};
try {
localStorage.setItem(KEY, JSON.stringify(entry));
return true;
} catch (e) {
return false; // przepełniony magazyn — zgłaszamy wyżej
}
}
function note(msg) {
var el = document.getElementById('natalNote');
if (el) el.textContent = msg;
}
function saveCurrent() {
var text = val('#horoscopeText');
if (!text) return;
if (save()) {
note('Interpretacja natalna zapamiętana — trafi do raportu (zakładka Skompiluj).');
} else {
// Interpretacje bywają długie; cicha strata byłaby najgorsza.
note('Nie udało się zapamiętać — magazyn przeglądarki pełny. '
+ 'Usuń starsze predykcje i spróbuj ponownie.');
}
}
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
if (!document.getElementById('natalNote')) return; // nie ta zakładka
// 1) interpretacja policzona przez okno postępu (strumień)
document.addEventListener('astrololo:horoscope', function (e) {
if (!e.detail || e.detail.profile === 'natal') saveCurrent();
});
// 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
// POST. Zapis nadpisuje jeden slot, więc odświeżenie niczego nie mnoży.
if (document.querySelector('#horoscopeText')) saveCurrent();
var status = read();
if (status) {
note('W raporcie jest zapamiętana interpretacja natalna z '
+ String(status.saved_at).slice(0, 10) + '.');
}
});
window.astrololoNatal = { read: read, clear: clear, key: KEY };
})();
@@ -1,150 +0,0 @@
// Zapamiętane predykcje okresowe (PRE-22).
//
// Kalendarz pozwala policzyć horoskop na wybrany okres. Partnerzy chcą kilku
// okresów naraz — i żeby wszystkie trafiły potem do raportu (zakładka
// „Skompiluj", PRE-23/24). Tu jest magazyn i lista.
//
// Trzymamy w localStorage, tak jak wspólne dane formularza (PRE-21): serwer
// zostaje bezstanowy, żadne dane urodzeniowe ani treści z baz nie lądują po
// stronie usługi. Zakładka „Skompiluj" odczyta to samo miejsce.
(function () {
'use strict';
var KEY = 'astrololo.predictions.v1';
function read() {
try {
var v = JSON.parse(localStorage.getItem(KEY) || '[]');
return Array.isArray(v) ? v : [];
} catch (e) {
return []; // uszkodzony wpis nie może zablokować strony
}
}
function write(list) {
try {
localStorage.setItem(KEY, JSON.stringify(list));
return true;
} catch (e) {
return false; // najczęściej przepełniony magazyn
}
}
function byPeriod(a, b) {
return String(a.from_date).localeCompare(String(b.from_date));
}
// Klucz tożsamości to OKRES. Ponowne policzenie tego samego zakresu ma
// podmienić wpis, a nie dokładać duplikat — inaczej lista puchłaby przy
// każdej próbie z innym modelem.
function upsert(entry) {
var list = read().filter(function (p) {
return !(p.from_date === entry.from_date && p.to_date === entry.to_date);
});
list.push(entry);
list.sort(byPeriod);
return write(list) ? list : null;
}
function remove(id) {
var list = read().filter(function (p) { return p.id !== id; });
write(list);
return list;
}
function val(sel) {
var el = document.querySelector(sel);
return el ? el.value.trim() : '';
}
/** Wyciąga świeżo policzony horoskop okresowy ze strony. */
function capture() {
var text = val('#horoscopeText');
var from = val('input[name=from_date]');
var to = val('input[name=to_date]');
if (!text || !from || !to) return null;
return {
id: from + '..' + to,
from_date: from,
to_date: to,
person: val('input[name=person]'),
text: text,
saved_at: new Date().toISOString()
};
}
function fmtDate(iso) {
return String(iso || '').slice(0, 10);
}
function render() {
var host = document.getElementById('savedPredictions');
if (!host) return;
var list = read();
if (!list.length) {
host.innerHTML = '<p class="muted small">Brak zapamiętanych predykcji. ' +
'Policz horoskop na wybrany okres — zapamięta się sam i trafi do raportu.</p>';
return;
}
var rows = list.map(function (p) {
var chars = (p.text || '').length;
return '<tr>' +
'<td class="nowrap">' + fmtDate(p.from_date) + ' ' + fmtDate(p.to_date) + '</td>' +
'<td class="muted small">' + fmtDate(p.saved_at) + '</td>' +
'<td class="muted small">' + chars.toLocaleString('pl-PL') + ' znaków</td>' +
'<td><button type="button" class="ghost" data-drop="' +
String(p.id).replace(/"/g, '&quot;') + '">Usuń</button></td>' +
'</tr>';
}).join('');
host.innerHTML =
'<div class="meta">Zapamiętane predykcje (' + list.length + ') — wejdą do raportu</div>' +
'<table class="angles"><thead><tr><th>Okres</th><th>Zapisano</th>' +
'<th>Objętość</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
Array.prototype.forEach.call(host.querySelectorAll('[data-drop]'), function (btn) {
btn.addEventListener('click', function () {
remove(btn.getAttribute('data-drop'));
render();
});
});
}
function saveCurrent() {
var entry = capture();
if (!entry) return;
var note = document.getElementById('predictionsNote');
if (upsert(entry)) {
if (note) note.textContent = 'Zapamiętano predykcję ' +
entry.from_date + ' ' + entry.to_date + '.';
} else if (note) {
// Horoskopy bywają długie; gdy magazyn się przepełni, trzeba powiedzieć
// wprost, zamiast po cichu zgubić wynik.
note.textContent = 'Nie udało się zapamiętać — magazyn przeglądarki pełny. ' +
'Usuń starsze predykcje i spróbuj ponownie.';
}
render();
}
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
if (!document.getElementById('savedPredictions')) return; // nie ta zakładka
render();
// 1) horoskop policzony przez okno postępu (strumień)
document.addEventListener('astrololo:horoscope', function (e) {
if (!e.detail || e.detail.profile === 'period') saveCurrent();
});
// 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
// POST. Zapis jest idempotentny (klucz = okres), więc odświeżenie strony
// niczego nie duplikuje.
if (document.querySelector('#horoscopeText')) saveCurrent();
});
// Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy.
window.astrololoPredictions = { read: read, remove: remove, key: KEY };
})();
@@ -1,138 +0,0 @@
// Okno postępu przy pisaniu horoskopu.
//
// Problem: generowanie trwa minutami, a zwykły POST formularza nie daje żadnego
// sygnału — aplikacja wygląda na zawieszoną. Zamiast udawanego paska postępu
// czytamy strumień RZECZYWISTYCH zdarzeń z serwera (NDJSON) i wypisujemy je
// jako log: budowa promptu, limity modelu, każda tura generowania.
//
// Degradacja: jeśli przeglądarka nie umie strumieniować `fetch`, nie przechwytujemy
// wysyłki — formularz idzie klasycznie i wszystko działa jak wcześniej, tylko bez okna.
document.addEventListener('DOMContentLoaded', function () {
const canStream = typeof fetch === 'function' && typeof ReadableStream === 'function' &&
typeof TextDecoder === 'function';
const form = document.querySelector('form[action="/interpret"], form[action="/timeline"]');
if (!canStream || !form) return;
const profile = form.getAttribute('action') === '/timeline' ? 'period' : 'natal';
const btn = form.querySelector('button[value="horoscope"]');
if (!btn) return;
// --- okno ---------------------------------------------------------------
const overlay = document.createElement('div');
overlay.className = 'progress-overlay';
overlay.hidden = true;
overlay.innerHTML =
'<div class="progress-box" role="dialog" aria-modal="true" aria-label="Postęp generowania">' +
'<div class="progress-head">' +
'<span class="progress-spinner" aria-hidden="true"></span>' +
'<strong id="progressTitle">Piszę horoskop…</strong>' +
'<span class="progress-clock" id="progressClock">0:00</span>' +
'</div>' +
'<ol class="progress-log" id="progressLog"></ol>' +
'<p class="muted small">Nie zamykaj tej karty — generowanie trwa na serwerze.</p>' +
'<div class="actions"><button type="button" class="ghost" id="progressClose" hidden>Zamknij</button></div>' +
'</div>';
document.body.appendChild(overlay);
const logEl = overlay.querySelector('#progressLog');
const clockEl = overlay.querySelector('#progressClock');
const titleEl = overlay.querySelector('#progressTitle');
const closeEl = overlay.querySelector('#progressClose');
let timer = null;
function addLine(text, kind) {
const li = document.createElement('li');
if (kind) li.className = 'log-' + kind;
const now = new Date();
li.textContent = String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0') + ' ' + text;
logEl.appendChild(li);
logEl.scrollTop = logEl.scrollHeight;
}
function startClock() {
const t0 = Date.now();
timer = setInterval(function () {
const s = Math.floor((Date.now() - t0) / 1000);
clockEl.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}, 1000);
}
function finish(title, allowClose) {
if (timer) { clearInterval(timer); timer = null; }
titleEl.textContent = title;
overlay.querySelector('.progress-spinner').style.visibility = 'hidden';
if (allowClose) closeEl.hidden = false;
}
closeEl.addEventListener('click', function () { overlay.hidden = true; });
// --- przechwycenie wysyłki ----------------------------------------------
btn.addEventListener('click', function (event) {
event.preventDefault();
if (!form.reportValidity()) return; // te same reguły co przy zwykłej wysyłce
const data = new FormData(form);
data.set('profile', profile);
logEl.innerHTML = '';
closeEl.hidden = true;
overlay.querySelector('.progress-spinner').style.visibility = '';
titleEl.textContent = 'Piszę horoskop…';
overlay.hidden = false;
startClock();
addLine('Wysyłam żądanie…');
fetch('/horoscope/stream', { method: 'POST', body: data })
.then(function (response) {
if (!response.ok || !response.body) throw new Error('HTTP ' + response.status);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
function pump() {
return reader.read().then(function (chunk) {
if (chunk.done) return;
buffer += decoder.decode(chunk.value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // ostatni może być niepełny
lines.forEach(function (raw) {
if (!raw.trim()) return;
let ev;
try { ev = JSON.parse(raw); } catch (e) { return; }
if (ev.type === 'ping') return; // sam heartbeat, nie logujemy
if (ev.type === 'result') {
addLine(ev.message || 'Gotowe.', 'ok');
if (ev.html) {
const host = document.getElementById('promptResult');
if (host) host.innerHTML = ev.html;
}
finish('Gotowe', true);
overlay.hidden = true;
const host = document.getElementById('promptResult');
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Gotowy horoskop ogłaszamy zdarzeniem, zamiast zapisywać go tutaj:
// to okno postępu, a nie magazyn. Nasłuchuje predictions.js (PRE-22).
document.dispatchEvent(new CustomEvent('astrololo:horoscope', {
detail: { profile: profile }
}));
return;
}
addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
: ev.type === 'warn' ? 'warn' : null);
if (ev.type === 'error') finish('Nie udało się', true);
});
return pump();
});
}
return pump();
})
.catch(function (e) {
addLine('Połączenie przerwane: ' + e.message, 'err');
addLine('Możesz spróbować ponownie — nic nie zostało utracone.');
finish('Nie udało się', true);
});
});
});
-101
View File
@@ -32,55 +32,6 @@ h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
(DAN-18: kolorowe kwadraty nieczytelne i niesterowalne kolorem):
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
2) font-variant-emoji:text nowsza własność CSS,
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
znaków zodiaku (), mimo VS15 potwierdzone wizualnie. Apple Symbols",
Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
"DejaVu Sans", sans-serif; }
td.glyph { color: var(--accent); white-space: nowrap; }
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
.wheel-fig figcaption { margin-top: .4rem; }
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
svg.wheel text.glyph { font-size: inherit; }
/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
.aspectarian-fig figcaption { margin-top: .4rem; }
svg.aspectarian text.glyph { font-size: inherit; }
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
ale PONIŻEJ okna postępu (1200) gdy trwa pisanie horoskopu, log operacji ma
zostać na wierzchu. */
.wheel-fig svg.wheel { cursor: zoom-in; }
.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.wheel-fig.zoomed {
position: fixed; inset: 0; z-index: 1100; margin: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: .5rem; padding: 1rem; background: var(--bg);
}
/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
bokiem okna inaczej na szerokim ekranie wystawałoby w pionie. */
.wheel-fig.zoomed svg.wheel {
max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
}
body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
.row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; }
@@ -93,18 +44,6 @@ 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; }
@@ -149,43 +88,3 @@ textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizi
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
/* Okno postępu przy generowaniu horoskopu */
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom zoom,
atrybucja) siedzą na z-index:1000 i bo .leaflet-container nie tworzy własnego
kontekstu stackowania trafiają wprost do korzenia. Przy z-index:50 mapa
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
display: flex; align-items: center; justify-content: center;
z-index: 1200; padding: 1rem; }
.progress-overlay[hidden] { display: none; }
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
color: var(--muted); font-size: .9rem; }
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
border: 2px solid var(--line); border-top-color: var(--accent);
animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
max-height: 15rem; overflow-y: auto;
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
.progress-log li { color: var(--ink); white-space: pre-wrap; }
.progress-log li.log-ok { color: #7fd18b; }
.progress-log li.log-warn { color: #e0c060; }
.progress-log li.log-err { color: #ef6b6b; }
/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
.readiness li { padding: .2rem 0; }
.readiness .rdy-ok { color: #7fd18b; }
.readiness .rdy-missing { color: #e0c060; }
.report-head { margin: 1.5rem 0 .5rem; }
.report-head h2 { margin: 0; font-size: 1.4rem; }
/* Treść od AI bywa długa white-space:pre-wrap zachowuje akapity modelu,
bez przepisywania ich na HTML. */
.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
@@ -1,60 +0,0 @@
// Powiększanie kosmogramu na pełne okno (PRE-25).
//
// Koło rysujemy w kolumnie tekstu, więc na małym ekranie glify i stopnie robią
// się nieczytelne. Kliknięcie rozciąga wykres na całe okno, ponowne wraca do
// strony. SVG skaluje się bez utraty jakości, więc nie potrzeba drugiej wersji
// rysunku ani żadnej biblioteki.
//
// Podpięte globalnie i osłonięte sprawdzeniem, czy koło w ogóle jest na stronie
// — dzięki temu zadziała też na przyszłej zakładce „Skompiluj", bez zmian tutaj.
(function () {
'use strict';
var ZOOMED = 'zoomed';
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
var figures = document.querySelectorAll('.wheel-fig');
if (!figures.length) return;
Array.prototype.forEach.call(figures, function (fig) {
var svg = fig.querySelector('svg.wheel');
if (!svg) return;
// Klawiatura ma działać tak samo jak mysz — to nie jest ozdobnik, tylko
// jedyny sposób na obejrzenie szczegółów wykresu.
svg.setAttribute('tabindex', '0');
svg.setAttribute('role', 'button');
svg.setAttribute('aria-label', 'Kosmogram — kliknij, aby powiększyć na całe okno');
function toggle() {
var on = fig.classList.toggle(ZOOMED);
// Blokujemy przewijanie strony pod spodem: inaczej scroll „przecieka"
// na treść, której i tak nie widać.
document.body.classList.toggle('wheel-zoom-open', on);
svg.setAttribute('aria-label', on
? 'Kosmogram powiększony — kliknij lub naciśnij Esc, aby wrócić'
: 'Kosmogram — kliknij, aby powiększyć na całe okno');
if (on) svg.focus();
}
svg.addEventListener('click', toggle);
svg.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
});
// Klik w tło (poza samym rysunkiem) też zamyka — naturalne dla nakładki.
fig.addEventListener('click', function (e) {
if (e.target === fig && fig.classList.contains(ZOOMED)) toggle();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && fig.classList.contains(ZOOMED)) toggle();
});
});
});
})();
@@ -1,21 +0,0 @@
{# Opcje obliczeń — WSPÓLNE dla „Horoskop" (/) i „Skompiluj" (/compile). Jeden plik,
bo dodawaliśmy je już 3× w dwóch formularzach i groziło rozjechaniem (regresja
podsumowania). Wszystkie synchronizowane między zakładkami przez formsync.js. #}
<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="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>
@@ -1,6 +1,3 @@
{# Katalog modeli wstrzykiwany przez handler — pole jest tekstowe, więc
można wpisać dowolny model, do którego konto ma dostęp. #}
<script type="application/json" id="llmCatalog">{{ llm_catalog | tojson }}</script>
{# Wspólny blok: sterowanie generowaniem + prompt (LOG-29/30) + horoskop (LOG-31).
Używany na ekranach Interpretacje i Kalendarz. #}
<div class="opts">
@@ -10,30 +7,80 @@
<option value="concise" {{ 'selected' if pb == 'concise' else '' }}>zwięzły (~4 tys. znaków)</option>
<option value="medium" {{ 'selected' if pb == 'medium' else '' }}>średni (~12 tys.)</option>
<option value="extensive" {{ 'selected' if pb == 'extensive' else '' }}>obszerny (~30 tys.)</option>
<option value="huge" {{ 'selected' if pb == 'huge' else '' }}>bardzo obszerny (~120 tys.)</option>
<option value="max" {{ 'selected' if pb == 'max' else '' }}>maksymalny kontekst modelu</option>
</select>
</label>
<label>Dostawca
<select name="llm_provider" id="llmProvider">
<label>Model
<select name="llm_provider">
{% set lp = form.llm_provider or 'local' %}
<option value="local" {{ 'selected' if lp == 'local' else '' }}>lokalny — nic nie opuszcza sieci</option>
<option value="anthropic" {{ 'selected' if lp == 'anthropic' else '' }}>Anthropic — dane wychodzą</option>
<option value="openai" {{ 'selected' if lp == 'openai' else '' }}>OpenAI — dane wychodzą</option>
</select>
</label>
<label>Model
<input type="text" name="llm_model" id="llmModel" list="llmModelList"
value="{{ form.llm_model or '' }}" placeholder="domyślny dostawcy"
autocomplete="off">
<datalist id="llmModelList"></datalist>
</label>
</div>
<p class="muted small" id="llmModelHint"></p>
<p class="muted small">
Prompt zawiera <strong>oryginalne opisy z baz</strong> oraz dane urodzeniowe. Model lokalny
przetwarza je u nas; wybór dostawcy w chmurze oznacza, że ta treść <strong>opuszcza naszą
sieć</strong>. Możesz najpierw obejrzeć prompt, a dopiero potem wysłać.
</p>
<div id="promptResult">{% include "_prompt_result.html" %}</div>
{% if prompt_result %}
{% set st = prompt_result.stats %}
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
{% if prompt_result.horoscope %}
<div class="meta">
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
model: {{ prompt_result.model }}
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
{% endif %}
{% if prompt_result.leaves_lan %}
· <strong class="retro">dane opuściły sieć</strong>
{% else %}
· dane nie opuściły sieci
{% endif %}
</div>
<div class="actions">
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
</div>
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
<p class="muted small">
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
</p>
{% endif %}
{% if prompt_result.llm_error %}
<div class="error">
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
</div>
{% endif %}
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
<div class="meta">
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
budżet: {{ st.budget }} ·
wskazań: <strong>{{ st.included }}</strong>
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
</div>
{% if st.omitted %}
<p class="muted small">
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
</p>
{% endif %}
{% if prompt_result.data_error %}
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
{% endif %}
<div class="actions">
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
</div>
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
{% endif %}
@@ -1,80 +0,0 @@
{# Blok WYNIKU generowania (prompt/horoskop). Wydzielony, bo wstawia go także
okno postępu po zakończeniu strumienia — dzięki temu jest jedno źródło
prawdy dla wyglądu wyniku, niezależnie od drogi, którą przyszedł. #}
{% if prompt_result %}
{% set st = prompt_result.stats %}
{# --- wynik: napisany horoskop (LOG-31) + transparentność (PRE-15) --- #}
{% if prompt_result.horoscope %}
<div class="meta">
Horoskop napisany przez: <strong>{{ prompt_result.provider }}</strong> ·
model: {{ prompt_result.model }}
{% if prompt_result.usage and prompt_result.usage.completion_tokens %}
· tokeny odpowiedzi: {{ prompt_result.usage.completion_tokens }}
{% endif %}
{% if prompt_result.leaves_lan %}
· <strong class="retro">dane opuściły sieć</strong>
{% else %}
· dane nie opuściły sieci
{% endif %}
</div>
<div class="actions">
<button type="button" class="ghost" data-copy="#horoscopeText">Kopiuj horoskop</button>
</div>
<textarea id="horoscopeText" class="prompt" rows="20" readonly>{{ prompt_result.horoscope }}</textarea>
<p class="muted small">
Treść wygenerował model językowy na podstawie {{ st.included }} wskazań z baz.
<strong>Nie stanowi porady medycznej, prawnej ani finansowej.</strong>
</p>
{% endif %}
{% if prompt_result.warnings %}
{% for w in prompt_result.warnings %}
<p class="muted small"><strong>Uwaga:</strong> {{ w }}</p>
{% endfor %}
{% endif %}
{% if prompt_result.token_plan %}
{% set tp = prompt_result.token_plan %}
<p class="muted small">
Tokeny: prompt {{ tp.prompt_tokens }} · okno modelu {{ tp.context_window }} ·
zarezerwowane na odpowiedź {{ tp.max_output }}
{% if prompt_result.usage and prompt_result.usage.turns and prompt_result.usage.turns > 1 %}
· odpowiedź złożona z {{ prompt_result.usage.turns }} tur (model dokańczał urwany tekst)
{% endif %}
</p>
{% endif %}
{% if prompt_result.llm_error %}
<div class="error">
Nie udało się napisać horoskopu: {{ prompt_result.llm_error }}<br>
Prompt poniżej jest gotowy — możesz go skopiować i użyć ręcznie.
</div>
{% endif %}
{# --- prompt: zawsze dostępny do podglądu i skopiowania --- #}
<div class="meta">
Prompt · {{ st.chars }} znaków (~{{ st.est_tokens }} tokenów) ·
budżet: {{ st.budget }} ·
wskazań: <strong>{{ st.included }}</strong>
{% if st.omitted %}· pominięto: <strong>{{ st.omitted }}</strong>{% endif %}
{% if st.deduplicated %}· scalono powtórek: {{ st.deduplicated }}{% endif %}
</div>
{% if st.omitted %}
<p class="muted small">
Pominięto {{ st.omitted }} najsłabszych wskazań (próg wagi {{ st.min_score_included }}).
Chcesz komplet — wybierz obszerniejszy budżet i wygeneruj ponownie.
</p>
{% endif %}
{% if prompt_result.data_error %}
<div class="error">{{ prompt_result.data_error }} — prompt złożony z samych wyliczeń.</div>
{% endif %}
<div class="actions">
<button type="button" class="ghost" data-copy="#promptText">Kopiuj prompt</button>
<span class="muted small">Możesz też wkleić go samodzielnie do ChatGPT lub Claude.</span>
</div>
<textarea id="promptText" class="prompt" rows="14" readonly>{{ prompt_result.prompt }}</textarea>
{% endif %}
@@ -1,239 +0,0 @@
{# Tabele wyniku horoskopu — używane przez „Skompiluj" (/compile), żeby
podsumowanie pokazywało dokładnie to, co policzono (regresja 2026-07-28:
podsumowanie miało braki). Skopiowane 1:1 z sekcji tabel na „Horoskop" (/).
TODO: przełączyć też chart.html na ten include, żeby oba widoki nie mogły się
rozjechać — na razie pilnować ręcznie, że są zgodne. Rysunki (koło/aspektarian/
deklinacja/antyscja) renderuje rodzic; tu są same tabele. Każda sekcja pokazuje
się tylko, gdy jej dane są w wyniku (opcja policzona → sekcja jest). #}
{# Fallback systemu domów: system nie istniał dla tego miejsca i policzyliśmy
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
{% if result.house_warnings %}
{% for w in result.house_warnings %}
<p class="house-warning">{{ w }}</p>
{% endfor %}
{% endif %}
{% if result.angles %}
<table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
<tbody>
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
{% set a = result.angles[key] %}
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #}
{% if result.house_systems and result.house_systems | length > 1 %}
<div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div>
<table class="angles">
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ house_label(hs.system) }}</th>{% endfor %}</tr></thead>
<tbody>
{% for i in range(12) %}
<tr><td>{{ i + 1 }}</td>
{% for hs in result.house_systems %}
<td class="mono"><span class="glyph">{{ hs.cusps[i].sign_glyph }}</span> {{ hs.cusps[i].in_sign }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<table>
<thead>
<tr>
<th title="Symbol astrologiczny (LOG-22)">Sym.</th>
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
</tr>
</thead>
<tbody>
{% for p in result.positions %}
<tr>
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
<td>{{ p.name }}</td>
<td>{{ p.sign }}</td>
<td class="mono">{{ p.in_sign }}</td>
<td>{{ p.house if p.house is defined else '—' }}</td>
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
<td class="mono">{% if p.declination is defined %}{{ '%+.2f°' | format(p.declination) }}{% if p.out_of_bounds %} <span class="badge" title="Out of bounds — deklinacja poza zakresem Słońca">OOB</span>{% endif %}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if result.lots %}
<div class="meta">Lots hermetyczne ({{ result.lots | length }}) · sekta: <strong>{{ result.sect }}</strong></div>
<table class="angles">
<thead><tr><th>Lot</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Formuła</th></tr></thead>
<tbody>
{% for l in result.lots %}
<tr><td>{{ l.name }}</td><td>{{ l.sign }}</td><td class="mono">{{ l.in_sign }}</td><td>{{ l.house }}</td><td class="muted small">{{ l.formula }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
{% if with_stations %}
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
<table class="angles">
<thead><tr><th>Planeta</th><th>Poprzednia</th><th>Następna</th></tr></thead>
<tbody>
{% for p in with_stations %}
<tr>
<td>{{ p.name }}{% if p.stations.station_soon %} <span class="badge">blisko</span>{% endif %}</td>
<td class="mono">{% if p.stations.prev %}{{ p.stations.prev.type }} · {{ p.stations.prev.date }} · {{ p.stations.prev.degree }} ({{ p.stations.prev.days }} d){% else %}—{% endif %}</td>
<td class="mono">{% if p.stations.next %}{{ p.stations.next.type }} · {{ p.stations.next.date }} · {{ p.stations.next.degree }} (+{{ p.stations.next.days }} d){% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.aspects %}
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
<tbody>
{% for a in result.aspects %}
<tr><td>{{ a.obj1 }}</td><td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.parallels %}
<div class="meta" title="Ciała na tej samej (paralela) lub przeciwnej (kontrparalela) deklinacji — działają jak koniunkcja / opozycja poza ekliptyką">Paralele deklinacji ({{ result.parallels | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th><th>Dekl.</th></tr></thead>
<tbody>
{% for r in result.parallels %}
<tr>
<td>{{ r.obj1 }}</td>
<td>{{ 'paralela' if r.type == 'parallel' else 'kontrparalela' }}</td>
<td>{{ r.obj2 }}</td>
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
<td class="mono">{{ '%+.2f'|format(r.dec1) }} / {{ '%+.2f'|format(r.dec2) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.antiscia %}
<div class="meta" title="Odbicie względem osi przesileń (antyscja) lub równonocy (kontrantyscja) — punkty „dzielące” tę samą długość dnia">Antyscja ({{ result.antiscia | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
<tbody>
{% for r in result.antiscia %}
<tr>
<td>{{ r.obj1 }}</td>
<td>{{ 'antyscja' if r.type == 'antiscion' else 'kontrantyscja' }}</td>
<td>{{ r.obj2 }}</td>
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.tables %}
{% set tb = result.tables %}
<div class="meta">Tabele dodatkowe (LOG-23)</div>
{% set bal = tb.tally.with_modern_10_plus_asc %}
<table class="angles">
<thead><tr><th>Bilans (10 planet + Asc)</th><th colspan="4">Rozkład</th></tr></thead>
<tbody>
<tr><td>Żywioły</td>
{% for e, n in bal.elements.items() %}
<td>{{ tb.tally.labels.elements[e] }}: <strong>{{ n }}</strong></td>
{% endfor %}
</tr>
<tr><td>Jakości</td>
{% for q, n in bal.qualities.items() %}
<td>{{ tb.tally.labels.qualities[q] }}: <strong>{{ n }}</strong></td>
{% endfor %}
<td></td>
</tr>
</tbody>
</table>
{% if tb.tally.missing_elements or tb.tally.missing_qualities %}
<p class="muted small">Brak:
{% for e in tb.tally.missing_elements %}<strong>{{ tb.tally.labels.elements[e] }}</strong>{{ ", " if not loop.last }}{% endfor %}
{% for q in tb.tally.missing_qualities %}<strong>{{ tb.tally.labels.qualities[q] }}</strong>{{ ", " if not loop.last }}{% endfor %}
</p>
{% endif %}
{% if tb.moon_phase %}
<p class="muted small">
<strong>Faza Księżyca:</strong> {{ tb.moon_phase.phase_pl }} ·
elongacja {{ '%.2f'|format(tb.moon_phase.angle) }}° ·
oświetlenie {{ '%.1f'|format(tb.moon_phase.illumination * 100) }}% ·
{{ 'przybywa' if tb.moon_phase.waxing else 'ubywa' }}
</p>
{% endif %}
{% if tb.planetary_hours %}
{% set ph = tb.planetary_hours %}
<p class="muted small">
<strong>Godziny planetarne:</strong> władca dnia {{ ph.day_ruler }} ·
{{ ph.hour_number }}. godzina ({{ ph.period }}), władca {{ ph.hour_ruler }} ·
godzina trwa {{ ph.hour_length_minutes }} min
</p>
{% endif %}
{% if tb.prenatal_syzygy %}
{% set s = tb.prenatal_syzygy %}
<p class="muted small">
<strong>Syzygia prenatalna:</strong> {{ s.type_pl }} {{ s.when_utc }}
({{ s.days_before_birth }} dni przed) w {{ s.in_sign }}
</p>
{% endif %}
{% if tb.critical_degrees %}
<div class="meta">Stopnie krytyczne</div>
<table class="angles">
<thead><tr><th>Obiekt</th><th>Pozycja</th><th>Uwaga</th></tr></thead>
<tbody>
{% for c in tb.critical_degrees %}
<tr><td>{{ c.name }}</td><td class="mono">{{ c.in_sign }}</td>
<td class="muted small">{{ c.flags | join('; ') }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<details class="loc">
<summary>Podziały: dwunastniki (D12) i nawamsa (D9)</summary>
<table>
<thead><tr><th>Obiekt</th><th>D12</th><th>D9</th></tr></thead>
<tbody>
{% for d in tb.divisional %}
<tr><td>{{ d.name }}</td><td class="mono">{{ d.d12_in_sign }}</td>
<td class="mono">{{ d.d9_in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% if result.cusps %}
<details class="loc">
<summary>Cusps domów ({{ house_label(result.house_system) }})</summary>
<table>
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
<tbody>
{% for c in result.cusps %}
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
+1 -12
View File
@@ -4,15 +4,7 @@
<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') }}">
{# 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>
{# 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>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main>
@@ -22,10 +14,7 @@
<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 %}
+89 -41
View File
@@ -6,12 +6,6 @@
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
<form method="post" action="/">
<div class="grid">
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
value="{{ form.person or '' }}">
</label>
</div>
<div class="grid">
<label>Data
<input type="date" name="date" value="{{ form.date or '' }}" required>
@@ -19,9 +13,8 @@
<label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required>
</label>
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h)
<input type="number" name="tz_offset" step="0.25" min="-12" max="14"
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
<label>Strefa (offset h)
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label>
</div>
<div class="grid">
@@ -34,11 +27,10 @@
<label>System domów
<select name="house_system">
{% set hs = form.house_system or 'whole_sign' %}
{% for value, name in HOUSE_SYSTEMS %}
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
{% endfor %}
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
</select>
<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">
@@ -52,7 +44,10 @@
</label>
</div>
{% include "_location_picker.html" %}
{% include "_form_options.html" %}
<div class="opts">
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
licz stacje planet (wolniejsze)</label>
</div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
@@ -66,48 +61,101 @@
{% endif %}
{% if result %}
{# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #}
{% for w in result.house_warnings or [] %}
<p class="house-warning">{{ w }}</p>
{% endfor %}
<div class="meta">
Silnik: <strong>{{ result.engine }}</strong> ·
obiektów: {{ result.positions | length }}
{% if result.house_system %}· domy: {{ house_label(result.house_system) }}{% endif %}
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
{% if result.zodiac %}· zodiak: {{ result.zodiac }}{% if result.ayanamsha is defined %} (ayanamsa {{ '%.4f'|format(result.ayanamsha) }}°){% endif %}{% endif %}
{% if moment %}· moment: {{ moment }}{% endif %}
</div>
{% if wheel_svg %}
<figure class="wheel-fig">
{{ wheel_svg | safe }}
<figcaption class="muted small">Kosmogram — znaki, domy, osie, obiekty, Lots (⊗) i aspekty. Linie w środku: niebieskie — harmonijne, czerwone — napięte, grubsze = ciaśniejszy orb. Najedź na obiekt lub linię, by zobaczyć szczegóły.</figcaption>
</figure>
{% if result.angles %}
<table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
<tbody>
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
{% set a = result.angles[key] %}
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if aspectarian_svg %}
<figure class="aspectarian-fig">
{{ aspectarian_svg | safe }}
<figcaption class="muted small">Aspektarian — siatka aspektów obiekt×obiekt. Glify po przekątnej to obiekty; komórka pod przekątną to aspekt między obiektem z jej wiersza a obiektem z jej kolumny (pusto = brak aspektu). Barwy jak na kole: niebieski — harmonijny, czerwony — napięty, zielony — koniunkcja; pogrubiony = orb poniżej 1°. Najedź na komórkę po szczegóły.</figcaption>
</figure>
<table>
<thead>
<tr>
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
</tr>
</thead>
<tbody>
{% for p in result.positions %}
<tr>
<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>
</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 %}
{% if declination_svg %}
<figure class="declination-fig">
{{ declination_svg | safe }}
<figcaption class="muted small">Wykres deklinacji — obiekty na tej samej wysokości są w <strong>paraleli</strong> (jak koniunkcja poza ekliptyką, zielony łącznik), lustrzane względem równika — w <strong>kontrparaleli</strong> (jak opozycja, czerwony). Poza zwrotnikami (±ε, strefa cieniowana) obiekt jest <strong>OOB</strong>. Najedź po szczegóły.</figcaption>
</figure>
{% 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 antiscia_svg %}
<figure class="antiscia-fig">
{{ antiscia_svg | safe }}
<figcaption class="muted small">Oś antyscji — ekliptyka rozwinięta w poziom; pionowa oś przesileń (0° Raka / Koziorożca) to lustro <strong>antyscji</strong>, oś równonocy (0° Barana / Wagi) — <strong>kontrantyscji</strong>. Łuki łączą pary. Najedź po szczegóły.</figcaption>
</figure>
{% 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>{{ 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 %}
{% include "_result_tables.html" %}
{% if result.cusps %}
<details class="loc">
<summary>Cusps domów ({{ result.house_system }})</summary>
<table>
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
<tbody>
{% for c in result.cusps %}
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% endif %}
<script src="{{ static('now.js') }}"></script>
<script src="/static/now.js"></script>
{% endblock %}
@@ -1,130 +0,0 @@
{% extends "base.html" %}
{% block title %}Skompiluj{% endblock %}
{% block nav_compile %}active{% endblock %}
{% block content %}
<p class="sub">Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.</p>
{# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część
kawałków (AI) mieszka w magazynie lokalnym, nie na serwerze. #}
<div id="readiness"></div>
<form method="post" action="/compile">
<div class="grid">
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
value="{{ form.person or '' }}">
</label>
</div>
<div class="grid">
<label>Data
<input type="date" name="date" value="{{ form.date or '' }}" required>
</label>
<label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required>
</label>
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h)
<input type="number" name="tz_offset" step="0.25" min="-12" max="14"
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label>
</div>
<div class="grid">
<label>Szerokość (lat, + N)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
</label>
<label>Długość (lon, + E)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
<label>System domów
<select name="house_system">
{% set hs = form.house_system or 'whole_sign' %}
{% for value, name in HOUSE_SYSTEMS %}
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
{% endfor %}
</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">
{% set zd = form.zodiac or 'tropical' %}
<option value="tropical" {{ 'selected' if zd == 'tropical' else '' }}>Tropikalny</option>
<option value="sidereal_lahiri" {{ 'selected' if zd == 'sidereal_lahiri' else '' }}>Syderyczny (Lahiri)</option>
<option value="sidereal_fagan_bradley" {{ 'selected' if zd == 'sidereal_fagan_bradley' else '' }}>Syderyczny (Fagan-Bradley)</option>
<option value="sidereal_krishnamurti" {{ 'selected' if zd == 'sidereal_krishnamurti' else '' }}>Syderyczny (Krishnamurti)</option>
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select>
</label>
</div>
{# 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"
title="Generuje plik pośredni LaTeXa i kompiluje go do PDF (usługa render)">Pobierz PDF</button>
<span id="pdfNote" class="muted small"></span>
</div>
</form>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
{% if result %}
{# ── 1. Nagłówek: kto ──────────────────────────────────────────────── #}
<div class="report-head">
<h2 id="reportPerson">{{ form.person or 'Bez podanego imienia' }}</h2>
<p class="muted small">
{{ form.date }} {{ form.time }} · offset {{ form.tz_offset }} h względem GMT ·
{{ '%.4f'|format(form.lat|float) }}, {{ '%.4f'|format(form.lon|float) }}
{% if moment %}· {{ moment }}{% endif %}
</p>
</div>
{# ── 2. Kosmogram ─────────────────────────────────────────────────── #}
{% if wheel_svg %}
<figure class="wheel-fig">
{{ wheel_svg | safe }}
<figcaption class="muted small">Kosmogram — kliknij, aby powiększyć.</figcaption>
</figure>
{% endif %}
{% if aspectarian_svg %}
<figure class="aspectarian-fig">
{{ aspectarian_svg | safe }}
<figcaption class="muted small">Aspektarian — siatka aspektów (PRE-18).</figcaption>
</figure>
{% endif %}
{% if declination_svg %}
<figure class="declination-fig">
{{ declination_svg | safe }}
<figcaption class="muted small">Wykres deklinacji — paralele i out-of-bounds (LOG-07).</figcaption>
</figure>
{% endif %}
{% if antiscia_svg %}
<figure class="antiscia-fig">
{{ antiscia_svg | safe }}
<figcaption class="muted small">Oś antyscji — odbicia względem przesileń i równonocy (LOG-07).</figcaption>
</figure>
{% endif %}
{# ── 3. Dane policzone ────────────────────────────────────────────── #}
{# WSPÓLNE tabele (ten sam plik co „Horoskop") — podsumowanie pokazuje dokładnie
to, co policzono: porównanie domów, stacje, żywioły itd., jeśli były wybrane. #}
<div class="meta">Horoskop · silnik {{ result.engine }}
{% if result.house_system %}· domy {{ result.house_system }}{% endif %}
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
{% include "_result_tables.html" %}
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
<div id="reportNatal"></div>
<div id="reportPredictions"></div>
{% endif %}
{# 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>
{% endblock %}
@@ -6,12 +6,6 @@
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
<form method="post" action="/interpret">
<div class="grid">
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
value="{{ form.person or '' }}">
</label>
</div>
<div class="grid">
<label>Data
<input type="date" name="date" value="{{ form.date or '' }}" required>
@@ -19,9 +13,8 @@
<label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required>
</label>
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h)
<input type="number" name="tz_offset" step="0.25" min="-12" max="14"
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
<label>Strefa (offset h)
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label>
<label>Szerokość (lat)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
@@ -36,11 +29,9 @@
</div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
{% include "_prompt_block.html" %}
<p class="muted small" id="natalNote"></p>
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit" name="action" value="report">Szukaj interpretacji</button>
<button type="submit" name="action" value="export" class="ghost" title="Pobierz dopasowania jako tabelę roboczą .xlsx — do filtrowania, sortowania i zaznaczania w Excelu (PRE-10).">Pobierz Excel</button>
<button type="submit" name="action" value="prompt" class="ghost">Generuj prompt (AI)</button>
<button type="submit" name="action" value="horoscope">Napisz horoskop (AI)</button>
<span id="geoNote" class="muted small"></span>
@@ -97,10 +88,6 @@
{% 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>
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
<script src="{{ static('natal.js') }}"></script>
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
{% endblock %}
@@ -1,51 +0,0 @@
{% 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 %}
@@ -1,73 +0,0 @@
{% 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 %}
@@ -6,12 +6,6 @@
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
<form method="post" action="/timeline">
<div class="grid">
<label title="Trafia do nagłówka raportu PDF (PRE-24). Nie jest nigdzie wysyłane poza aplikację ani zapisywane na serwerze.">Imię i nazwisko
<input type="text" name="person" autocomplete="name" placeholder="np. Jan Kowalski"
value="{{ form.person or '' }}">
</label>
</div>
<div class="grid">
<label>Data urodzenia
<input type="date" name="date" value="{{ form.date or '' }}" required>
@@ -19,9 +13,8 @@
<label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required>
</label>
<label title="Przesunięcie czasu lokalnego względem GMT/UTC. Krok 15 minut (0,25 h) — bo część stref ma przesunięcie o pół lub ćwierć godziny, np. Indie +5:30, Nepal +5:45.">Offset względem GMT (h)
<input type="number" name="tz_offset" step="0.25" min="-12" max="14"
value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
<label>Strefa (offset h)
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label>
<label>Szerokość (lat)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
@@ -85,16 +78,6 @@
{% endfor %}
{% endif %}
{# Zapamiętane predykcje okresowe (PRE-22) — materiał dla zakładki „Skompiluj" #}
<section id="predictionsBox">
<div id="savedPredictions"></div>
<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>
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
<script src="{{ static('predictions.js') }}"></script>
<script src="/static/now.js"></script>
<script src="/static/copy.js"></script>
{% endblock %}
-68
View File
@@ -1,68 +0,0 @@
"""Strefa czasowa z lokalizacji (PRE-03) — „logika dwóch lokalizacji".
Cel po ludzku: żeby użytkownik nie musiał znać offsetu GMT ani pamiętać o czasie
letnim. Z dokładnych współrzędnych bierzemy strefę IANA (`tzfpy`, OFFLINE bez
zapytań do sieci, więc działa też bez internetu), a z niej offset DLA DATY
URODZENIA. `zoneinfo` zna reguły historyczne i DST, więc np. Kraków 1984 to +1h
zimą, +2h latem, a Katmandu to +5:45.
Sedno PRE-03: offset ustalamy RAZ i trzymamy jako stałą liczbę w formularzu
przeliczenie dla dokładnej lokalizacji już nim nie rusza. Dzięki temu drobna
zmiana współrzędnych nie przerzuca strefy ani czasu letniego i nie przeskakuje"
Ascendenta na sąsiedni znak. Większa miejscowość" z wymagania jest zbędna:
strefa IANA jest i tak regionalna, więc wioska daje samą strefę co pobliskie
miasto.
"""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try: # zależność opcjonalna — brak biblioteki
from tzfpy import get_tz # nie może wywalić strony, tylko wyłącza podpowiedź
except Exception: # pragma: no cover
get_tz = None
def _fmt_offset(hours: float) -> str:
sign = "+" if hours >= 0 else ""
total = int(round(abs(hours) * 60))
return f"{sign}{total // 60}:{total % 60:02d}"
def resolve(lat: float, lon: float, date: str, time: str = "12:00") -> dict | None:
"""(lat, lon, data lokalna, godzina lokalna) → {tz, offset, dst, label}.
Zwraca None, gdy strefy nie da się ustalić (brak biblioteki, punkt bez strefy,
niepoprawna data) wtedy zostaje ręczny offset, nic się nie psuje.
`offset` jest w godzinach (np. 5.75 dla +5:45), DST-świadomy dla podanej daty.
"""
if get_tz is None:
return None
try:
tz = get_tz(float(lon), float(lat)) # UWAGA: tzfpy przyjmuje (lon, lat)
except Exception:
return None
if not tz:
return None
try:
zone = ZoneInfo(tz)
except (ZoneInfoNotFoundError, ValueError):
return None
# Wall-time w tej strefie dla podanej daty → offset i czy czas letni jest aktywny.
try:
local = datetime.fromisoformat(f"{date}T{time or '12:00'}")
except ValueError:
try:
local = datetime.fromisoformat(date).replace(hour=12)
except ValueError:
return None
local = local.replace(tzinfo=zone)
off = local.utcoffset()
if off is None:
return None
hours = round(off.total_seconds() / 3600.0, 2)
dst = bool(local.dst())
label = f"{tz} · {_fmt_offset(hours)}" + (" (czas letni)" if dst else "")
return {"tz": tz, "offset": hours, "dst": dst, "label": label}
@@ -1,2 +0,0 @@
-r requirements.txt
pytest>=8.0
-9
View File
@@ -3,12 +3,3 @@ uvicorn[standard]>=0.34
httpx>=0.28
jinja2>=3.1
python-multipart>=0.0.20
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
# Strefa czasowa z lokalizacji (PRE-03): współrzędne → strefa IANA (offline, lekki
# wheel Rust), a offset/DST liczy stdlib zoneinfo. tzdata na wypadek slim-obrazu
# bez systemowej bazy stref.
tzfpy>=0.15
tzdata>=2024.1
# Eksport wyników do Excela — „tabela robocza" (DAN-23/PRE-10)
openpyxl>=3.1
@@ -1,45 +0,0 @@
"""Generator wpisu do APP_USERS — konta imienne (PRE-17).
Hasło podajesz interaktywnie (nie w argumencie), żeby nie zostało w historii
powłoki ani w liście procesów. Na wyjściu dostajesz gotowy wpis `login:hash`
do zmiennej APP_USERS samo hasło nigdzie nie jest zapisywane.
python scripts/make_user.py alicja
Kilka kont sklejasz przecinkiem:
APP_USERS='alicja:scrypt$…,bartek:scrypt$…'
Odebranie dostępu jednej osobie = usunięcie jej wpisu (reszta nie zmienia haseł).
"""
import getpass
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from app.security import hash_password # noqa: E402
def main() -> int:
if len(sys.argv) != 2:
print(__doc__)
return 2
login = sys.argv[1].strip()
if not login or ":" in login or "," in login:
print("Login nie może być pusty ani zawierać ':' lub ',' (rozdzielają wpisy).")
return 2
first = getpass.getpass(f"Hasło dla '{login}': ")
if not first:
print("Puste hasło — przerwane.")
return 2
if first != getpass.getpass("Powtórz hasło: "):
print("Hasła się różnią — przerwane.")
return 2
print("\nDopisz do APP_USERS (wpisy po przecinku):\n")
print(f"{login}:{hash_password(first)}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,148 +0,0 @@
"""Konta imienne i dziennik audytowy (PRE-17).
Kod ochronny testujemy zachowanie, nie kształt źródła. Dwie rzeczy tu
krytyczne i CICHE, gdy się zepsują: (1) odebranie dostępu jednej osobie musi
faktycznie działać, (2) dziennik musi mówić KTO i ILE, ale NIGDY nie powielać
treści baz (byłby wtedy kolejnym nośnikiem wycieku).
"""
import base64
import logging
import pytest
from app import security
def _basic(user: str, password: str) -> dict:
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {raw}"}
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
for var in ("APP_USERS", "APP_PASSWORD", "APP_USER"):
monkeypatch.delenv(var, raising=False)
security._hits.clear() # limit żądań nie może przeciekać między testami
# ── hasła: hash zamiast jawnego tekstu ──
def test_hash_roundtrip_and_salt_is_random():
h = security.hash_password("tajne")
assert h.startswith("scrypt$")
assert security._secret_matches(h, "tajne")
assert not security._secret_matches(h, "inne")
assert security.hash_password("tajne") != h # inna sól za każdym razem
def test_broken_hash_denies_instead_of_crashing():
assert security._secret_matches("scrypt$niepoprawny", "cokolwiek") is False
def test_non_ascii_password_is_handled_not_crashing():
"""REGRESJA (błąd zastany): `secrets.compare_digest` rzuca TypeError na znakach
spoza ASCII, więc hasło z polskimi literami wywracało logowanie błędem 500
zamiast zwykłej odmowy. Porównujemy bajty."""
assert security._secret_matches("zażółć", "zażółć") is True
assert security._secret_matches("zażółć", "inne") is False
assert security._secret_matches(security.hash_password("gęślą"), "gęślą") is True
# ── konta imienne ──
def test_named_accounts_are_parsed(monkeypatch):
monkeypatch.setenv("APP_USERS", "alicja:tajne, bartek:inne")
assert set(security.accounts()) == {"alicja", "bartek"}
def test_login_with_hashed_password(monkeypatch):
monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}")
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
assert security.authenticate(_basic("alicja", "złe")["Authorization"]) is None
def test_unknown_user_is_denied(monkeypatch):
monkeypatch.setenv("APP_USERS", "alicja:tajne")
assert security.authenticate(_basic("obcy", "tajne")["Authorization"]) is None
def test_revoking_one_account_leaves_others_untouched(monkeypatch):
"""Sedno PRE-17: odcinamy jedną osobę BEZ zmiany hasła pozostałym."""
monkeypatch.setenv("APP_USERS", "alicja:tajne,bartek:inne")
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) == "bartek"
monkeypatch.setenv("APP_USERS", "alicja:tajne") # usunięto wpis Bartka
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) is None
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
def test_shared_password_still_works_alone(monkeypatch):
"""Zgodność wstecz — dotychczasowe wdrożenie nie może paść po aktualizacji."""
monkeypatch.setenv("APP_USER", "astrololo")
monkeypatch.setenv("APP_PASSWORD", "wspolne")
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) == "astrololo"
def test_named_accounts_disable_the_shared_password(monkeypatch):
"""Gdy są konta imienne, wspólne hasło MUSI przestać działać — inaczej
zostawiałoby tylne wejście bez śladu w dzienniku."""
monkeypatch.setenv("APP_USERS", "alicja:tajne")
monkeypatch.setenv("APP_USER", "astrololo")
monkeypatch.setenv("APP_PASSWORD", "wspolne")
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) is None
def test_no_accounts_means_auth_disabled(monkeypatch):
assert security.auth_enabled() is False
# ── dziennik audytowy ──
def _client():
from starlette.testclient import TestClient
from app.main import app
return TestClient(app)
def test_audit_logs_who_and_how_many(monkeypatch, caplog):
monkeypatch.setenv("APP_USERS", "alicja:tajne")
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
_client().get("/synastry", headers=_basic("alicja", "tajne"))
entry = "\n".join(r.getMessage() for r in caplog.records)
assert "user=alicja" in entry and "path=/synastry" in entry and "status=200" in entry
def test_audit_marks_failed_login_without_naming_the_account(monkeypatch, caplog):
monkeypatch.setenv("APP_USERS", "alicja:tajne")
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
_client().get("/synastry", headers=_basic("alicja", "złe"))
entry = "\n".join(r.getMessage() for r in caplog.records)
assert "status=401" in entry and "user=-" in entry
def test_audit_logger_actually_emits(monkeypatch):
"""REGRESJA: wpisy szły na INFO, ale domyślna konfiguracja uvicorna nie
obsługuje naszych loggerów dziennik istniał w kodzie i był PUSTY na żywym
serwerze. Niewidoczny dziennik jest gorszy niż jego brak (złudzenie kontroli),
więc pilnujemy, że ma własny handler i poziom pozwalający na INFO."""
security._setup_audit_logging()
assert security.audit_log.handlers, "dziennik audytowy nie ma własnego handlera"
assert security.audit_log.isEnabledFor(logging.INFO)
def test_audit_never_carries_database_content():
"""Wpis niesie WYŁĄCZNIE metadane i liczby — logi nie mogą być nośnikiem wycieku."""
src = (__import__("pathlib").Path(security.__file__)).read_text(encoding="utf-8")
fmt = src[src.index('"AUDYT'):src.index("ms=%.0f") + 10]
for forbidden in ("rows", "samples", "effect", "significator", "text", "body"):
assert forbidden not in fmt, f"format wpisu audytowego dotyka treści: {forbidden}"
def test_handlers_report_record_counts():
"""Bez liczby rekordów dziennik pokazywałby „ktoś wchodził", a nie „ktoś
wyniósł 5000 rekordów" — czyli nie wykryłby powolnego wypompowywania bazy."""
import pathlib
main = (pathlib.Path(security.__file__).parent / "main.py").read_text(encoding="utf-8")
assert main.count("security.audit_records(") >= 3 # sygnifikatory, raport, eksport
@@ -1,53 +0,0 @@
"""Konfigurowalne aspekty i orby w UI (PRE-06).
Ustawienia muszą: (1) być na obu formularzach (Horoskop i Skompiluj), (2) wędrować
między zakładkami (formsync), (3) trafiać do logiki na ekran i do PDF-a inaczej
podsumowanie liczyłoby aspekty innym orbem niż horoskop (ta sama klasa błędu co
regresja podsumowania).
"""
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje obu formularzy
MAIN = (APP / "main.py").read_text(encoding="utf-8")
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
SYNC = (APP / "static" / "formsync.js").read_text(encoding="utf-8")
COMPILE_JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
FIELDS = ("aspect_orb", "aspect_luminary_bonus", "aspect_minor")
def test_both_forms_have_aspect_settings():
"""Pola są we WSPÓLNYM pliku opcji, który oba formularze dołączają."""
for f in FIELDS:
assert f'name="{f}"' in OPTS, f"brak {f} we wspólnym pliku opcji"
assert '{% include "_form_options.html" %}' in CHART
assert '{% include "_form_options.html" %}' in COMPILE
def test_handlers_accept_and_pass_aspect_settings():
# co najmniej Horoskop i Skompiluj przekazują orb do logiki (synastria też — 3)
assert MAIN.count("aspect_orb=aspect_orb") >= 2
assert "aspect_minor: bool = Form(False)" in MAIN
def test_client_forwards_aspect_settings():
for f in FIELDS:
assert f'"{f}": {f}' in CLIENT, f"klient nie przekazuje {f}"
def test_settings_are_synced_between_tabs():
for f in FIELDS:
assert f"'{f}'" in SYNC, f"formsync nie synchronizuje {f}"
def test_pdf_payload_includes_aspect_settings():
assert "aspect_orb: v('aspect_orb')" in COMPILE_JS
assert "aspect_minor: checked('aspect_minor')" in COMPILE_JS
def test_pdf_handler_reads_aspect_settings():
assert 'aspect_orb=float(data.get("aspect_orb")' in MAIN
assert 'aspect_minor=bool(data.get("aspect_minor"))' in MAIN
@@ -1,41 +0,0 @@
"""Cache-busting plików statycznych (PRE-26).
Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL). Doklejamy
hash treści zmiana pliku zmienia URL. Regresja byłaby CICHA (użytkownik widzi
stary interfejs), więc pilnujemy strukturalnie.
"""
import os
import pathlib
import re
os.environ.pop("APP_PASSWORD", None) # import app.main bez bramki logowania
from app.main import _asset_version, static_url
TEMPLATES = pathlib.Path(__file__).resolve().parents[1] / "app" / "templates"
def test_static_url_carries_content_hash():
assert re.fullmatch(r"/static/styles\.css\?v=[0-9a-f]{8}", static_url("styles.css"))
def test_version_is_content_based_so_differs_between_files():
assert _asset_version("styles.css") != _asset_version("formsync.js")
def test_missing_file_does_not_crash():
assert static_url("nie-ma-takiego.js").endswith("?v=0")
def test_no_template_serves_raw_static_paths():
"""Żaden href/src nie może iść na surowe /static/... — omijałoby cache-busting."""
for f in TEMPLATES.glob("*.html"):
txt = f.read_text(encoding="utf-8")
assert 'src="/static/' not in txt, f"{f.name}: surowy src bez cache-bustingu"
assert 'href="/static/' not in txt, f"{f.name}: surowy href bez cache-bustingu"
def test_base_uses_helper_for_stylesheet_and_scripts():
base = (TEMPLATES / "base.html").read_text(encoding="utf-8")
assert "static('styles.css')" in base
assert "static('formsync.js')" in base
@@ -1,545 +0,0 @@
"""Kosmogram — koło horoskopowe jako SVG (PRE-12).
Sprawdzamy, że SVG jest POPRAWNY (parsuje się jako XML inaczej wstawiony do
strony rozjedzie render) i że niesie to, co ma nieść: pierścień 12 znaków, cztery
osie, numery domów i obiekty. Geometria (Asc po lewej) oraz rozsuwanie ciasnych
skupisk sprawdzone liczbowo, nie na oko.
"""
import pytest
import xml.dom.minidom as minidom
from app import chartwheel
def _sample_chart(asc=128.9, mc=5.0):
"""Minimalny wynik z warstwy logiki: osie + cuspy (whole sign) + pierścień znaków."""
signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
glyphs = ["", "", "", "", "", "", "", "", "", "", "", ""]
asc_sign = int(asc // 30)
cusps = [{"house": i + 1, "sign": signs[(asc_sign + i) % 12],
"decimal": (asc_sign * 30 + i * 30) % 360, "sign_glyph": glyphs[(asc_sign + i) % 12]}
for i in range(12)]
return {
"angles": {
"Asc": {"name": "Asc", "decimal": asc, "sign": signs[asc_sign]},
"MC": {"name": "MC", "decimal": mc},
"Dsc": {"name": "Dsc", "decimal": (asc + 180) % 360},
"IC": {"name": "IC", "decimal": (mc + 180) % 360},
},
"cusps": cusps,
"sign_glyphs": [{"sign": s, "glyph": g} for s, g in zip(signs, glyphs)],
}
def _rendered():
return chartwheel.render(_sample_chart())
def test_output_is_well_formed_xml():
"""Wstawiamy surowy SVG do strony — musi być poprawnym XML, bo inaczej
zepsuje cały dokument."""
svg = _rendered().replace("var(--line)", "#000").replace(
"var(--accent)", "#000").replace("var(--muted)", "#000")
doc = minidom.parseString(svg) # rzuci wyjątkiem, gdy błędny
assert doc.documentElement.tagName == "svg"
def test_has_twelve_sign_glyphs():
assert _rendered().count('class="glyph"') == 12
def test_has_all_four_axis_labels():
svg = _rendered()
for label in (">AC<", ">DC<", ">MC<", ">IC<"):
assert label in svg
def test_has_house_numbers_one_to_twelve():
svg = _rendered()
for h in range(1, 13):
assert f">{h}<" in svg
def test_ascendant_is_on_the_left():
"""Konwencja: Asc po lewej. Sprawdzamy oś ACDC liczbowo — punkt Asc musi mieć
x wyraźnie mniejszy od środka (220), a Dsc większy."""
chart = _sample_chart(asc=128.9)
asc = chart["angles"]["Asc"]["decimal"]
ax, ay = chartwheel._pt(asc, asc, chartwheel.R_ZOD)
dx, dy = chartwheel._pt(asc + 180, asc, chartwheel.R_ZOD)
assert ax < chartwheel._CX - 100, "Asc powinien być po lewej"
assert dx > chartwheel._CX + 100, "Dsc powinien być po prawej"
assert abs(ay - chartwheel._CY) < 1e-6, "oś AscDsc jest pozioma"
def test_longitude_increases_counterclockwise():
"""Asc+90° (II dom) ląduje na DOLE koła (y > środek, bo w SVG y rośnie w dół)."""
asc = 100.0
_, y = chartwheel._pt(asc + 90, asc, chartwheel.R_ZOD)
assert y > chartwheel._CY + 100
def test_empty_when_no_angles():
"""Silnik bez osi/domów (brak sidereal) → brak koła, nie wyjątek."""
assert chartwheel.render({"positions": []}) == ""
assert chartwheel.render({"angles": {}, "cusps": [], "sign_glyphs": []}) == ""
# ───────────────────────── rozsuwanie nakładających się obiektów ─────────
# Prawdziwe długości z horoskopu referencyjnego (1984-04-30, Kraków), w kolejności
# DEFAULT_OBJECTS. Merkury i Wenus dzieli 0,41°, Wenus i Księżyc 3,68° — bez
# rozsuwania glify rysują się jeden na drugim.
_REAL = [40.210, 31.449, 27.355, 27.767, 234.520, 282.962, 223.306,
252.811, 271.220, 210.477, 68.158, 248.158, 345.667]
def _min_gap(angles):
s = sorted(a % 360.0 for a in angles)
return min((s[(i + 1) % len(s)] - s[i]) % 360.0 for i in range(len(s)))
def test_spread_separates_real_chart_cluster():
"""REGRESJA: przy liczeniu odstępu modulo 360 przesunięcie, które przerzucało
obiekt ZA sąsiada, dawało lukę ~359,9° zamiast ujemnej algorytm uznawał, że
jest luzem, i kończył z Wenus dokładnie na Księżycu (0,3 px od siebie)."""
out = chartwheel.spread(_REAL)
assert _min_gap(out) >= chartwheel.MIN_SEP - 1e-6
def test_spread_preserves_order():
out = chartwheel.spread(_REAL)
by_new = [i for i, _ in sorted(enumerate(out), key=lambda p: p[1])]
by_old = [i for i, _ in sorted(enumerate(_REAL), key=lambda p: p[1] % 360.0)]
assert by_new == by_old
def test_spread_leaves_roomy_objects_untouched():
"""Obiekty, które nikomu nie wchodzą w drogę, mają zostać na swoim miejscu —
wykres ma kłamać jak najmniej."""
out = chartwheel.spread(_REAL)
node = _REAL.index(68.158) # Węzeł Płn. — 28° od najbliższego
assert out[node] == pytest.approx(_REAL[node], abs=1e-6)
def test_spread_handles_wrap_around_zero():
"""Skupisko na styku 0/360° też musi się rozsunąć."""
out = chartwheel.spread([358.0, 359.0, 1.0, 2.0])
assert _min_gap(out) >= chartwheel.MIN_SEP - 1e-6
def test_spread_falls_back_to_even_layout_when_impossible():
"""Gdy obiektów jest tyle, że min. odstęp się nie zmieści — rozkładamy równo,
zamiast kręcić się w pętli."""
many = [10.0] * 60 # 60 × 8° = 480° > 360°
out = chartwheel.spread(many)
assert _min_gap(out) == pytest.approx(360.0 / 60, abs=1e-6)
def test_spread_single_object_is_identity():
assert chartwheel.spread([42.0]) == [42.0]
# ─────────────────────────────── obiekty na kole ─────────────────────────
def _chart_with_objects():
chart = _sample_chart()
chart["positions"] = [
{"name": "Sun", "decimal": 40.21, "glyph": "", "direction": "D"},
{"name": "Moon", "decimal": 31.45, "glyph": "", "direction": "D"},
{"name": "Venus", "decimal": 27.77, "glyph": "", "direction": "D"},
{"name": "Mercury", "decimal": 27.36, "glyph": "", "direction": "Rx"},
]
return chart
def test_all_objects_are_drawn():
svg = chartwheel.render(_chart_with_objects())
for glyph in ("", "", "", ""):
assert glyph in svg
def test_degree_in_sign_is_labelled():
"""Stopień W ZNAKU (nie długość absolutna): Słońce 40,21° → 10 Byka."""
svg = chartwheel.render(_chart_with_objects())
assert ">10<" in svg # Słońce
assert ">1<" in svg # Księżyc (31,45 → 1 Byka)
def test_retrograde_is_marked():
svg = chartwheel.render(_chart_with_objects())
assert "" in svg
def test_no_retrograde_marker_when_all_direct():
chart = _chart_with_objects()
for p in chart["positions"]:
p["direction"] = "D"
assert "" not in chartwheel.render(chart)
def test_objects_without_longitude_are_skipped():
"""Niekompletny obiekt nie może wywalić całego rysunku."""
chart = _chart_with_objects()
chart["positions"].append({"name": "Broken", "glyph": "?"})
svg = chartwheel.render(chart)
assert svg and "" in svg
def test_empty_when_cusps_lack_longitude():
"""Starsze wyniki bez `decimal` w cuspach — degradujemy do pustego, nie sypiemy."""
chart = _sample_chart()
for c in chart["cusps"]:
del c["decimal"]
assert chartwheel.render(chart) == ""
# ───────────────────────────── linie aspektów ────────────────────────────
def _chart_with_aspects():
chart = _chart_with_objects()
chart["positions"].append({"name": "Mars", "decimal": 220.0, "glyph": "", "direction": "D"})
chart["aspects"] = [
{"obj1": "Sun", "obj2": "Mars", "aspect": "trine", "orb": 0.2, "allowed": 8.0},
{"obj1": "Moon", "obj2": "Mars", "aspect": "square", "orb": 5.0, "allowed": 8.0},
{"obj1": "Mercury", "obj2": "Venus", "aspect": "conjunction", "orb": 0.4, "allowed": 10.0},
]
return chart
def test_aspect_lines_are_drawn():
"""Każdy aspekt, którego oba obiekty mają pozycję, dostaje linię — porównujemy
TEN SAM wykres z aspektami i bez, żeby różnica była wyłącznie aspektami."""
chart = _chart_with_aspects()
with_asp = chartwheel.render(chart).count("<line")
chart_no = _chart_with_aspects()
chart_no["aspects"] = []
without = chartwheel.render(chart_no).count("<line")
assert with_asp - without == 3
def test_aspect_color_encodes_type():
"""Niebieski = harmonijny, czerwony = napięty, zielony = koniunkcja."""
svg = chartwheel.render(_chart_with_aspects())
assert "#6aa6c9" in svg # trine — harmonijny (niebieski)
assert "#cf6a6a" in svg # square — napięty (czerwony)
assert "#8fae7a" in svg # conjunction — zielony
def test_tight_aspect_is_bold():
"""Poniżej 1° linia pogrubiona (2.0) — wprost z wymagania. Szeroki aspekt
(square, orb 5°) NIE jest pogrubiony."""
svg = chartwheel.render(_chart_with_aspects())
assert 'stroke-width="2.0"' in svg # SunMars trine 0.2° i MercuryVenus 0.4°
# dwa ciasne aspekty → dwie pogrubione linie
assert svg.count('stroke-width="2.0"') == 2
def test_aspect_uses_true_position_not_spread():
"""Linia aspektu łączy PRAWDZIWE pozycje. Mercury(27.36) i Venus(0.41° od
siebie) rozsuwane jako glify, ale linia koniunkcji ma iść między ich
faktycznymi punktami na piaście."""
chart = _chart_with_aspects()
asc = chart["angles"]["Asc"]["decimal"]
# punkt Wenus na piaście z prawdziwej długości
vx, vy = chartwheel._pt(27.77, asc, chartwheel.R_HUB)
svg = chartwheel.render(chart)
assert f'x1="{vx:.2f}"' in svg or f'x2="{vx:.2f}"' in svg
def test_aspect_to_missing_object_is_skipped():
"""Aspekt do obiektu bez pozycji nie może wywalić rysunku."""
chart = _chart_with_aspects()
chart["aspects"].append(
{"obj1": "Sun", "obj2": "Chiron", "aspect": "trine", "orb": 1.0, "allowed": 8.0})
svg = chartwheel.render(chart)
assert svg and "" in svg
def test_no_aspect_lines_without_aspects():
chart = _chart_with_objects() # bez klucza 'aspects'
a = chartwheel.render(chart).count("<line")
chart["aspects"] = []
assert chartwheel.render(chart).count("<line") == a
def test_print_theme_aspect_colors_have_no_css_vars():
"""Motyw druku (PDF) — konkretne kolory aspektów, bez zmiennych CSS."""
svg = chartwheel.render(_chart_with_aspects(), theme="print")
assert "var(--" not in svg
assert "#a83232" in svg # square napięty w wariancie druku
# ─────────────────────────── etap 4: dopracowanie ────────────────────────
def test_objects_have_hover_tooltips():
"""Najechanie na glif pokazuje nazwę, pozycję, dom i retrogradację (natywny
<title> w SVG bez JS-a)."""
chart = _chart_with_objects()
for p in chart["positions"]: # fixture jest minimalny; dodaj etykiety
p["in_sign"] = "Tau 10°"
p["house"] = 9
svg = chartwheel.render(chart)
assert "<title>" in svg
assert "Sun · Tau 10° · dom 9" in svg # nazwa + pozycja + dom w dymku
def test_retrograde_object_says_so_in_tooltip():
svg = chartwheel.render(_chart_with_objects()) # Mercury jest Rx
assert "retrogradacja" in svg
def test_aspect_lines_have_polish_tooltips():
svg = chartwheel.render(_chart_with_aspects())
assert "trygon" in svg # SunMars trine
assert "kwadratura" in svg # MoonMars square
assert "orb" in svg
def test_part_of_fortune_is_drawn():
"""Fortuna (⊗) na kole — jedyny Lot ze standardowym glifem."""
chart = _chart_with_objects()
chart["lots"] = [
{"name": "Fortune", "longitude": 120.14, "glyph": "", "in_sign": "Leo 0°", "house": 1},
{"name": "Spirit", "longitude": 137.66, "glyph": None, "in_sign": "Leo 17°"},
]
svg = chartwheel.render(chart)
assert "" in svg
assert "Fortune · " in svg # dymek Lota
def test_lots_without_glyph_are_skipped():
"""Loty bez standardowego symbolu (Spirit, Eros…) nie zaśmiecają koła."""
chart = _chart_with_objects()
chart["lots"] = [{"name": "Spirit", "longitude": 137.66, "glyph": None}]
before = chartwheel.render(chart).count("<text")
chart["lots"] = []
after = chartwheel.render(chart).count("<text")
assert before == after # Spirit nic nie dorysował
def test_cusp_degrees_shown_for_quadrant_hidden_for_whole_sign():
"""Whole sign: cuspy na 0° znaku → pomijamy zbędne zera. System kwadratowy:
stopnie realnie coś mówią, więc je pokazujemy."""
whole = _sample_chart() # cuspy na 0/30/60…
assert chartwheel.render(whole).count('font-size="6.5"') == 0
quad = _sample_chart()
for i, c in enumerate(quad["cusps"]): # przesuń cuspy poza granice znaków
c["decimal"] = (c["decimal"] + 12.5) % 360.0
assert chartwheel.render(quad).count('font-size="6.5"') == 12
def test_print_theme_survives_stage4_features():
"""Motyw druku (PDF) nadal bez zmiennych CSS mimo Lotów, dymków i cuspów."""
chart = _chart_with_aspects()
chart["lots"] = [{"name": "Fortune", "longitude": 120.0, "glyph": "", "in_sign": "Leo 0°"}]
svg = chartwheel.render(chart, theme="print")
assert "var(--" not in svg
assert "" in svg and "<title>" in svg
# ─────────────────────────── etap 5: aspektarian (PRE-18) ─────────────────
def test_aspectarian_is_well_formed_xml():
"""Aspektarian też wstawiamy surowo do strony — musi być poprawnym XML."""
svg = chartwheel.render_aspectarian(_chart_with_aspects())
svg = (svg.replace("var(--line)", "#000").replace("var(--accent)", "#000")
.replace("var(--muted)", "#000").replace("var(--ink)", "#000"))
doc = minidom.parseString(svg)
assert doc.documentElement.tagName == "svg"
def test_aspectarian_triangular_grid_has_right_cell_count():
"""N obiektów → przekątna (N komórek) + dolny-lewy trójkąt (N·(N1)/2).
Fixture ma 5 obiektów: 5 + 10 = 15 komórek."""
svg = chartwheel.render_aspectarian(_chart_with_aspects())
assert svg.count("<rect") == 15
def test_aspectarian_puts_object_glyphs_on_diagonal():
"""Każdy obiekt raz — na przekątnej."""
svg = chartwheel.render_aspectarian(_chart_with_aspects())
for glyph in ("", "", "", "", ""):
assert glyph in svg
def test_aspectarian_shows_aspect_glyphs():
"""Aspekty z fixture: trygon (△), kwadratura (□), koniunkcja (☌)."""
svg = chartwheel.render_aspectarian(_chart_with_aspects())
assert "" in svg and "" in svg and "" in svg
def test_aspectarian_colors_match_the_wheel():
"""Te same barwy co linie na kole: niebieski/czerwony/zielony."""
svg = chartwheel.render_aspectarian(_chart_with_aspects())
assert "#6aa6c9" in svg # trine — harmonijny
assert "#cf6a6a" in svg # square — napięty
assert "#8fae7a" in svg # conjunction — zielony
def test_aspectarian_tight_aspect_is_bold():
"""Orb < 1° → glif pogrubiony (jak pogrubiona linia na kole). Dwa ciasne
aspekty w fixture (SunMars 0.2°, MercuryVenus 0.4°) dwa pogrubienia."""
svg = chartwheel.render_aspectarian(_chart_with_aspects())
assert svg.count('font-weight="bold"') == 2
def test_aspectarian_cells_have_tooltips():
svg = chartwheel.render_aspectarian(_chart_with_aspects())
assert "<title>" in svg
assert "trygon" in svg and "orb" in svg
def test_aspectarian_tooltip_shows_applying_separating():
"""Gdy aspekt niesie kierunek (A/S), dymek to mówi po polsku."""
chart = _chart_with_aspects()
chart["aspects"][0]["as"] = "A" # SunMars aplikacyjny
svg = chartwheel.render_aspectarian(chart)
assert "aplikacyjny" in svg
def test_aspectarian_empty_for_fewer_than_two_objects():
chart = _chart_with_objects()
chart["positions"] = chart["positions"][:1]
assert chartwheel.render_aspectarian(chart) == ""
def test_aspectarian_renders_grid_even_without_aspects():
"""Pusta siatka też niesie informację (które pary NIE mają aspektu) —
rysujemy przekątną z obiektami, bez glifów aspektów."""
chart = _chart_with_objects()
chart["aspects"] = []
svg = chartwheel.render_aspectarian(chart)
assert svg and "" in svg
for asp_glyph in ("", "", "", "", ""):
assert asp_glyph not in svg
def test_aspectarian_object_without_glyph_is_safe():
"""Niekompletny obiekt (bez glifu) nie wywala siatki."""
chart = _chart_with_aspects()
chart["positions"].append({"name": "Chiron", "decimal": 100.0}) # brak glyph
svg = chartwheel.render_aspectarian(chart)
assert svg and "" in svg
def test_aspectarian_print_theme_has_no_css_vars():
"""Motyw druku (PDF) — konkretne kolory, żaden `var(--…)`."""
svg = chartwheel.render_aspectarian(_chart_with_aspects(), theme="print")
assert "var(--" not in svg
assert "#a83232" in svg # square napięty w wariancie druku
# ─────────────── etap 6: deklinacja i antyscja (LOG-07) ───────────────────
def _chart_with_declination():
chart = _chart_with_objects() # Sun/Moon/Venus/Mercury
chart["obliquity"] = 23.44
decs = {"Sun": 15.0, "Moon": 15.2, "Venus": -15.0, "Mercury": 25.5}
for p in chart["positions"]:
p["declination"] = decs[p["name"]]
if p["name"] == "Mercury":
p["out_of_bounds"] = True # 25.5° > ε=23.44 → OOB
chart["parallels"] = [
{"obj1": "Sun", "obj2": "Moon", "type": "parallel",
"orb": 0.2, "allowed": 1.0, "dec1": 15.0, "dec2": 15.2},
{"obj1": "Sun", "obj2": "Venus", "type": "contraparallel",
"orb": 0.0, "allowed": 1.0, "dec1": 15.0, "dec2": -15.0},
]
return chart
def test_declination_is_well_formed_xml():
svg = chartwheel.render_declination(_chart_with_declination())
svg = (svg.replace("var(--line)", "#000").replace("var(--accent)", "#000")
.replace("var(--muted)", "#000").replace("var(--ink)", "#000"))
assert minidom.parseString(svg).documentElement.tagName == "svg"
def test_declination_empty_for_fewer_than_two():
chart = _chart_with_declination()
chart["positions"] = chart["positions"][:1]
assert chartwheel.render_declination(chart) == ""
def test_declination_draws_equator_and_tropics():
"""Równik (0°) i dwie linie zwrotników (kreskowane) muszą być."""
svg = chartwheel.render_declination(_chart_with_declination())
assert ">0°<" in svg
assert svg.count("stroke-dasharray") >= 2 # ±ε kreskowane
def test_declination_object_glyphs_present():
svg = chartwheel.render_declination(_chart_with_declination())
for g in ("", "", "", ""):
assert g in svg
def test_declination_oob_object_is_highlighted():
"""Merkury poza zwrotnikami (OOB) w kolorze wyróżnienia + z „OOB" w dymku."""
svg = chartwheel.render_declination(_chart_with_declination())
assert "#ff9b6a" in svg # kolor retro/OOB (motyw ekranowy)
assert "OOB" in svg
def test_declination_parallel_and_contraparallel_colors():
"""Paralela jak koniunkcja (zielony), kontrparalela jak opozycja (czerwony)."""
svg = chartwheel.render_declination(_chart_with_declination())
assert "#8fae7a" in svg # parallel → zielony
assert "#cf6a6a" in svg # contraparallel → czerwony
assert "paralela" in svg # dymek
def test_declination_print_theme_has_no_css_vars():
svg = chartwheel.render_declination(_chart_with_declination(), theme="print")
assert "var(--" not in svg
def _chart_with_antiscia():
chart = _chart_with_objects()
chart["antiscia"] = [
{"obj1": "Sun", "obj2": "Moon", "type": "antiscion", "orb": 0.3, "allowed": 1.0},
{"obj1": "Venus", "obj2": "Mercury", "type": "contra_antiscion", "orb": 0.5, "allowed": 1.0},
]
return chart
def test_antiscia_is_well_formed_xml():
svg = chartwheel.render_antiscia(_chart_with_antiscia())
svg = (svg.replace("var(--line)", "#000").replace("var(--accent)", "#000")
.replace("var(--muted)", "#000").replace("var(--ink)", "#000"))
assert minidom.parseString(svg).documentElement.tagName == "svg"
def test_antiscia_empty_for_fewer_than_two():
chart = _chart_with_antiscia()
chart["positions"] = chart["positions"][:1]
assert chartwheel.render_antiscia(chart) == ""
def test_antiscia_draws_reflection_axes():
"""Oś przesileń (i równonocy) — linie kreskowane."""
svg = chartwheel.render_antiscia(_chart_with_antiscia())
assert svg.count("stroke-dasharray") >= 2
def test_antiscia_pair_colors_and_tooltip():
"""Antyscja jak koniunkcja (zielony łuk), kontrantyscja jak opozycja (czerwony)."""
svg = chartwheel.render_antiscia(_chart_with_antiscia())
assert "#8fae7a" in svg # antiscion → zielony
assert "#cf6a6a" in svg # contra_antiscion → czerwony
assert "antyscja" in svg and "<path" in svg
def test_antiscia_renders_axis_even_without_pairs():
"""Bez par (ciasny orb) oś i obiekty i tak coś mówią."""
chart = _chart_with_objects()
chart["antiscia"] = []
svg = chartwheel.render_antiscia(chart)
assert svg and "stroke-dasharray" in svg and "" in svg
def test_antiscia_print_theme_has_no_css_vars():
svg = chartwheel.render_antiscia(_chart_with_antiscia(), theme="print")
assert "var(--" not in svg
@@ -1,158 +0,0 @@
"""Niezmiennik: KAŻDE wyjście HTTP w dół niesie token międzywarstwowy (LOG-32)
oraz klucz szyfrujący łącze (PRE-16).
Powód istnienia tego testu: token dodano do klienta na gałęzi, która odbiła się od
mastera zanim powstały metody `prompt()` i `horoscope()`. Git zmergował obie zmiany
czysto (różne linie), ale nowe metody wyszły BEZ tokenu i dostawały 401 dopiero na
produkcji. Zwykły test jednej metody by tego nie złapał, więc sprawdzamy regułę
strukturalnie.
Po dołożeniu szyfrowania ta sama klasa błędu ma gorszy objaw: wywołanie bez `link=`
nie wywala się widocznie, tylko po cichu wysyła treść JAWNYM tekstem. Dlatego dla
wywołań przez `link_crypto` wymagamy obu argumentów naraz.
"""
import ast
import pathlib
CLIENT = pathlib.Path(__file__).resolve().parents[1] / "app" / "clients" / "logic_client.py"
HTTP_VERBS = ("post", "get", "put", "patch", "delete")
# Wszystkie drogi w dół przez link_crypto — także strumień postępu (stream_lines),
# który dołożono po scaleniu okna postępu z szyfrowaniem. Każda musi nieść i token,
# i klucz łącza.
LINK_CALLS = ("call", "call_json", "stream_lines")
def _http_calls(path: pathlib.Path) -> list[tuple[str, int, bool, bool]]:
"""(opis, linia, czy_ma_headers, czy_wymaga_i_ma_link) dla każdego wyjścia w dół."""
tree = ast.parse(path.read_text(encoding="utf-8"))
out = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
target = node.func.value
has_headers = any(kw.arg == "headers" for kw in node.keywords)
has_link = any(kw.arg == "link" for kw in node.keywords)
if (node.func.attr in HTTP_VERBS
and isinstance(target, ast.Name) and target.id == "client"):
# Surowe wywołanie httpx — omija szyfrowanie, więc dopuszczalne tylko
# dla ścieżek wyjętych spod niego (patrz link_crypto.PUBLIC_PATHS).
out.append((f"client.{node.func.attr}()", node.lineno, has_headers, True))
elif (node.func.attr in LINK_CALLS
and isinstance(target, ast.Name) and target.id == "link_crypto"):
out.append((f"link_crypto.{node.func.attr}()", node.lineno, has_headers, has_link))
return out
def test_client_module_exists():
assert CLIENT.is_file()
def test_every_outbound_call_sends_auth_header():
calls = _http_calls(CLIENT)
assert calls, "nie znaleziono żadnego wywołania HTTP — test przestał cokolwiek pilnować"
missing = [f"{CLIENT.name}:{line} {what}" for what, line, ok, _ in calls if not ok]
assert not missing, (
"Wywołania w dół bez tokenu międzywarstwowego (dostaną 401 przy włączonej "
"ochronie): " + ", ".join(missing)
)
def test_every_outbound_call_passes_link_key():
"""Brak `link=` nie boli od razu — po prostu treść leci jawnym tekstem."""
calls = _http_calls(CLIENT)
missing = [f"{CLIENT.name}:{line} {what}" for what, line, _, ok in calls if not ok]
assert not missing, (
"Wywołania w dół bez klucza łącza — poszłyby NIEZASZYFROWANE: " + ", ".join(missing)
)
def test_raw_http_calls_only_on_paths_exempt_from_encryption():
"""Surowe `client.get/post` wolno wołać wyłącznie tam, gdzie szyfrowania nie ma
z założenia (`/health` dla sond k8s). Każde inne to obejście PRE-16."""
import re
from app import link_crypto
source = CLIENT.read_text(encoding="utf-8").splitlines()
offenders = []
for what, line, _, _ in _http_calls(CLIENT):
if not what.startswith("client."):
continue
url = re.search(r'f"\{self\.base_url\}([^"]*)"', source[line - 1])
if url is None or url.group(1) not in link_crypto.PUBLIC_PATHS:
offenders.append(f"{CLIENT.name}:{line} {what}")
assert not offenders, (
"Surowe wywołania HTTP poza ścieżkami wyjętymi spod szyfrowania: "
+ ", ".join(offenders)
)
def test_auth_headers_helper_is_lazy():
"""Token czytany przy wywołaniu, nie przy imporcie — inaczej pod wystartowałby
z pustym tokenem, gdyby zmienna pojawiła się później."""
import os
from app.clients.logic_client import _auth_headers
old = os.environ.get("INTERNAL_TOKEN")
try:
os.environ["INTERNAL_TOKEN"] = "abc"
assert _auth_headers() == {"X-Astrololo-Token": "abc"}
os.environ.pop("INTERNAL_TOKEN")
assert _auth_headers() == {} # ochrona wyłączona = brak nagłówka
finally:
if old is not None:
os.environ["INTERNAL_TOKEN"] = old
else:
os.environ.pop("INTERNAL_TOKEN", None)
# --------------------------------------------- wybor modelu musi dojsc do logiki
# Ta sama klasa bledu co przy tokenie: dokladajac nowa sciezke latwo zapomniec
# przekazac parametr, a objaw (cichy powrot do modelu domyslnego) jest niewidoczny.
def _calls_to(path_fragment: str) -> list[int]:
"""Linie wywolan logic.<metoda>(...) w handlerach prezentacji."""
main = CLIENT.parent.parent / "main.py"
tree = ast.parse(main.read_text(encoding="utf-8"))
out = []
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr == path_fragment
and isinstance(node.func.value, ast.Name) and node.func.value.id == "logic"):
out.append(node.lineno)
return out
def _has_kwarg(main_src: str, lineno: int, name: str) -> bool:
tree = ast.parse(main_src)
for node in ast.walk(tree):
if isinstance(node, ast.Call) and node.lineno == lineno:
return any(kw.arg == name for kw in node.keywords)
return False
def test_every_llm_call_passes_selected_model():
main = CLIENT.parent.parent / "main.py"
src = main.read_text(encoding="utf-8")
missing = []
for method in ("prompt", "horoscope"):
for line in _calls_to(method):
if not _has_kwarg(src, line, "model"):
missing.append(f"main.py:{line} logic.{method}()")
assert not missing, (
"Wywołania bez wybranego modelu — po cichu użyją domyślnego: " + ", ".join(missing)
)
def test_every_llm_call_passes_provider():
main = CLIENT.parent.parent / "main.py"
src = main.read_text(encoding="utf-8")
missing = []
for method in ("prompt", "horoscope"):
for line in _calls_to(method):
if not _has_kwarg(src, line, "provider"):
missing.append(f"main.py:{line} logic.{method}()")
assert not missing, "Wywołania bez dostawcy: " + ", ".join(missing)
-197
View File
@@ -1,197 +0,0 @@
"""Zakładka „Skompiluj" — zbiorczy raport (PRE-23).
Raport składa się z trzech kawałków liczonych w różnych miejscach: horoskopu
(serwer), interpretacji natalnej i predykcji okresowych (magazyn przeglądarki).
Pilnujemy, żeby zakładka umiała je zebrać, a przede wszystkim żeby POWIEDZIAŁA,
czego brakuje. Niekompletny raport, o którym dowiadujesz się dopiero z PDF-a,
byłby najgorszym wynikiem.
"""
import pathlib
from starlette.testclient import TestClient
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TPL = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
NATAL_JS = (APP / "static" / "natal.js").read_text(encoding="utf-8")
INTERPRET = (APP / "templates" / "interpret.html").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
def _client():
import os
os.environ.pop("APP_PASSWORD", None) # bez bramki logowania w teście
from app.main import app
return TestClient(app)
# ───────────────────────────────── nawigacja i trasa ─────────────────────
def test_tab_is_in_the_menu():
assert 'href="/compile"' in BASE and "Skompiluj" in BASE
def test_get_renders_empty_form():
r = _client().get("/compile")
assert r.status_code == 200
assert "Złóż podsumowanie" in r.text
def test_routes_exist():
assert '@app.get("/compile"' in MAIN and '@app.post("/compile"' in MAIN
# ─────────────────────────── kolejność w raporcie ────────────────────────
def test_report_order_matches_the_brief():
"""Prośba partnerów: najpierw imię i nazwisko, potem wprowadzone dane, potem
RYSUNEK kosmogramu, a dopiero po nim interpretacja natalna i predykcje."""
person = TPL.index('id="reportPerson"')
wheel = TPL.index("wheel_svg")
natal = TPL.index('id="reportNatal"')
preds = TPL.index('id="reportPredictions"')
assert person < wheel < natal < preds
def test_person_name_heads_the_report():
assert 'id="reportPerson"' in TPL
assert "form.person" in TPL
# ───── wszystkie rysunki w raporcie i w PDF (nie tylko koło) ─────
# Zasada: co pokazujemy na stronie, ma trafić do podsumowania i do PDF-a.
def test_report_shows_every_chart_figure():
"""Raport «Skompiluj» pokazuje wszystkie rysunki, nie tylko koło."""
for cls in ("wheel-fig", "aspectarian-fig", "declination-fig", "antiscia-fig"):
assert cls in TPL, f"brak {cls} w raporcie"
def test_compile_page_renders_all_figures():
"""Handler /compile liczy wszystkie rysunki do podglądu raportu."""
for var in ("aspectarian_svg", "declination_svg", "antiscia_svg"):
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}"
# ──────────────────────── zbieranie materiału z magazynów ────────────────
def test_reads_stores_through_their_api_not_raw_storage():
"""Format danych ma jednego właściciela — moduł, który je zapisuje.
Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie."""
assert "window.astrololoNatal" in JS
assert "window.astrololoPredictions" in JS
# chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu)
assert "localStorage.getItem" not in JS
assert "localStorage.setItem" not in JS
def test_stores_are_loaded_on_the_tab():
for src in ("natal.js", "predictions.js", "compile.js"):
assert src in TPL, f"brak {src} na zakładce"
def test_stores_load_before_the_assembler():
"""Szukamy TAGÓW skryptów, nie samych nazw — te padają też w komentarzach.
Statyka jedzie przez helper cache-bustingu (PRE-26): src="{{ static('X') }}"."""
def tag(name):
return TPL.index("static('" + name + "')")
assert tag("natal.js") < tag("compile.js")
assert tag("predictions.js") < tag("compile.js")
# ─────────────────────────── mówi, czego brakuje ─────────────────────────
def test_shows_what_is_missing():
assert 'id="readiness"' in TPL
assert "rdy-missing" in JS
for hint in ("Interpretacje", "Kalendarz"):
assert hint in JS, f"brak podpowiedzi, gdzie uzupełnić: {hint}"
def test_all_three_parts_are_checked():
for label in ("Policzony horoskop", "Interpretacja natalna", "Predykcje okresowe"):
assert label in JS
# ───────────────────────────── natalna zapamiętywana ─────────────────────
def test_natal_is_captured_on_interpret_tab():
assert "natal.js" in INTERPRET
assert 'id="natalNote"' in INTERPRET
def test_natal_store_keeps_a_single_entry():
"""Natalna jest JEDNA — dotyczy momentu urodzenia, nie okresu. Ponowne
wygenerowanie ma podmienić, nie dokładać."""
assert "setItem" in NATAL_JS and "push" not in NATAL_JS
def test_natal_only_stores_natal_profile():
assert "'natal'" in NATAL_JS
def test_full_storage_is_reported():
assert "pełny" in NATAL_JS
# ─────────────────────────────── bezpieczeństwo ──────────────────────────
def test_ai_text_is_escaped_before_injection():
"""Treść od modelu wstawiamy do DOM — bez ucieczki byłby to wektor wstrzyknięcia."""
assert "function esc" in JS
assert "replace(/</g, '&lt;')" in JS
def test_does_nothing_on_other_tabs():
assert "if (!document.getElementById('readiness')) return;" in JS
# ─────── podsumowanie NIE gubi opcji policzonych przy horoskopie ──────────
# Regresja 2026-07-28: „Skompiluj" przeliczało horoskop bez stacji/tabel/
# porównania domów, więc podsumowanie miało braki względem tego, co policzono.
def test_compile_form_carries_the_options():
"""Te same opcje co „Horoskop" — z WSPÓLNEGO pliku (formsync je synchronizuje)."""
assert '{% include "_form_options.html" %}' in TPL
opts = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8")
for f in ('name="stations"', 'name="tables"', 'name="house_systems"'):
assert f in opts, f"brak opcji we wspólnym pliku: {f}"
def test_compile_handler_recomputes_with_all_options():
assert "house_systems: list[str] = Form([])" in MAIN
assert "stations: bool = Form(False)" in MAIN
assert "house_systems=house_systems" in MAIN # przekazane do logiki
assert "stations=stations, zodiac=zodiac, tables=tables" in MAIN
def test_compile_pdf_recomputes_with_all_options():
assert 'stations=bool(data.get("stations"))' in MAIN
assert 'house_systems=[s for s in (data.get("house_systems")' in MAIN
def test_compile_pdf_payload_includes_options():
assert "house_systems: multi('house_systems')" in JS
assert "stations: checked('stations')" in JS
def test_summary_uses_shared_result_tables():
"""Podsumowanie pokazuje te same tabele co horoskop — jeden wspólny plik,
żeby nie mogły znowu się rozjechać."""
assert '{% include "_result_tables.html" %}' in TPL
@@ -1,103 +0,0 @@
"""Wspólne dane formularza między zakładkami + pole imienia (PRE-21).
Znów test strukturalny na źródle: synchronizacja żyje w JS, a jej awaria jest
CICHA formularz po prostu przestaje pamiętać dane i użytkownik wpisuje je od
nowa. Pilnujemy więc reguł, które łatwo zgubić przy kolejnych zmianach.
"""
import pathlib
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TEMPLATES = APP / "templates"
SYNC_JS = (APP / "static" / "formsync.js").read_text(encoding="utf-8")
GEO_JS = (APP / "static" / "geo.js").read_text(encoding="utf-8")
BASE_HTML = (TEMPLATES / "base.html").read_text(encoding="utf-8")
MAIN_PY = (APP / "main.py").read_text(encoding="utf-8")
WITH_FORM = ("chart.html", "interpret.html", "timeline.html")
# Pola, które mają wędrować między zakładkami. Gdy dojdzie nowe wspólne pole,
# ten wykaz ma się nie rozjechać z formsync.js.
SHARED = ("person", "date", "time", "tz_offset", "lat", "lon",
"house_system", "zodiac", "place",
"stations", "tables", "house_systems") # opcje też wędrują (regresja: podsumowanie liczyło się bez nich)
def test_option_checkboxes_are_synced_by_type():
"""Stacje/tabele to checkboxy (.checked), a porównanie domów to wiele checkboxów
tej samej nazwy (lista) formsync musi umieć oba, nie tylko .value."""
assert "'check'" in SYNC_JS and "'multi'" in SYNC_JS
assert "el.checked" in SYNC_JS
# ─────────────────────────────── pole imienia ────────────────────────────
@pytest.mark.parametrize("name", WITH_FORM)
def test_person_field_present_on_every_form(name):
"""Imię i nazwisko trafia do nagłówka raportu PDF (PRE-24) — musi dać się
je wpisać z każdej zakładki, bo dane wspólne."""
html = (TEMPLATES / name).read_text(encoding="utf-8")
assert 'name="person"' in html, f"{name}: brak pola imienia"
@pytest.mark.parametrize("name", WITH_FORM)
def test_person_value_survives_submit(name):
"""Po wysłaniu formularza pole ma pokazywać to, co wpisano — inaczej znikałoby
przy każdym przeliczeniu."""
html = (TEMPLATES / name).read_text(encoding="utf-8")
assert "form.person" in html, f"{name}: wartość imienia nie wraca po POST"
def test_handlers_accept_and_echo_person():
"""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). 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
# ─────────────────────── synchronizacja między zakładkami ────────────────
@pytest.mark.parametrize("key", SHARED)
def test_every_shared_field_is_synced(key):
assert f"'{key}'" in SYNC_JS, f"pole {key} nie jest synchronizowane"
def test_sync_script_loaded_before_map_script():
"""KOLEJNOŚĆ MA ZNACZENIE: formsync w <head> z `defer` wykonuje się przed
geo.js (skrypty defer idą w kolejności dokumentu), więc mapa startuje już
z odtworzonymi współrzędnymi zamiast skakać po chwili."""
assert "formsync.js" in BASE_HTML, "formsync.js nie jest ładowany globalnie"
assert "defer" in BASE_HTML.split("formsync.js")[0].rsplit("<script", 1)[-1] + "defer"
def test_sync_reacts_to_other_browser_tab():
"""Zmiana w drugiej otwartej karcie ma dociągnąć się tutaj (zdarzenie storage)."""
assert "'storage'" in SYNC_JS
def test_sync_persists_on_submit():
assert "'submit'" in SYNC_JS
def test_corrupt_storage_does_not_break_the_page():
"""Uszkodzony wpis w localStorage nie może zablokować formularza."""
assert "catch" in SYNC_JS and "JSON.parse" in SYNC_JS
def test_map_changes_are_reported_to_sync():
"""geo.js ustawia pola z KODU (`.value = ...`), co samo z siebie nie wywołuje
zdarzeń bez jawnego `change` synchronizacja przegapiłaby wybór z mapy."""
assert "function setVal" in GEO_JS
assert "dispatchEvent(new Event('change'" in GEO_JS
# wszystkie miejsca ustawiające pola idą przez setVal
assert "latEl.value =" not in GEO_JS.replace("el.value = value", "")
assert "searchEl.value = d.name" not in GEO_JS
def test_sync_does_not_wipe_fields_missing_on_a_tab():
"""Zakładka bez danego pola (np. Sygnifikatory) nie może wyczyścić wartości
zapamiętanej z innej zakładki. `collect` pomija pole, którego nie ma na stronie."""
assert "if (!els.length) return;" in SYNC_JS
@@ -1,98 +0,0 @@
"""Porównanie systemów domów naraz (PRE-05).
JS-a tu nie ma sprawdzamy strukturalnie: formularz pozwala zaznaczyć kilka
systemów, klient przekazuje je do logiki, a szablon pokazuje kuspy obok siebie.
"""
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje
TABLES = (APP / "templates" / "_result_tables.html").read_text(encoding="utf-8") # wspólne tabele
MAIN = (APP / "main.py").read_text(encoding="utf-8")
from app.house_systems import HOUSE_SYSTEMS, LIMITED, label as house_label # noqa: E402
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
def test_both_forms_use_shared_option_partial():
"""Opcje w jednym pliku — oba formularze go dołączają (koniec duplikacji)."""
assert '{% include "_form_options.html" %}' in CHART
assert '{% include "_form_options.html" %}' in COMPILE
def _render(name: str, **ctx) -> str:
"""Renderuje szablon z tymi samymi globalsami, co produkcja.
Szablony budują listę systemów PĘTLĄ po katalogu, więc sprawdzanie ich
surowego tekstu nic już nie mówi trzeba je wykonać. Przy okazji test
łapie literówki w Jinja, których grep po źródle nigdy nie wyłapał."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True)
env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED,
house_label=house_label, static=lambda p: p)
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():
assert "house_systems: list[str] = Form([])" in MAIN
assert "house_systems=house_systems" in MAIN # przekazane do logiki
def test_client_forwards_house_systems_to_logic():
assert '"house_systems": house_systems' in CLIENT
def test_comparison_table_renders_only_for_multiple_systems():
"""Bez dodatkowych systemów nie pokazujemy pustej tabeli porównania."""
assert "result.house_systems | length > 1" in TABLES
assert "Porównanie systemów domów" in TABLES
def test_comparison_table_shows_cusp_per_system():
"""Tabela iteruje po systemach i po 12 domach — kusp w każdej komórce."""
assert "for hs in result.house_systems" in TABLES
assert "hs.cusps[i].in_sign" in TABLES
def test_checkbox_state_survives_submit():
"""Zaznaczone systemy zostają zaznaczone po przeliczeniu."""
html = _render("_form_options.html", form={"house_systems": ["koch", "campanus"]})
for value, _ in HOUSE_SYSTEMS:
marker = f'value="{value}" checked'
assert (marker in html) == (value in ("koch", "campanus")), value
def test_fallback_notice_is_shown_prominently():
"""Po fallbacku kosmogram wygląda bezbłędnie — ostrzeżenie jest jedynym
sygnałem, że podział domów jest z innego systemu."""
html = _render("_result_tables.html",
result={"house_warnings": ["UWAGA: system domów nie ma definicji"]})
assert "house-warning" in html
assert "UWAGA: system domów nie ma definicji" in html
def test_no_notice_when_nothing_was_substituted():
html = _render("_result_tables.html", result={"house_warnings": []})
assert "house-warning" not in html
@@ -1,91 +0,0 @@
"""Zapamiętane predykcje okresowe (PRE-22).
Magazyn żyje w JS (localStorage), więc znów pilnujemy go strukturalnie. Sedno:
predykcje mają PRZEŻYĆ przejście na inną zakładkę i trafić do raportu, a lista
nie może puchnąć od powtórzonych prób na tym samym okresie.
"""
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
JS = (APP / "static" / "predictions.js").read_text(encoding="utf-8")
PROGRESS = (APP / "static" / "progress.js").read_text(encoding="utf-8")
TIMELINE = (APP / "templates" / "timeline.html").read_text(encoding="utf-8")
# ─────────────────────────────── podpięcie ───────────────────────────────
def test_script_loaded_on_calendar_tab():
assert "predictions.js" in TIMELINE
def test_loaded_after_progress_script():
"""Nasłuchuje zdarzenia, które wysyła progress.js — musi być załadowany po nim,
inaczej przegapi pierwsze wywołanie."""
assert TIMELINE.index("progress.js") < TIMELINE.index("predictions.js")
def test_calendar_has_container_for_the_list():
assert 'id="savedPredictions"' in TIMELINE
def test_progress_announces_finished_horoscope():
"""Okno postępu ogłasza gotowy horoskop zdarzeniem, zamiast samo go zapisywać
to okno postępu, a nie magazyn."""
assert "astrololo:horoscope" in PROGRESS
assert "profile" in PROGRESS.split("astrololo:horoscope")[1][:200]
# ─────────────────────────────── zachowanie ──────────────────────────────
def test_only_period_horoscopes_are_stored():
"""Interpretacja natalna ma NIE trafiać na listę predykcji okresowych."""
assert "'period'" in JS
def test_same_period_replaces_instead_of_duplicating():
"""Ponowne policzenie tego samego zakresu podmienia wpis. Inaczej lista
puchłaby przy każdej próbie z innym modelem."""
assert "function upsert" in JS
assert "p.from_date === entry.from_date" in JS
assert "p.to_date === entry.to_date" in JS
def test_entry_carries_period_and_text():
for field in ("from_date", "to_date", "text", "saved_at"):
assert field in JS, f"wpis nie niesie pola {field}"
def test_incomplete_result_is_not_stored():
"""Bez tekstu albo bez dat nie ma czego zapamiętać — nie zapisujemy śmieci."""
assert "if (!text || !from || !to) return null;" in JS
def test_list_can_be_pruned():
assert "function remove" in JS
assert "data-drop" in JS
def test_full_storage_is_reported_not_swallowed():
"""Horoskopy bywają długie. Gdy magazyn się przepełni, użytkownik musi się
o tym dowiedzieć, zamiast po cichu stracić wynik."""
assert "pełny" in JS or "pelny" in JS
assert "return false;" in JS
def test_corrupt_storage_does_not_break_the_page():
assert "catch" in JS and "Array.isArray" in JS
def test_storage_is_exposed_for_the_compile_tab():
"""Zakładka „Skompiluj" (PRE-23) ma czytać to samo miejsce — jedno źródło prawdy."""
assert "window.astrololoPredictions" in JS
def test_does_nothing_on_other_tabs():
assert "if (!document.getElementById('savedPredictions')) return;" in JS
def test_reload_does_not_duplicate():
"""Wariant bez strumienia zapisuje przy wczytaniu strony — klucz to okres,
więc odświeżenie nie mnoży wpisów."""
assert "id: from + '..' + to" in JS
@@ -1,149 +0,0 @@
"""Łącze do usługi render i wariant kosmogramu do druku (PRE-24).
Przez to łącze leci CAŁY raport: dane urodzeniowe i opisy z baz. Musi być
szyfrowane tak samo jak pozostałe, WŁASNYM kluczem i musi obowiązywać ten sam
niezmiennik co dla klienta logiki: żadnego wyjścia w dół bez tokenu i klucza.
"""
import pathlib
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CLIENT = APP / "clients" / "render_client.py"
SRC = CLIENT.read_text(encoding="utf-8")
WHEEL = (APP / "chartwheel.py").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
COMPILE_JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
# ─────────────────────────── szyfrowanie łącza ───────────────────────────
def test_uses_its_own_third_key():
"""Osobny klucz na parę rozmówców. Przejęcie klucza renderu nie może otwierać
łącza do logiki ani do danych."""
from app import link_crypto
assert link_crypto.ENV_PRESENTATION_RENDER == "LINK_KEY_PRESENTATION_RENDER"
assert link_crypto.ENV_PRESENTATION_RENDER != link_crypto.ENV_PRESENTATION_LOGIC
assert link_crypto.ENV_PRESENTATION_RENDER != link_crypto.ENV_LOGIC_DATA
assert "ENV_PRESENTATION_RENDER" in SRC
def test_report_goes_through_the_encrypted_link():
assert "link_crypto.call(" in SRC
assert "link=_link()" in SRC
def test_every_outbound_call_carries_token_and_key():
"""Ten sam niezmiennik co przy kliencie logiki — tam brak nagłówka wyszedł
dopiero na produkcji (401)."""
import ast
tree = ast.parse(SRC)
calls = [n for n in ast.walk(tree)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
and n.func.attr in ("call", "call_json")]
assert calls, "nie znaleziono wywołania przez link_crypto"
for c in calls:
kwargs = {k.arg for k in c.keywords}
assert "headers" in kwargs and "link" in kwargs
def test_health_is_the_only_unencrypted_path():
"""/health musi zostać otwarty dla sond k8s — ale nic poza nim."""
from app import link_crypto
assert "/health" in link_crypto.PUBLIC_PATHS
raw = [line for line in SRC.splitlines() if "client.get(" in line or "client.post(" in line]
assert all("/health" in line for line in raw), f"surowe wywołania poza /health: {raw}"
def test_key_is_read_lazily():
"""Konfiguracja może się zmienić bez restartu procesu (podmiana sekretu)."""
assert "def _link()" in SRC and "key_from_env" in SRC
# ──────────────────────── 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
def test_print_theme_has_no_css_variables():
"""`var(--…)` w SVG poza stroną nie rozwiąże się — wyszłyby czarne albo
żadne kreski."""
from app import chartwheel
chart = _sample_chart()
printed = chartwheel.render(chart, theme="print")
assert printed and "var(--" not in printed
def test_screen_theme_still_uses_css_variables():
"""Na stronie koło ma się dostrajać do motywu aplikacji."""
from app import chartwheel
assert "var(--" in chartwheel.render(_sample_chart(), theme="screen")
def test_print_theme_embeds_the_glyph_font():
"""Font glifów podaje CSS klasy `.glyph` — poza stroną trzeba go wpisać
W SAM rysunek, inaczej kosmogram w PDF wyszedłby BEZ symboli."""
from app import chartwheel
printed = chartwheel.render(_sample_chart(), theme="print")
assert 'font-family=' in printed
assert "DejaVu Sans" in printed # font obecny w obrazie usługi render
def test_screen_theme_leaves_font_to_css():
from app import chartwheel
assert "font-family=" not in chartwheel.render(_sample_chart(), theme="screen")
def test_unknown_theme_falls_back_to_screen():
from app import chartwheel
assert chartwheel.render(_sample_chart(), theme="cokolwiek") == \
chartwheel.render(_sample_chart(), theme="screen")
# ─────────────────────────── składanie po stronie klienta ────────────────
def test_browser_sends_all_three_parts():
for field in ("person", "natal", "predictions"):
assert field in COMPILE_JS, f"raport wysyłany bez części: {field}"
def test_pdf_button_exists():
tpl = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
assert 'id="pdfBtn"' in tpl
def test_wheel_failure_does_not_block_the_report():
"""Brak rysunków ma dać raport bez nich, a nie brak raportu — tekst kosztował
wywołanie modelu, rysunki policzymy zawsze."""
assert "figures: list[dict] = []" in MAIN
assert "Brak rysunków nie może zablokować raportu" in MAIN
# ─────────────────────────────────── pomocnicze ──────────────────────────
def _sample_chart():
signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
glyphs = ["", "", "", "", "", "", "", "", "", "", "", ""]
asc = 128.9
asc_sign = int(asc // 30)
return {
"angles": {"Asc": {"name": "Asc", "decimal": asc, "sign": signs[asc_sign]},
"MC": {"name": "MC", "decimal": 5.0},
"Dsc": {"name": "Dsc", "decimal": (asc + 180) % 360},
"IC": {"name": "IC", "decimal": 185.0}},
"cusps": [{"house": i + 1, "sign": signs[(asc_sign + i) % 12],
"decimal": (asc_sign * 30 + i * 30) % 360} for i in range(12)],
"sign_glyphs": [{"sign": s, "glyph": g} for s, g in zip(signs, glyphs)],
"positions": [{"name": "Sun", "decimal": 40.2, "glyph": "", "direction": "D"}],
}
@@ -1,76 +0,0 @@
"""Eksport raportu do Excela — tabela robocza (DAN-23/PRE-10)."""
import io
import pathlib
from openpyxl import load_workbook
from app.report_export import HEADERS, report_to_xlsx
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
MAIN = (APP / "main.py").read_text(encoding="utf-8")
INTERPRET = (APP / "templates" / "interpret.html").read_text(encoding="utf-8")
def _report() -> dict:
return {"engine": "own", "objects": [
{"name": "Sun", "facets": [
{"type": "sign", "label": "w znaku Cancer", "token": "[Su + Can", "samples": [
{"significator": "Su Can", "expanded": "Słońce w Raku", "effect": "opiekuńczy"},
{"significator": "Su Can h10", "expanded": "Słońce w Raku, 10 dom", "effect": "kariera 50% publiczna"}]},
{"type": "house", "label": "w 10 domu", "token": "10 H.", "samples": [
{"significator": "Su h10", "expanded": "Słońce w 10 domu", "effect": "ambicja"}]}]},
{"name": "Moon", "facets": [
{"type": "aspect", "label": "trygon z Mars", "token": "[Mo tri [Ma", "samples": [
{"significator": "Mo tri Ma", "expanded": "Księżyc trygon Mars", "effect": "energia"}]}]},
]}
def _load(report):
return load_workbook(io.BytesIO(report_to_xlsx(report))).active
def test_header_row_matches_columns():
ws = _load(_report())
assert [c.value for c in ws[1]] == HEADERS
def test_one_row_per_sample_flattened():
"""Zagnieżdżenie obiekt→fasety→próbki rozwijamy na płaskie wiersze."""
ws = _load(_report())
assert ws.max_row == 1 + 4 # nagłówek + 4 dopasowania
def test_row_carries_object_facet_and_effect():
ws = _load(_report())
vals = [[c.value for c in row] for row in ws.iter_rows(min_row=2)]
first = vals[0]
assert first[0] == "Sun" and first[1] == "w znaku Cancer" and first[2] == "sign"
assert first[6] == "opiekuńczy" # kolumna „Opis / efekt"
assert any("kariera 50% publiczna" in (r[6] or "") for r in vals) # % nie psuje niczego
def test_autofilter_and_frozen_header_for_working_table():
"""Filtrowanie/sortowanie od razu w Excelu (PRE-10)."""
ws = _load(_report())
assert ws.auto_filter.ref == "A1:G5"
assert ws.freeze_panes == "A2"
def test_empty_report_is_safe():
ws = _load({"objects": []})
assert [c.value for c in ws[1]] == HEADERS
assert ws.max_row == 1 # sam nagłówek, bez wyjątku
assert ws.auto_filter.ref == "A1:G1"
# ── wpięcie w UI ──
def test_interpret_has_export_button():
assert 'name="action" value="export"' in INTERPRET
def test_handler_returns_xlsx_download():
assert 'if action == "export":' in MAIN
assert "report_to_xlsx" in MAIN
assert "spreadsheetml.sheet" in MAIN # media type .xlsx
assert 'filename="interpretacje.xlsx"' in MAIN
@@ -1,201 +0,0 @@
"""Logowanie do aplikacji i limit żądań (LOG-32).
To jest brama chroniąca oryginalne bazy interpretacyjne bez niej każdy w sieci
mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują,
że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą.
"""
import base64
import pytest
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from starlette.testclient import TestClient
from app import security
USER, PASSWORD = "astrololo", "haslo-testowe"
def _basic(user: str, password: str) -> dict[str, str]:
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {raw}"}
def _app() -> FastAPI:
app = FastAPI()
security.install(app)
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/significators", response_class=HTMLResponse)
def significators():
return "<p>treść z bazy interpretacyjnej</p>"
return app
@pytest.fixture(autouse=True)
def _reset_rate_limit():
security._hits.clear()
yield
security._hits.clear()
@pytest.fixture
def guarded(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120")
return TestClient(_app())
# ------------------------------------------------------------------ logowanie
def test_blocks_anonymous_access(guarded):
r = guarded.get("/significators")
assert r.status_code == 401
assert "treść z bazy" not in r.text
def test_challenges_with_basic_realm(guarded):
"""Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania."""
assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "")
def test_rejects_wrong_password(guarded):
assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401
def test_rejects_wrong_user(guarded):
assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401
def test_rejects_malformed_header(guarded):
for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"):
assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401
def test_allows_correct_credentials(guarded):
r = guarded.get("/significators", headers=_basic(USER, PASSWORD))
assert r.status_code == 200
assert "treść z bazy" in r.text
def test_health_stays_public(guarded):
assert guarded.get("/health").status_code == 200
def test_open_when_password_unset(monkeypatch):
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie)."""
monkeypatch.delenv("APP_PASSWORD", raising=False)
assert not security.auth_enabled()
assert TestClient(_app()).get("/significators").status_code == 200
# --------------------------------------------------------------- limit żądań
def test_rate_limit_blocks_flood(monkeypatch):
"""Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5")
client = TestClient(_app())
auth = _basic(USER, PASSWORD)
codes = [client.get("/significators", headers=auth).status_code for _ in range(8)]
assert codes[:5] == [200] * 5
assert 429 in codes[5:]
def test_rate_limited_response_has_retry_after(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1")
client = TestClient(_app())
auth = _basic(USER, PASSWORD)
client.get("/significators", headers=auth)
r = client.get("/significators", headers=auth)
assert r.status_code == 429 and r.headers.get("Retry-After") == "60"
def test_rate_limit_precedes_auth(monkeypatch):
"""Limit musi działać także dla niezalogowanych — inaczej zgadywanie hasła
i sondowanie API jest darmowe."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
client = TestClient(_app())
codes = [client.get("/significators").status_code for _ in range(6)]
assert 429 in codes
def test_rate_limit_disabled_when_zero(monkeypatch):
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
monkeypatch.delenv("APP_PASSWORD", raising=False)
client = TestClient(_app())
assert all(client.get("/significators").status_code == 200 for _ in range(30))
# ------------------------------------------ adres klienta za odwrotnym proxy
#
# Po włączeniu TLS (PRE-16) aplikacja stoi za Ingressem, więc bezpośredni peer
# to zawsze POD proxy. Te testy pilnują obu stron kompromisu: żeby limit dalej
# rozróżniał ludzi, a jednocześnie żeby nagłówek nie stał się furtką do jego
# ominięcia.
class _Req:
"""Minimalny zamiennik Request — `client_ip` czyta tylko te dwa pola."""
def __init__(self, peer: str | None, **headers: str):
self.client = type("C", (), {"host": peer})() if peer else None
self.headers = {k.replace("_", "-"): v for k, v in headers.items()}
def test_client_ip_ignores_headers_without_trust_proxy(monkeypatch):
"""Bez TRUST_PROXY nagłówek jest bezwartościowy — każdy może go dopisać."""
monkeypatch.delenv("TRUST_PROXY", raising=False)
req = _Req("10.42.0.7", x_forwarded_for="1.2.3.4", x_real_ip="5.6.7.8")
assert security.client_ip(req) == "10.42.0.7"
def test_client_ip_takes_last_forwarded_entry(monkeypatch):
"""Ostatni wpis dopisał NASZ proxy; wcześniejsze mógł podstawić klient."""
monkeypatch.setenv("TRUST_PROXY", "true")
req = _Req("10.42.0.7", x_forwarded_for="1.1.1.1, 2.2.2.2, 192.168.1.50")
assert security.client_ip(req) == "192.168.1.50"
def test_client_ip_falls_back_to_real_ip(monkeypatch):
monkeypatch.setenv("TRUST_PROXY", "true")
req = _Req("10.42.0.7", x_real_ip="192.168.1.50")
assert security.client_ip(req) == "192.168.1.50"
def test_client_ip_falls_back_to_peer_when_headers_missing(monkeypatch):
monkeypatch.setenv("TRUST_PROXY", "true")
assert security.client_ip(_Req("10.42.0.7")) == "10.42.0.7"
def test_spoofed_forwarded_header_cannot_dodge_the_limit(monkeypatch):
"""Sedno sprawy: bez zaufania do proxy podszywanie się NIE resetuje wiadra.
Gdyby limiter brał pierwszy lepszy `X-Forwarded-For`, wystarczyłoby zmieniać
go co żądanie, żeby pompować bazę bez ograniczeń.
"""
monkeypatch.delenv("TRUST_PROXY", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
monkeypatch.delenv("APP_PASSWORD", raising=False)
client = TestClient(_app())
codes = [client.get("/significators", headers={"X-Forwarded-For": f"9.9.9.{i}"}).status_code
for i in range(6)]
assert 429 in codes
def test_proxied_clients_get_separate_buckets(monkeypatch):
"""Za proxy dwie różne osoby nie mogą się nawzajem odcinać."""
monkeypatch.setenv("TRUST_PROXY", "true")
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
monkeypatch.delenv("APP_PASSWORD", raising=False)
client = TestClient(_app())
first = [client.get("/significators", headers={"X-Forwarded-For": "192.168.1.50"}).status_code
for _ in range(5)]
second = client.get("/significators", headers={"X-Forwarded-For": "192.168.1.51"})
assert 429 in first, "limit musi zadziałać dla pierwszego adresu"
assert second.status_code == 200, "drugi adres ma własne wiadro"
@@ -1,36 +0,0 @@
"""Ekran ustawień: bazy na udziale (PRE-09)."""
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TPL = (APP / "templates" / "settings.html").read_text(encoding="utf-8")
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
def test_tab_is_in_the_menu():
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]
@@ -1,124 +0,0 @@
"""Poprawki zgłoszone przez partnerów: offset względem GMT i lokalizacja po
kliknięciu Tu i teraz" (PRE-19, PRE-20).
JS-a tu nie uruchomimy, więc pilnujemy tego strukturalnie tak samo jak
niezmienników klienta HTTP. Obie regresje ciche (nic się nie wywala, tylko
formularz pokazuje nieprawdę), więc test na źródle jest tu wart więcej niż zwykle.
"""
import pathlib
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
TEMPLATES = APP / "templates"
GEO_JS = APP / "static" / "geo.js"
WITH_OFFSET = ("chart.html", "interpret.html", "timeline.html")
# ───────────────────────────── PRE-19: offset względem GMT ───────────────
@pytest.mark.parametrize("name", WITH_OFFSET)
def test_offset_label_says_relative_to_gmt(name):
"""Etykieta musi mówić, WZGLĘDEM CZEGO liczymy przesunięcie."""
html = (TEMPLATES / name).read_text(encoding="utf-8")
assert "Offset względem GMT" in html, f"{name}: etykieta nie mówi o GMT"
@pytest.mark.parametrize("name", WITH_OFFSET)
def test_offset_step_is_quarter_hour(name):
"""Krok 15 minut = 0,25 h. Strefy pół- i ćwierćgodzinne (Indie +5:30,
Nepal +5:45) realne przy kroku 1 h nie dało się ich ustawić."""
html = (TEMPLATES / name).read_text(encoding="utf-8")
assert 'name="tz_offset" step="0.25"' in html, f"{name}: krok inny niż 15 minut"
@pytest.mark.parametrize("name", WITH_OFFSET)
def test_offset_range_covers_real_zones(name):
html = (TEMPLATES / name).read_text(encoding="utf-8")
assert 'min="-12"' in html and 'max="14"' in html, f"{name}: brak sensownego zakresu stref"
# ──────────────────── PRE-20: „Tu i teraz" odświeża nazwę miejsca ────────
def _geo_source() -> str:
return GEO_JS.read_text(encoding="utf-8")
def _handler_body() -> str:
"""Ciało handlera zdarzenia astrololo:coords (wysyłanego przez „Tu i teraz").
Szukamy KONKRETNIE `addEventListener`, nie samej nazwy zdarzenia ta pada
także w komentarzu nagłówkowym pliku i wtedy wycięlibyśmy komentarz zamiast
kodu (test przechodziłby albo padał z niewłaściwego powodu).
"""
src = _geo_source()
start = src.index("addEventListener('astrololo:coords'")
end = src.index("});", start)
return src[start:end]
def test_now_button_handler_refreshes_place_name():
"""REGRESJA: po „Tu i teraz" pineska i współrzędne skakały na nowe miejsce,
a w polu tekstowym zostawała stara nazwa formularz pokazywał jedno miejsce,
a liczył dla innego."""
assert "reverseName(" in _handler_body(), (
"handler astrololo:coords nie odświeża nazwy miejsca (reverseName)"
)
def test_reverse_name_clears_field_before_lookup():
"""Nazwa jest czyszczona ZANIM przyjdzie odpowiedź. Gdyby /reverse nie
odpowiedziało (brak sieci), zostałaby stara nazwa czyli pole kłamałoby
o lokalizacji. Lepiej puste pole niż nieprawdziwe."""
src = _geo_source()
start = src.index("function reverseName")
body = src[start:src.index("\n }", start)]
# sposób zapisu może się zmienić (bezpośrednie .value albo setVal, które
# dodatkowo zgłasza `change`) — pilnujemy KOLEJNOŚCI, nie składni
clear_at = max(body.find("searchEl.value = ''"), body.find("setVal(searchEl, '')"))
fetch_at = body.find("fetch(")
assert clear_at != -1, "reverseName nie czyści pola nazwy"
assert clear_at < fetch_at, "pole czyszczone dopiero po zapytaniu — stara nazwa zdąży zostać"
def test_now_button_still_syncs_map():
"""Odświeżenie nazwy nie może zastąpić przesunięcia pineski i widoku mapy."""
body = _handler_body()
assert "marker.setLatLng" in body and "map.setView" in body
# ---- strefa czasowa z lokalizacji (PRE-03) ----
def test_setcoords_resolves_timezone():
"""Nowe współrzędne (mapa/wyszukiwarka) mają odświeżyć strefę i offset."""
src = _geo_source()
body = src[src.index("function setCoords"):src.index("\n }", src.index("function setCoords"))]
assert "resolveTz()" in body
def test_now_button_resolves_timezone():
"""„Tu i teraz" to nowe miejsce → nowa strefa."""
assert "resolveTz()" in _handler_body()
def test_date_change_recomputes_timezone():
"""Czas letni zależy od daty — zmiana daty musi przeliczyć offset."""
src = _geo_source()
assert "dateEl.addEventListener('change', resolveTz)" in src
def test_timezone_hint_does_not_clobber_manual_offset_on_load():
"""Na wejściu podpowiadamy strefę TYLKO gdy offset wygląda na nieustawiony —
inaczej nadpisalibyśmy wartość ręcznie wpisaną i wysłaną przez użytkownika."""
src = _geo_source()
assert "parseFloat(tzEl.value) === 0" in src
def test_timezone_fills_offset_field_and_shows_zone():
src = _geo_source()
start = src.index("function resolveTz")
body = src[start:src.index("\n }", start)]
assert "/timezone?lat=" in body
assert "setVal(tzEl, d.offset)" in body # wypełnia pole offsetu
assert "d.label" in body # pokazuje wykrytą strefę

Some files were not shown because too many files have changed in this diff Show More