Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddb0acc218 | |||
| 473d059a6a | |||
| 929b691238 | |||
| 3ce3911f55 | |||
| 91644a44e3 | |||
| 114b7eebdf | |||
| 163ace4283 | |||
| f33cdc88f4 | |||
| 877ec91ff0 | |||
| 487dbb8fd3 | |||
| 5203ba9e76 | |||
| 64d1afc76d | |||
| 4da5f5fe7e | |||
| 4ef90b30bc | |||
| 73fd41b9d3 | |||
| 752f477a27 | |||
| 04b26afa6d | |||
| 2892b71be3 | |||
| 6f87b2b323 | |||
| a2aabd37a5 | |||
| de5d58958c | |||
| ab6fc0722c | |||
| 6ced2ddd22 | |||
| 79bce8ae90 | |||
| 85e9182f12 | |||
| c75f8377bf | |||
| 5234e86c95 | |||
| 0ac8df250c | |||
| 9323803cb4 | |||
| 678c3bad76 | |||
| 88a1ecd3c4 | |||
| b28c612bf7 | |||
| 2bda4e311d | |||
| 5d0d86cb90 | |||
| 9d5b65ddbd | |||
| cff0ac194c | |||
| 73f39e7df8 | |||
| 96d983b26a | |||
| c4a181b810 | |||
| 97ad21d2e4 | |||
| 2dbd3410e9 | |||
| ca458fd741 | |||
| b013831492 | |||
| d14a77360a | |||
| a8c3072e62 | |||
| 93932246f3 | |||
| 8d579de34a | |||
| db3d1e5117 | |||
| 4a86d34f5a | |||
| fb317172ff | |||
| f6323cac10 | |||
| 243d02d55b | |||
| 166b438f83 | |||
| 41d7ca491d | |||
| 6acd3546fb | |||
| 82809665ff | |||
| 0e74566a78 | |||
| cea6907f84 | |||
| dabbaef9dd | |||
| a81ae698a3 | |||
| eef67d37b5 | |||
| 94c3023d3a | |||
| e4f6a16c9c | |||
| 413c46b5dd | |||
| 340b3058e4 | |||
| 376a4bfded | |||
| e69c0714b9 |
@@ -0,0 +1,32 @@
|
|||||||
|
name: build-swisseph
|
||||||
|
|
||||||
|
# Osobny pipeline dla silnika B (Swiss Ephemeris, AGPL) — celowo ODDZIELONY od
|
||||||
|
# głównego build.yaml (data/logic/presentation). Buduje się tylko, gdy zmienia się
|
||||||
|
# sam silnik, i nie miesza obrazu AGPL do pipeline'u permisywnego produktu.
|
||||||
|
#
|
||||||
|
# Obraz konsumuje profil deployu `astrololo-swisseph` w repo `deploy`.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'services/engine-swisseph/**'
|
||||||
|
- '.gitea/workflows/build-swisseph.yaml'
|
||||||
|
workflow_dispatch: {} # ręczne odpalenie (bootstrap pierwszego obrazu)
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||||
|
- name: Build & push engine-swisseph (AGPL, izolowany)
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_SHA::8}
|
||||||
|
IMG=gitea.czernobog.pl/gitea/astrololo-engine-swisseph
|
||||||
|
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312) — build jest
|
||||||
|
# zarazem realnym testem Dockerfile'a.
|
||||||
|
docker build -t $IMG:$TAG -t $IMG:latest ./services/engine-swisseph
|
||||||
|
docker push $IMG:$TAG
|
||||||
|
docker push $IMG:latest
|
||||||
|
echo "Zbudowano i wypchnięto: $IMG:$TAG (+ latest)"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||||
|
- name: Build & push (data, logic, presentation)
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_SHA::8}
|
||||||
|
for SVC in data logic presentation; do
|
||||||
|
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
|
||||||
|
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
|
||||||
|
done
|
||||||
|
echo "Tag: $TAG"
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
name: Testy
|
||||||
|
|
||||||
|
# Odpala się przy każdym pushu (dowolna gałąź) oraz dla pull requestów do master.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
logic-tests:
|
||||||
|
name: Testy warstwy logicznej (silnik)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Python 3.12 (jak w obrazach Dockera)
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: services/logic/requirements-dev.txt
|
||||||
|
|
||||||
|
# Jądro efemeryd JPL (de421.bsp, ~17 MB). Cache'ujemy je między runami.
|
||||||
|
- name: Cache jądra efemeryd
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: services/logic/.ephemeris
|
||||||
|
key: ephemeris-de421
|
||||||
|
|
||||||
|
# Pobieramy jawnie (a nie licząc na auto-pobranie przez Skyfield), żeby
|
||||||
|
# brak jądra był twardym błędem, a nie cichym pomijaniem testów.
|
||||||
|
- name: Pobierz jądro efemeryd (gdy brak w cache)
|
||||||
|
run: |
|
||||||
|
mkdir -p services/logic/.ephemeris
|
||||||
|
if [ ! -s services/logic/.ephemeris/de421.bsp ]; then
|
||||||
|
curl -fSL --retry 3 --max-time 300 \
|
||||||
|
-o services/logic/.ephemeris/de421.bsp \
|
||||||
|
https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp
|
||||||
|
fi
|
||||||
|
ls -lh services/logic/.ephemeris/de421.bsp
|
||||||
|
|
||||||
|
- name: Instalacja zależności
|
||||||
|
run: pip install -r services/logic/requirements-dev.txt
|
||||||
|
|
||||||
|
# CI=true (ustawiane przez GitHub) sprawia, że brak silnika = błąd,
|
||||||
|
# a nie pominięcie — patrz tests/conftest.py.
|
||||||
|
- name: Testy (pytest)
|
||||||
|
working-directory: services/logic
|
||||||
|
env:
|
||||||
|
PYTHONPATH: .
|
||||||
|
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
|
||||||
|
run: pytest tests -q -rs
|
||||||
|
|
||||||
|
presentation-tests:
|
||||||
|
name: Testy warstwy prezentacji (dostęp do baz)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: services/presentation/requirements-dev.txt
|
||||||
|
- name: Instalacja zależności
|
||||||
|
run: pip install -r services/presentation/requirements-dev.txt
|
||||||
|
# Bramka chroniąca oryginalne bazy — nietestowany kod ochronny jest gorszy
|
||||||
|
# niż jego brak, bo daje złudzenie zabezpieczenia.
|
||||||
|
- name: Testy (pytest)
|
||||||
|
working-directory: services/presentation
|
||||||
|
env:
|
||||||
|
PYTHONPATH: .
|
||||||
|
run: pytest tests -q -rs
|
||||||
|
|
||||||
|
swisseph-image:
|
||||||
|
name: Build obrazu silnika B (swisseph)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312), więc ten
|
||||||
|
# build jest realnym testem Dockerfile'a — nie tylko pobraniem paczek.
|
||||||
|
- name: docker build
|
||||||
|
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
|
||||||
|
|
||||||
|
# Test biegnie WEWNĄTRZ obrazu, bez sieci i bez kontenera w tle. Poprzednia
|
||||||
|
# wersja startowała kontener w tle (--name swe) i pukała curl-em w
|
||||||
|
# localhost:8003 — co miało dwie wady:
|
||||||
|
# 1. nie sprzątała kontenera, więc każdy kolejny przebieg padał na
|
||||||
|
# konflikcie nazwy (Conflict. The container name "/swe" is already in use),
|
||||||
|
# 2. job Gitea Actions sam działa w kontenerze, a -p publikuje port na
|
||||||
|
# HOŚCIE — więc localhost joba to nie ten sam localhost.
|
||||||
|
# Wywołanie funkcji endpointów wprost omija oba problemy, a sprawdza to samo:
|
||||||
|
# obraz się zbudował, pyswisseph liczy, kontrakt /positions się zgadza.
|
||||||
|
# --rm gwarantuje, że nic nie zostaje po przebiegu.
|
||||||
|
- name: Smoke test (health + pozycje) wewnątrz obrazu
|
||||||
|
run: |
|
||||||
|
docker run --rm astrololo/engine-swisseph:ci python - <<'PY'
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.main import DEFAULT_OBJECTS, PositionsRequest, health, positions
|
||||||
|
|
||||||
|
h = health()
|
||||||
|
assert h["status"] == "ok", h
|
||||||
|
print("health:", h)
|
||||||
|
|
||||||
|
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
|
||||||
|
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
|
||||||
|
req = PositionsRequest(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
||||||
|
lat=50.0647, lon=19.9450)
|
||||||
|
out = positions(req)
|
||||||
|
by = {p["name"]: p for p in out["positions"]}
|
||||||
|
|
||||||
|
assert out["engine"] == "swisseph", out["engine"]
|
||||||
|
assert len(by) == len(DEFAULT_OBJECTS), sorted(by)
|
||||||
|
sun = by["Sun"]["longitude"]
|
||||||
|
assert 39.5 < sun < 41.0, f"Slonce poza oczekiwanym zakresem: {sun}"
|
||||||
|
nn, sn = by["North Node"]["longitude"], by["South Node"]["longitude"]
|
||||||
|
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
|
||||||
|
|
||||||
|
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
|
||||||
|
print("SMOKE OK")
|
||||||
|
PY
|
||||||
|
|
||||||
|
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
|
||||||
|
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
|
||||||
|
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
|
||||||
|
- name: Usuń osierocony kontener ze starych przebiegów
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f swe 2>/dev/null || true
|
||||||
|
|
||||||
|
compile-all:
|
||||||
|
name: Kontrola składni wszystkich warstw
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
# Sam kompilator — bez instalowania zależności warstw (w tym AGPL-owego
|
||||||
|
# silnika swisseph, który nie wchodzi do produktu).
|
||||||
|
- name: py_compile
|
||||||
|
run: python -m compileall -q services
|
||||||
@@ -10,6 +10,10 @@ services/data/.cache/
|
|||||||
*.parquet
|
*.parquet
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# Cache efemeryd silnika własnego (pobierane jądra JPL, regenerowalne)
|
||||||
|
services/logic/.ephemeris/
|
||||||
|
*.bsp
|
||||||
|
|
||||||
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
||||||
services/data/data_files/*.xlsx
|
services/data/data_files/*.xlsx
|
||||||
!services/data/data_files/.gitkeep
|
!services/data/data_files/.gitkeep
|
||||||
|
|||||||
@@ -1,31 +1,56 @@
|
|||||||
.PHONY: help up down sample reindex migrate sql dev-data dev-logic dev-presentation
|
.PHONY: help install up down sample reindex migrate sql test clean-cache \
|
||||||
|
dev-data dev-logic dev-presentation
|
||||||
|
|
||||||
|
# Autodetekcja narzędzia compose: nowe "docker compose" albo stare "docker-compose".
|
||||||
|
COMPOSE := $(shell if docker compose version >/dev/null 2>&1; then echo "docker compose"; \
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
|
||||||
|
|
||||||
|
# Python aktywnego środowiska (venv). Nadpisz: make install PY=python3.12
|
||||||
|
PY ?= python
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "up - uruchom wszystkie 3 warstwy (docker compose)"
|
@echo "install - zainstaluj zależności WSZYSTKICH warstw do aktywnego venv"
|
||||||
@echo "down - zatrzymaj"
|
|
||||||
@echo "sample - wygeneruj przykładowe pliki .xlsx"
|
@echo "sample - wygeneruj przykładowe pliki .xlsx"
|
||||||
|
@echo "dev-data - uruchom warstwę danych lokalnie (:8002)"
|
||||||
|
@echo "dev-logic - uruchom warstwę logiczną lokalnie (:8001)"
|
||||||
|
@echo "dev-presentation - uruchom warstwę prezentacji lokalnie (:8000)"
|
||||||
|
@echo "test - testy warstwy logicznej (silnik efemeryd)"
|
||||||
|
@echo "clean-cache - wyczyść regenerowalny cache warstwy danych"
|
||||||
@echo "reindex - zbuduj cache + indeks warstwy bazodanowej"
|
@echo "reindex - zbuduj cache + indeks warstwy bazodanowej"
|
||||||
@echo "migrate - ETL: Excel -> SQL"
|
@echo "migrate - ETL: Excel -> SQL"
|
||||||
@echo "sql - uruchom z warstwą SQL (DATA_PROVIDER=sql)"
|
@echo "up / down - uruchom / zatrzymaj cały stos (Docker; wykryto: $(COMPOSE))"
|
||||||
@echo "dev-* - uruchom pojedynczą warstwę lokalnie (bez dockera)"
|
@echo "sql - Docker z warstwą SQL (DATA_PROVIDER=sql)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Szybki start lokalny (bez Dockera), w 3 terminalach:"
|
||||||
|
@echo " make install"
|
||||||
|
@echo " make sample # opcjonalnie: dane przykładowe"
|
||||||
|
@echo " make dev-data # terminal 1"
|
||||||
|
@echo " make dev-logic # terminal 2"
|
||||||
|
@echo " make dev-presentation # terminal 3 -> http://localhost:8000"
|
||||||
|
|
||||||
up:
|
# --- instalacja zależności do aktywnego środowiska ---
|
||||||
docker compose up --build
|
install:
|
||||||
|
$(PY) -m pip install \
|
||||||
down:
|
-r services/data/requirements.txt \
|
||||||
docker compose down
|
-r services/logic/requirements.txt \
|
||||||
|
-r services/presentation/requirements.txt
|
||||||
|
@echo "OK. Zależności warstw zainstalowane. (Silnik AGPL 'engine-swisseph' instaluje się osobno.)"
|
||||||
|
|
||||||
sample:
|
sample:
|
||||||
cd services/data && python scripts/make_sample_data.py
|
cd services/data && $(PY) scripts/make_sample_data.py
|
||||||
|
|
||||||
reindex:
|
reindex:
|
||||||
cd services/data && python -m app.ingest.build_index
|
cd services/data && $(PY) -m app.ingest.build_index
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
cd services/data && python -m app.ingest.to_sql
|
cd services/data && $(PY) -m app.ingest.to_sql
|
||||||
|
|
||||||
sql:
|
test:
|
||||||
DATA_PROVIDER=sql docker compose up --build
|
cd services/logic && PYTHONPATH=. $(PY) -m pytest tests -q
|
||||||
|
|
||||||
|
clean-cache:
|
||||||
|
rm -rf services/data/.cache/* services/logic/.ephemeris/* 2>/dev/null || true
|
||||||
|
@echo "Cache wyczyszczony."
|
||||||
|
|
||||||
# --- lokalny dev (3 osobne terminale) ---
|
# --- lokalny dev (3 osobne terminale) ---
|
||||||
dev-data:
|
dev-data:
|
||||||
@@ -36,3 +61,20 @@ dev-logic:
|
|||||||
|
|
||||||
dev-presentation:
|
dev-presentation:
|
||||||
cd services/presentation && LOGIC_URL=http://localhost:8001 uvicorn app.main:app --reload --port 8000
|
cd services/presentation && LOGIC_URL=http://localhost:8001 uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
|
# --- Docker (opcjonalnie) ---
|
||||||
|
up:
|
||||||
|
@if [ -z "$(COMPOSE)" ]; then \
|
||||||
|
echo "Nie znaleziono Docker Compose (ani 'docker compose', ani 'docker-compose')."; \
|
||||||
|
echo "Użyj trybu lokalnego: make install, potem dev-data / dev-logic / dev-presentation."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
$(COMPOSE) up --build
|
||||||
|
|
||||||
|
down:
|
||||||
|
@if [ -z "$(COMPOSE)" ]; then echo "Brak Docker Compose."; exit 1; fi
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
sql:
|
||||||
|
@if [ -z "$(COMPOSE)" ]; then echo "Brak Docker Compose."; exit 1; fi
|
||||||
|
DATA_PROVIDER=sql $(COMPOSE) up --build
|
||||||
|
|||||||
@@ -28,13 +28,20 @@ make up # zbuduj i uruchom 3 warstwy
|
|||||||
# otwórz http://localhost:8000
|
# otwórz http://localhost:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
## Szybki start (lokalnie, 3 terminale)
|
## Szybki start (lokalnie, bez Dockera — zalecane)
|
||||||
|
```bash
|
||||||
|
python -m venv .env && source .env/bin/activate # venv (jednorazowo)
|
||||||
|
make install # zależności WSZYSTKICH warstw
|
||||||
|
make sample # opcjonalnie: dane przykładowe
|
||||||
|
```
|
||||||
|
Potem w 3 osobnych terminalach (w każdym `source .env/bin/activate`):
|
||||||
```bash
|
```bash
|
||||||
cd services/data && pip install -r requirements.txt && python scripts/make_sample_data.py
|
|
||||||
make dev-data # terminal 1 -> :8002
|
make dev-data # terminal 1 -> :8002
|
||||||
make dev-logic # terminal 2 -> :8001
|
make dev-logic # terminal 2 -> :8001
|
||||||
make dev-presentation # terminal 3 -> :8000
|
make dev-presentation # terminal 3 -> :8000 -> http://localhost:8000
|
||||||
```
|
```
|
||||||
|
> `make install` instaluje zależności wszystkich trzech warstw do aktywnego venv.
|
||||||
|
> Testy silnika: `make test`. Wyczyszczenie cache: `make clean-cache`.
|
||||||
|
|
||||||
## Modułowość — dowód
|
## Modułowość — dowód
|
||||||
- Wymień prezentację (np. na SPA/React) → reszta bez zmian, kontrakt `/api/query` stały.
|
- Wymień prezentację (np. na SPA/React) → reszta bez zmian, kontrakt `/api/query` stały.
|
||||||
@@ -66,3 +73,4 @@ export DATA_PROVIDER=sql # przełącz całą warstwę
|
|||||||
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
|
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
|
||||||
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
|
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
|
||||||
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
|
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
|
||||||
|
# updater test Mon 20 Jul 2026 19:52:08 CEST
|
||||||
|
|||||||
@@ -16,11 +16,23 @@ services:
|
|||||||
build: ./services/logic
|
build: ./services/logic
|
||||||
environment:
|
environment:
|
||||||
DATA_URL: http://data:8002
|
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:
|
depends_on:
|
||||||
- data
|
- data
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "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:
|
||||||
|
build: ./services/engine-swisseph
|
||||||
|
profiles: ["comparison"]
|
||||||
|
ports:
|
||||||
|
- "8003:8003"
|
||||||
|
|
||||||
presentation:
|
presentation:
|
||||||
build: ./services/presentation
|
build: ./services/presentation
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,296 @@
|
|||||||
|
# Wdrożenie PRE-16 — HTTPS na wejściu i szyfrowanie łączy między warstwami
|
||||||
|
|
||||||
|
Instrukcja krok po kroku. **Kolejność ma znaczenie** — punkt „Dlaczego taka
|
||||||
|
kolejność" niżej tłumaczy, co się stanie, jeśli ją zamienić.
|
||||||
|
|
||||||
|
Dotyczy dwóch pull requestów:
|
||||||
|
|
||||||
|
| Repo | PR | Co wnosi |
|
||||||
|
|---|---|---|
|
||||||
|
| `gitea/astrololo` | [#21](https://gitea.czernobog.pl/gitea/astrololo/pulls/21) | kod: szyfrowanie łączy, limit żądań za proxy |
|
||||||
|
| `gitea/deploy` | [#4](https://gitea.czernobog.pl/gitea/deploy/pulls/4) | manifesty: Ingress, certyfikat, klucze łączy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Co się właściwie zmienia
|
||||||
|
|
||||||
|
**Na wejściu do aplikacji.** Dotąd logowanie szło przez HTTP Basic po zwykłym
|
||||||
|
http — czyli hasło leciało siecią w postaci trywialnej do podsłuchania (base64 to
|
||||||
|
nie szyfrowanie). Po zmianie wejście jest po https, a http odsyła na https.
|
||||||
|
Przy okazji **odblokowują się dwie funkcje zepsute dziś z tego samego powodu**:
|
||||||
|
geolokalizacja („Tu i teraz") i kopiowanie promptu do schowka działają wyłącznie
|
||||||
|
w tzw. secure context i po http po prostu odmawiały.
|
||||||
|
|
||||||
|
**Między warstwami.** Prezentacja, logika i dane rozmawiały ze sobą otwartym
|
||||||
|
tekstem wewnątrz klastra. Token międzywarstwowy mówił *kto* pyta, ale nie ukrywał
|
||||||
|
*czego dotyczy odpowiedź* — a płyną nią surowe wiersze oryginalnych baz. Teraz
|
||||||
|
każde ciało żądania i odpowiedzi jest szyfrowane **AES-256-GCM**, osobnym kluczem
|
||||||
|
na każdą parę rozmówców.
|
||||||
|
|
||||||
|
**Wejście na świat pozostaje jedno: prompt do modelu.** Ta zmiana niczego tu nie
|
||||||
|
rusza — dotyczy wyłącznie ruchu wewnątrz sieci i wejścia z przeglądarki.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zanim zaczniesz — stan wyjściowy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get deploy,svc
|
||||||
|
kubectl -n astrololo get secret # powinny być: astrololo-auth, gitea-registry
|
||||||
|
kubectl -n kube-system get svc traefik -o jsonpath='{.status.loadBalancer.ingress[*].ip}'; echo
|
||||||
|
```
|
||||||
|
|
||||||
|
Zanotuj adres Traefika — będzie potrzebny w kroku 3. Sprawdź też, czy działa
|
||||||
|
aplikacja w obecnej postaci (przez NodePort), żeby mieć punkt odniesienia.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 1 — sekret z kluczami łączy
|
||||||
|
|
||||||
|
**Przed czymkolwiek innym.** Klucze muszą istnieć, zanim pody spróbują wstać
|
||||||
|
z nową konfiguracją, bo bez nich celowo **nie wystartują**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo create secret generic astrololo-link \
|
||||||
|
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(openssl rand -hex 32)" \
|
||||||
|
--from-literal=LINK_KEY_LOGIC_DATA="$(openssl rand -hex 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Kluczy nikt nigdy nie musi oglądać — służą tylko usługom. Nie ma ich w repo
|
||||||
|
GitOps i **nie ma ich tam wkładać**: cokolwiek trafi do gita, zostaje w historii
|
||||||
|
na zawsze.
|
||||||
|
|
||||||
|
Dwa osobne klucze to nie ozdobnik. Przejęcie klucza prezentacji nie daje dostępu
|
||||||
|
do warstwy danych, gdzie leżą całe bazy. Logika dostaje oba, bo rozmawia w obie
|
||||||
|
strony; prezentacja i dane dostają wyłącznie swój.
|
||||||
|
|
||||||
|
Sprawdź:
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
|
||||||
|
# oczekiwane: dwa klucze, każdy 64 znaki po odkodowaniu (32 bajty)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 2 — cert-manager
|
||||||
|
|
||||||
|
Jednorazowo, na cały klaster:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml
|
||||||
|
kubectl -n cert-manager rollout status deploy/cert-manager deploy/cert-manager-webhook --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
Poczekaj, aż **webhook** będzie gotowy — dopóki nie wstanie, tworzenie obiektów
|
||||||
|
`Certificate` kończy się błędem połączenia i wygląda jak zepsuty manifest.
|
||||||
|
|
||||||
|
Sprawdź:
|
||||||
|
```bash
|
||||||
|
kubectl get crd | grep cert-manager | head -3 # muszą się pojawić
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Dlaczego własne CA, a nie Let's Encrypt.** Klaster stoi w LAN (Traefik trzyma
|
||||||
|
> LoadBalancera na adresach 192.168.1.x), więc walidacja HTTP-01 nie ma jak dojść
|
||||||
|
> z internetu, a DNS-01 wymagałby trzymania w klastrze tokena API do domeny.
|
||||||
|
> Własne CA nie potrzebuje niczego z zewnątrz i odnawia certyfikaty samo. Cena:
|
||||||
|
> raz na urządzenie importujesz korzeń (krok 6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 3 — DNS
|
||||||
|
|
||||||
|
Wpis `astrololo.czernobog.pl` → adres Traefika z kroku „stan wyjściowy”.
|
||||||
|
W routerze, lokalnym DNS-ie albo doraźnie w `/etc/hosts`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "192.168.1.73 astrololo.czernobog.pl" | sudo tee -a /etc/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
**To nie jest krok opcjonalny.** Service `presentation` przestaje być NodePortem
|
||||||
|
(był drugą, nieszyfrowaną drogą do aplikacji — czyli obejściem całego PRE-16),
|
||||||
|
więc po wdrożeniu manifestów nazwa jest jedynym wejściem. Awaryjnie zawsze zostaje:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo port-forward svc/presentation 8000:8000 # http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 4 — merge PR-a aplikacji (astrololo #21)
|
||||||
|
|
||||||
|
Teraz, **przed** manifestami.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr merge --login gitea --repo gitea/astrololo 21
|
||||||
|
```
|
||||||
|
|
||||||
|
Po merge'u CI zbuduje obrazy, a image-updater sam podbije tagi w repo `deploy`,
|
||||||
|
skąd ArgoCD wymieni pody. Poczekaj, aż to się przetoczy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
|
||||||
|
kubectl -n astrololo get pods -o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Na tym etapie **nic się jeszcze nie szyfruje** — nowy kod to potrafi, ale zmienne
|
||||||
|
z kluczami dokłada dopiero PR do `deploy`. Aplikacja działa dokładnie jak dotąd.
|
||||||
|
To celowe: chcemy, żeby *cała* obsada podów umiała szyfrować, zanim ktokolwiek
|
||||||
|
tego zażąda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 5 — merge PR-a manifestów (deploy #4)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr merge --login gitea --repo gitea/deploy 4
|
||||||
|
```
|
||||||
|
|
||||||
|
ArgoCD zsynchronizuje się sam (`automated`, `selfHeal`). Wjeżdża naraz: Ingress,
|
||||||
|
certyfikat, zmienne z kluczami, `TRUST_PROXY` i zdjęcie NodePortu.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n argocd get application astrololo
|
||||||
|
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
|
||||||
|
kubectl -n astrololo get certificate # astrololo-ca i astrololo-tls: READY=True
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Spodziewaj się kilkudziesięciu sekund błędów w trakcie.** Pody wymieniają się
|
||||||
|
> po kolei, więc przez chwilę stara prezentacja (jeszcze bez klucza) rozmawia
|
||||||
|
> z nową logiką (już z kluczem) i dostaje odmowę. To zamierzone: alternatywą byłby
|
||||||
|
> tryb „przyjmuj i szyfrowane, i jawne”, który zwykle zostaje włączony na zawsze.
|
||||||
|
|
||||||
|
Merge nie cofnie tagów obrazów — PR dotyka w `kustomization.yaml` wyłącznie listy
|
||||||
|
`resources`, nie bloku `images`, więc git złoży to z nowszymi tagami z mastera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 6 — zaufanie do własnego CA (raz na urządzenie)
|
||||||
|
|
||||||
|
Bez tego przeglądarka pokaże ostrzeżenie o certyfikacie. Korzeń jest ważny 10 lat,
|
||||||
|
więc robisz to raz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo get secret astrololo-ca -o jsonpath='{.data.ca\.crt}' \
|
||||||
|
| base64 -d > astrololo-ca.crt
|
||||||
|
|
||||||
|
# macOS — do systemowego zaufania (poprosi o hasło administratora)
|
||||||
|
sudo security add-trusted-cert -d -r trustRoot \
|
||||||
|
-k /Library/Keychains/System.keychain astrololo-ca.crt
|
||||||
|
|
||||||
|
# Linux (Debian/Ubuntu)
|
||||||
|
sudo cp astrololo-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
Firefox ma **własny** magazyn certyfikatów — import przez *Ustawienia →
|
||||||
|
Prywatność i bezpieczeństwo → Wyświetl certyfikaty → Organy certyfikacji*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Krok 7 — sprawdzenie, że działa to, co miało zadziałać
|
||||||
|
|
||||||
|
### Wejście po https
|
||||||
|
```bash
|
||||||
|
curl -sI http://astrololo.czernobog.pl/ | head -2 # 301 → https
|
||||||
|
curl -s -o /dev/null -w "bez hasła: %{http_code}\n" https://astrololo.czernobog.pl/
|
||||||
|
curl -s -o /dev/null -w "z hasłem: %{http_code}\n" -u astrololo:'<hasło>' https://astrololo.czernobog.pl/
|
||||||
|
curl -sI -u astrololo:'<hasło>' https://astrololo.czernobog.pl/ | grep -i strict-transport
|
||||||
|
```
|
||||||
|
Oczekiwane: **301**, **401**, **200**, nagłówek HSTS obecny. Brak ostrzeżenia
|
||||||
|
o certyfikacie w przeglądarce oznacza, że krok 6 się udał.
|
||||||
|
|
||||||
|
### W przeglądarce
|
||||||
|
Kliknij **„Tu i teraz"** — powinno pobrać lokalizację (po http odmawiało).
|
||||||
|
Wygeneruj prompt i kliknij **kopiuj** — schowek powinien zadziałać bez obejść.
|
||||||
|
|
||||||
|
### Szyfrowanie łączy — sprawdzenie wprost
|
||||||
|
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **bez klucza**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo exec deploy/presentation -- \
|
||||||
|
python -c "
|
||||||
|
import httpx, os
|
||||||
|
r = httpx.post('http://logic:8001/chart/report',
|
||||||
|
json={'when_utc':'1984-04-30T09:20:00+00:00','lat':50.06,'lon':19.94},
|
||||||
|
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
|
||||||
|
print(r.status_code, r.text[:120])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Zwróć uwagę, że
|
||||||
|
żądanie miało **prawidłowy token** — sam token już nie wystarcza, i o to chodziło.
|
||||||
|
|
||||||
|
To samo w dół, do warstwy danych:
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo exec deploy/logic -- \
|
||||||
|
python -c "
|
||||||
|
import httpx, os
|
||||||
|
r = httpx.post('http://data:8002/search',
|
||||||
|
json={'key':'significator','value':'[Sat','exact':False,'limit':5},
|
||||||
|
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
|
||||||
|
print(r.status_code, r.text[:120])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logi startowe
|
||||||
|
```bash
|
||||||
|
kubectl -n astrololo logs deploy/logic | grep -i "łącze\|UWAGA"
|
||||||
|
```
|
||||||
|
Powinno być `łącze szyfrowane (AES-256-GCM…)`. Jeśli widzisz ostrzeżenie
|
||||||
|
o rozmowie **jawnym tekstem** — klucz nie doszedł do poda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dlaczego taka kolejność
|
||||||
|
|
||||||
|
| Kolejność | Skutek zamiany |
|
||||||
|
|---|---|
|
||||||
|
| Sekret **przed** manifestami | `LINK_ENCRYPTION_REQUIRED=true` bez klucza celowo wywraca start. Pody wpadną w CrashLoop i będą tak siedzieć do czasu utworzenia sekretu. |
|
||||||
|
| cert-manager **przed** manifestami | API odrzuci `Certificate`/`Issuer` jako nieznane rodzaje zasobów, ArgoCD pokaże aplikację jako niezsynchronizowaną i sam tego nie naprawi. |
|
||||||
|
| DNS **przed** manifestami | NodePort znika razem z nimi. Bez wpisu DNS zostaje tylko `port-forward`. |
|
||||||
|
| Aplikacja **przed** manifestami | Odwrotnie: manifesty włączyłyby szyfrowanie na obrazach, które go nie znają — wszystkie żądania kończyłyby się odmową do czasu przebudowy obrazów. |
|
||||||
|
|
||||||
|
Fail-closed w obie strony jest zamierzony. Usługa, która wstała i **po cichu nie
|
||||||
|
szyfruje**, jest gorsza niż pod w CrashLoop — awarii nie widać, a bazy jadą
|
||||||
|
otwartym tekstem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wycofanie
|
||||||
|
|
||||||
|
Manifestów: `git revert` merge'a w `deploy` — ArgoCD samo wróci do NodePortu
|
||||||
|
i ruchu bez szyfrowania. Kod aplikacji **nie wymaga wycofania**: bez zmiennych
|
||||||
|
`LINK_KEY_*` moduł przepuszcza ruch jak dotąd (i głośno o tym mówi w logach).
|
||||||
|
|
||||||
|
Certyfikat i CA zostają w namespace; usunięcie: `kubectl -n astrololo delete
|
||||||
|
certificate astrololo-ca astrololo-tls`. cert-managera można zostawić — nie
|
||||||
|
przeszkadza.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gdy coś nie gra
|
||||||
|
|
||||||
|
| Objaw | Przyczyna | Co zrobić |
|
||||||
|
|---|---|---|
|
||||||
|
| Pody w `CrashLoopBackOff`, w logach `LINK_ENCRYPTION_REQUIRED … nie ustawiony` | brak sekretu `astrololo-link` | krok 1, potem `rollout restart` |
|
||||||
|
| `400 Łącze międzywarstwowe wymaga szyfrowania` przy normalnym korzystaniu | jedna warstwa ma klucz, druga nie (albo trwa rollout) | `rollout status`; sprawdź, czy wszystkie trzy pody mają zmienną |
|
||||||
|
| `400 Nie udało się odczytać zaszyfrowanego żądania` | klucze po obu stronach łącza są **różne** | wymień sekret i zrestartuj **wszystkie trzy** naraz |
|
||||||
|
| `Certificate` stoi w `READY=False` | webhook cert-managera jeszcze nie wstał | `kubectl -n cert-manager get pods`, poczekaj i sprawdź `kubectl -n astrololo describe certificate astrololo-tls` |
|
||||||
|
| Przeglądarka: „połączenie nie jest prywatne” | korzeń CA nieimportowany na tym urządzeniu | krok 6 (pamiętaj, że Firefox ma osobny magazyn) |
|
||||||
|
| `404` z Traefika pod adresem aplikacji | DNS wskazuje gdzie indziej niż LoadBalancer Traefika | porównaj `dig +short astrololo.czernobog.pl` z adresem z kroku „stan wyjściowy” |
|
||||||
|
| Limit żądań odcina wszystkich naraz | brak `TRUST_PROXY=true` — cały ruch liczony jako jeden klient | sprawdź zmienną w `deploy/presentation` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Czego to nie załatwia
|
||||||
|
|
||||||
|
- **Szyfrowane są ciała żądań, nie nagłówki.** Ścieżka (`/search`) i token
|
||||||
|
międzywarstwowy jadą czytelnie. Sam token nikomu nic nie daje — bez klucza łącza
|
||||||
|
każde żądanie kończy się odmową — ale metadanych to nie ukrywa. Pełne ukrycie
|
||||||
|
wymagałoby mTLS.
|
||||||
|
- **Własne CA to nie publiczne zaufanie.** Każde nowe urządzenie wymaga importu
|
||||||
|
korzenia. Gdyby aplikacja miała kiedyś wyjść na świat, właściwą drogą jest
|
||||||
|
Let's Encrypt przez DNS-01.
|
||||||
|
- **NFS z plikami baz** stoi obok aplikacji — kto ma dostęp do share'u, bierze
|
||||||
|
pliki z pominięciem wszystkich powyższych zabezpieczeń. Do zamknięcia po stronie
|
||||||
|
infrastruktury (eksport tylko dla IP węzłów, `root_squash`, najlepiej read-only).
|
||||||
|
- **Sekrety w etcd** są tylko zakodowane base64. Docelowo: szyfrowanie etcd
|
||||||
|
at-rest albo Sealed Secrets / SOPS.
|
||||||
+7
-2
@@ -31,5 +31,10 @@ class FrameCache:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
|
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
|
||||||
# astype(str) na kolumnach object zapewnia stabilny zapis Parquet
|
# Kolumny z heterogenicznych plików Excela bywają mieszane (int + str +
|
||||||
frame.to_parquet(self._path(fp, sheet), index=False)
|
# NaN w jednej kolumnie) — pyarrow tego nie zapisze. Warstwa danych i tak
|
||||||
|
# operuje na tekście (wyszukiwanie po .astype(str)), więc zapisujemy ramkę
|
||||||
|
# jako string (brak wartości -> pusty tekst). Gwarantuje to stabilny zapis
|
||||||
|
# Parquet niezależnie od zawartości pliku źródłowego.
|
||||||
|
safe = frame.astype("string").fillna("")
|
||||||
|
safe.to_parquet(self._path(fp, sheet), index=False)
|
||||||
|
|||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
||||||
|
|
||||||
|
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
||||||
|
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
||||||
|
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
||||||
|
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
||||||
|
zrzut z węzła), miał je w całości.
|
||||||
|
|
||||||
|
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
||||||
|
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
||||||
|
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
||||||
|
podatne na modyfikację".
|
||||||
|
|
||||||
|
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
||||||
|
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
||||||
|
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
||||||
|
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
||||||
|
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
||||||
|
|
||||||
|
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
||||||
|
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
||||||
|
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
||||||
|
|
||||||
|
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
||||||
|
|
||||||
|
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
||||||
|
|
||||||
|
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
||||||
|
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
||||||
|
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
||||||
|
|
||||||
|
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
||||||
|
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
||||||
|
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
||||||
|
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.link")
|
||||||
|
|
||||||
|
MAGIC = b"AL1"
|
||||||
|
VERSION = "v1"
|
||||||
|
NONCE_BYTES = 12
|
||||||
|
KEY_BYTES = 32 # AES-256
|
||||||
|
LENGTH_PREFIX = 4
|
||||||
|
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
||||||
|
MAX_SKEW_SECONDS = 300.0
|
||||||
|
|
||||||
|
HEADER_ENC = "X-Astrololo-Enc"
|
||||||
|
HEADER_TS = "X-Astrololo-Enc-Ts"
|
||||||
|
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
||||||
|
|
||||||
|
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||||
|
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||||
|
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||||
|
|
||||||
|
REQUEST, RESPONSE = b"req", b"res"
|
||||||
|
|
||||||
|
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
||||||
|
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
class LinkError(Exception):
|
||||||
|
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- klucze
|
||||||
|
|
||||||
|
def parse_key(raw: str) -> bytes:
|
||||||
|
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
raise LinkError("pusty klucz łącza")
|
||||||
|
try:
|
||||||
|
key = bytes.fromhex(text)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(text, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
||||||
|
if len(key) != KEY_BYTES:
|
||||||
|
raise LinkError(
|
||||||
|
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
||||||
|
f"`openssl rand -hex 32`"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def key_from_env(env_name: str) -> bytes | None:
|
||||||
|
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
||||||
|
raw = os.getenv(env_name, "")
|
||||||
|
return parse_key(raw) if raw.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_required() -> bool:
|
||||||
|
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
||||||
|
|
||||||
|
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
||||||
|
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
||||||
|
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
||||||
|
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
||||||
|
"""
|
||||||
|
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
||||||
|
return HKDF(
|
||||||
|
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
||||||
|
info=b"astrololo/link/" + direction,
|
||||||
|
).derive(link_key)
|
||||||
|
|
||||||
|
|
||||||
|
class Link:
|
||||||
|
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
||||||
|
|
||||||
|
def __init__(self, link_key: bytes) -> None:
|
||||||
|
self._by_direction = {
|
||||||
|
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
||||||
|
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- pojedyncza ramka
|
||||||
|
|
||||||
|
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
||||||
|
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
||||||
|
stamp.encode("ascii"), str(seq).encode("ascii")])
|
||||||
|
|
||||||
|
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
plaintext: bytes) -> bytes:
|
||||||
|
nonce = os.urandom(NONCE_BYTES)
|
||||||
|
sealed = self._by_direction[direction].encrypt(
|
||||||
|
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
||||||
|
return MAGIC + nonce + sealed
|
||||||
|
|
||||||
|
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
frame: bytes) -> bytes:
|
||||||
|
if not frame.startswith(MAGIC):
|
||||||
|
raise LinkError("ramka bez znacznika astrololo")
|
||||||
|
body = frame[len(MAGIC):]
|
||||||
|
if len(body) <= NONCE_BYTES:
|
||||||
|
raise LinkError("ramka za krótka")
|
||||||
|
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
|
||||||
|
try:
|
||||||
|
return self._by_direction[direction].decrypt(
|
||||||
|
nonce, sealed, self._aad(direction, path, stamp, seq))
|
||||||
|
except InvalidTag as exc:
|
||||||
|
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
|
||||||
|
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
|
||||||
|
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
|
||||||
|
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ strumień ramek
|
||||||
|
|
||||||
|
def seal_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
chunks: Iterable[bytes]) -> Iterator[bytes]:
|
||||||
|
for seq, chunk in enumerate(chunks):
|
||||||
|
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
|
||||||
|
|
||||||
|
def open_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
raw: bytes) -> Iterator[bytes]:
|
||||||
|
for seq, frame in enumerate(frames_in(raw)):
|
||||||
|
yield self.open(direction, path, stamp, seq, frame)
|
||||||
|
|
||||||
|
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
|
||||||
|
return b"".join(self.open_stream(direction, path, stamp, raw))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ramkowanie
|
||||||
|
|
||||||
|
def frame_out(payload: bytes) -> bytes:
|
||||||
|
return struct.pack(">I", len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def frames_in(raw: bytes) -> Iterator[bytes]:
|
||||||
|
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
|
||||||
|
offset = 0
|
||||||
|
while offset < len(raw):
|
||||||
|
if offset + LENGTH_PREFIX > len(raw):
|
||||||
|
raise LinkError("urwana ramka (brak nagłówka długości)")
|
||||||
|
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
offset += LENGTH_PREFIX
|
||||||
|
if offset + size > len(raw):
|
||||||
|
raise LinkError("urwana ramka (za mało danych)")
|
||||||
|
yield raw[offset:offset + size]
|
||||||
|
offset += size
|
||||||
|
|
||||||
|
|
||||||
|
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
|
||||||
|
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
|
||||||
|
|
||||||
|
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
|
||||||
|
rozjechać się między dwa odczyty.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
if len(buffer) < LENGTH_PREFIX:
|
||||||
|
return
|
||||||
|
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
if len(buffer) < LENGTH_PREFIX + size:
|
||||||
|
return
|
||||||
|
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
|
||||||
|
del buffer[:LENGTH_PREFIX + size]
|
||||||
|
yield frame
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- świeżość ruchu
|
||||||
|
|
||||||
|
def stamp_now() -> str:
|
||||||
|
return f"{time.time():.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def check_stamp(stamp: str) -> None:
|
||||||
|
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
|
||||||
|
odtworzyć w dowolnym momencie w przyszłości."""
|
||||||
|
try:
|
||||||
|
sent = float(stamp)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise LinkError("brak albo błędny znacznik czasu") from exc
|
||||||
|
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
|
||||||
|
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================== strona serwerowa
|
||||||
|
|
||||||
|
class LinkCryptoMiddleware:
|
||||||
|
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
|
||||||
|
|
||||||
|
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
|
||||||
|
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
|
||||||
|
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, link: Link | None, layer: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.link = link
|
||||||
|
self.layer = layer
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
path = scope["path"]
|
||||||
|
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
|
||||||
|
|
||||||
|
if headers.get(HEADER_ENC.lower()) != VERSION:
|
||||||
|
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
|
||||||
|
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
|
||||||
|
# chcemy po cichu wrócić do jawnego ruchu.
|
||||||
|
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
|
||||||
|
self.layer, path)
|
||||||
|
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
|
||||||
|
|
||||||
|
stamp = headers.get(HEADER_TS.lower(), "")
|
||||||
|
try:
|
||||||
|
check_stamp(stamp)
|
||||||
|
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
|
||||||
|
except LinkError as exc:
|
||||||
|
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
|
||||||
|
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
|
||||||
|
|
||||||
|
scope = dict(scope)
|
||||||
|
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
|
||||||
|
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
|
||||||
|
|
||||||
|
def _sealing_send(self, send, path: str):
|
||||||
|
state: dict = {"stamp": "", "seq": 0}
|
||||||
|
|
||||||
|
async def sealing(message):
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
state["stamp"] = stamp_now()
|
||||||
|
keep = [(k, v) for k, v in message.get("headers", [])
|
||||||
|
if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
message = dict(message)
|
||||||
|
message["headers"] = keep + [
|
||||||
|
(b"content-type", CONTENT_TYPE.encode()),
|
||||||
|
(HEADER_ENC.lower().encode(), VERSION.encode()),
|
||||||
|
(HEADER_TS.lower().encode(), state["stamp"].encode()),
|
||||||
|
]
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
if message["type"] == "http.response.body":
|
||||||
|
chunk = message.get("body", b"")
|
||||||
|
sealed = b""
|
||||||
|
if chunk:
|
||||||
|
sealed = frame_out(self.link.seal(
|
||||||
|
RESPONSE, path, state["stamp"], state["seq"], chunk))
|
||||||
|
state["seq"] += 1
|
||||||
|
return await send({"type": "http.response.body", "body": sealed,
|
||||||
|
"more_body": message.get("more_body", False)})
|
||||||
|
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
return sealing
|
||||||
|
|
||||||
|
|
||||||
|
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
|
||||||
|
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
|
||||||
|
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
|
||||||
|
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
kept.append((b"content-length", str(length).encode()))
|
||||||
|
if length:
|
||||||
|
kept.append((b"content-type", b"application/json"))
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_body(receive) -> bytes:
|
||||||
|
body = bytearray()
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.disconnect":
|
||||||
|
raise LinkError("rozłączenie w trakcie odbioru żądania")
|
||||||
|
body += message.get("body", b"")
|
||||||
|
if not message.get("more_body", False):
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _replay(body: bytes, original):
|
||||||
|
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
|
||||||
|
|
||||||
|
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
|
||||||
|
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
|
||||||
|
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
|
||||||
|
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
|
||||||
|
"""
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
if delivered:
|
||||||
|
return await original()
|
||||||
|
delivered = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return receive
|
||||||
|
|
||||||
|
|
||||||
|
async def _refuse(send, detail: str) -> None:
|
||||||
|
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
|
||||||
|
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
|
||||||
|
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
|
||||||
|
await send({"type": "http.response.start", "status": 400, "headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"content-length", str(len(payload)).encode()),
|
||||||
|
]})
|
||||||
|
await send({"type": "http.response.body", "body": payload})
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, env_name: str, layer: str):
|
||||||
|
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
|
||||||
|
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
|
||||||
|
link_key = key_from_env(env_name)
|
||||||
|
if link_key is None and encryption_required():
|
||||||
|
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
|
||||||
|
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
|
||||||
|
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
|
||||||
|
f"warstwa {layer} nie wystartuje bez klucza łącza"
|
||||||
|
)
|
||||||
|
if link_key is None:
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
|
||||||
|
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
|
||||||
|
"ruch wewnątrz sieci.", env_name, layer,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
link = Link(link_key)
|
||||||
|
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
|
||||||
|
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ strona kliencka
|
||||||
|
|
||||||
|
def call(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
|
||||||
|
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
|
||||||
|
|
||||||
|
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
|
||||||
|
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
|
||||||
|
się niejasnym błędem odszyfrowania.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
|
||||||
|
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
|
||||||
|
# sygnifikatory, o które pytamy bazę.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
|
||||||
|
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
response = client.request(method, url, json=payload, headers=request_headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
path = httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
|
||||||
|
"Content-Type": CONTENT_TYPE})
|
||||||
|
|
||||||
|
response = client.request(method, url, content=body, headers=request_headers)
|
||||||
|
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
log.error("łącze %s odmówiło: %s", path, response.text[:200])
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
|
||||||
|
reply_stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(reply_stamp)
|
||||||
|
return link.open_all(RESPONSE, path, reply_stamp, response.content)
|
||||||
|
|
||||||
|
|
||||||
|
def call_json(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None):
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
return _json.loads(call(client, method, url, payload=payload,
|
||||||
|
headers=headers, link=link))
|
||||||
|
|
||||||
|
|
||||||
|
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
|
||||||
|
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
|
||||||
|
|
||||||
|
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
|
||||||
|
w buforze zamiast zakładać, że każdy kawałek to komplet.
|
||||||
|
"""
|
||||||
|
if link is None:
|
||||||
|
yield from response.iter_bytes()
|
||||||
|
return
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
|
||||||
|
stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(stamp)
|
||||||
|
path = response.request.url.path
|
||||||
|
buffer = bytearray()
|
||||||
|
seq = 0
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
buffer += chunk
|
||||||
|
for frame in unframe_incremental(buffer):
|
||||||
|
yield link.open(RESPONSE, path, stamp, seq, frame)
|
||||||
|
seq += 1
|
||||||
|
if buffer:
|
||||||
|
raise LinkError("strumień urwał się w połowie ramki")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
|
||||||
|
link: Link | None) -> Iterator[str]:
|
||||||
|
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
|
||||||
|
|
||||||
|
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
|
||||||
|
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
|
||||||
|
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
|
||||||
|
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
|
||||||
|
dopiero na znakach nowej linii.
|
||||||
|
|
||||||
|
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
|
||||||
|
działał bez zmian.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx as _httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
|
||||||
|
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
|
||||||
|
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
|
||||||
|
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
with client.stream("POST", url, json=payload, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
for text_line in response.iter_lines():
|
||||||
|
if text_line:
|
||||||
|
yield text_line
|
||||||
|
return
|
||||||
|
|
||||||
|
path = _httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
|
||||||
|
with client.stream("POST", url, content=body, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
buffer = bytearray()
|
||||||
|
for plain in open_response_stream(response, link):
|
||||||
|
buffer += plain
|
||||||
|
while True:
|
||||||
|
nl = buffer.find(b"\n")
|
||||||
|
if nl < 0:
|
||||||
|
break
|
||||||
|
text_line = bytes(buffer[:nl])
|
||||||
|
del buffer[:nl + 1]
|
||||||
|
if text_line:
|
||||||
|
yield text_line.decode("utf-8")
|
||||||
|
if buffer:
|
||||||
|
yield bytes(buffer).decode("utf-8")
|
||||||
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app import link_crypto, security
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||||
from app.providers.factory import build_provider
|
from app.providers.factory import build_provider
|
||||||
@@ -24,6 +25,10 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
||||||
|
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
||||||
|
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
|
||||||
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
|
link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/search", response_model=SearchResult)
|
@app.post("/search", response_model=SearchResult)
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ class SearchQuery(BaseModel):
|
|||||||
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
||||||
value: str = Field(..., description="Szukana wartość.")
|
value: str = Field(..., description="Szukana wartość.")
|
||||||
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
||||||
limit: int = Field(50, ge=1, le=1000)
|
# Górny limit celowo niski: to zapytanie oddaje SUROWE wiersze baz, więc wysoki
|
||||||
|
# pułap zamienia je w narzędzie do masowego pobrania (LOG-32). 5000 = tyle, ile
|
||||||
|
# realnie potrzebuje build_report na jeden obiekt.
|
||||||
|
limit: int = Field(50, ge=1, le=5000)
|
||||||
fields: list[str] | None = Field(
|
fields: list[str] | None = Field(
|
||||||
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ class ExcelDataProvider(DataProvider):
|
|||||||
|
|
||||||
def warmup(self) -> None:
|
def warmup(self) -> None:
|
||||||
for path in self._excel_files():
|
for path in self._excel_files():
|
||||||
|
try:
|
||||||
self._ensure_indexed(path)
|
self._ensure_indexed(path)
|
||||||
|
except Exception as e: # jeden uszkodzony plik nie może zablokować startu
|
||||||
|
print(f"[data] pominięto plik przy indeksowaniu: {path} — {e}")
|
||||||
|
|
||||||
def _excel_files(self) -> list[str]:
|
def _excel_files(self) -> list[str]:
|
||||||
base = Path(self.s.excel_dir)
|
base = Path(self.s.excel_dir)
|
||||||
@@ -112,7 +115,9 @@ class ExcelDataProvider(DataProvider):
|
|||||||
if query.exact:
|
if query.exact:
|
||||||
mask = col.str.lower() == query.value.lower()
|
mask = col.str.lower() == query.value.lower()
|
||||||
else:
|
else:
|
||||||
mask = col.str.lower().str.contains(query.value.lower(), na=False)
|
# regex=False: wartości sygnifikatorów zawierają znaki [ + itd.,
|
||||||
|
# które są metaznakami regex — szukamy dosłownie.
|
||||||
|
mask = col.str.lower().str.contains(query.value.lower(), na=False, regex=False)
|
||||||
matched = frame[mask]
|
matched = frame[mask]
|
||||||
if query.fields:
|
if query.fields:
|
||||||
keep = [c for c in query.fields if c in matched.columns]
|
keep = [c for c in query.fields if c in matched.columns]
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Uwierzytelnianie międzywarstwowe (LOG-32).
|
||||||
|
|
||||||
|
Warstwa danych oddaje SUROWE wiersze baz — to najbardziej wrażliwy punkt całego
|
||||||
|
systemu. Bez tego kontrolera wystarczyłoby uderzyć w nią bezpośrednio, z pominięciem
|
||||||
|
i logiki, i logowania w UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
|
||||||
|
musi go przynieść w nagłówku X-Astrololo-Token.
|
||||||
|
|
||||||
|
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
|
||||||
|
starcie leci ostrzeżenie.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.security")
|
||||||
|
|
||||||
|
HEADER = "X-Astrololo-Token"
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
def token() -> str:
|
||||||
|
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
|
||||||
|
return os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool(token())
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, layer: str) -> None:
|
||||||
|
if not enabled():
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
|
||||||
|
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _guard(request: Request, call_next):
|
||||||
|
if request.url.path in PUBLIC_PATHS or not enabled():
|
||||||
|
return await call_next(request)
|
||||||
|
got = request.headers.get(HEADER, "")
|
||||||
|
if not secrets.compare_digest(got, token()):
|
||||||
|
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
|
||||||
|
status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
@@ -7,3 +7,5 @@ openpyxl>=3.1
|
|||||||
pyarrow>=18.0
|
pyarrow>=18.0
|
||||||
SQLAlchemy>=2.0
|
SQLAlchemy>=2.0
|
||||||
pydantic>=2.10
|
pydantic>=2.10
|
||||||
|
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||||
|
cryptography>=44.0
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Build wieloetapowy — bo `pyswisseph` to rozszerzenie C bez gotowych wheeli.
|
||||||
|
#
|
||||||
|
# Na PyPI (2.10.3.2) wheels kończą się na cp311 i obejmują wyłącznie i686/x86_64.
|
||||||
|
# Dla Pythona 3.12 oraz dla arm64 pip ZAWSZE kompiluje ze źródeł, a `-slim` nie ma
|
||||||
|
# kompilatora — dlatego jednoetapowy build tu padał. Kompilujemy w etapie builder,
|
||||||
|
# a do obrazu finalnego wchodzi już tylko gotowy wheel (bez toolchaina).
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /wheels /wheels
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
|
||||||
|
&& rm -rf /wheels
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Sanity check na etapie budowania: brak działającego swissepha ma wywalić build,
|
||||||
|
# a nie dopiero pierwszy request.
|
||||||
|
RUN python -c "import swisseph as swe; swe.set_ephe_path(None); print('swisseph OK', swe.version)"
|
||||||
|
|
||||||
|
EXPOSE 8003
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
Ten komponent (services/engine-swisseph) jest licencjonowany na warunkach
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE wersja 3 (AGPL-3.0-or-later).
|
||||||
|
|
||||||
|
Powód: linkuje bibliotekę pyswisseph / Swiss Ephemeris (Astrodienst AG), która
|
||||||
|
jest udostępniana na zasadzie podwójnego licencjonowania: AGPL-3.0 ALBO płatna
|
||||||
|
licencja komercyjna. Wybierając wariant AGPL, ten komponent również jest AGPL.
|
||||||
|
|
||||||
|
WAŻNE — IZOLACJA: ten komponent jest celowo wydzielony jako osobny proces/usługa
|
||||||
|
i komunikuje się z resztą systemu wyłącznie przez HTTP. Pozostałe komponenty
|
||||||
|
projektu (warstwa prezentacji, warstwa logiczna z silnikiem własnym, warstwa
|
||||||
|
danych) NIE są dziełem pochodnym tego komponentu ani Swiss Ephemeris i pozostają
|
||||||
|
na licencji permisywnej. Ten komponent NIE wchodzi do dystrybucji zamkniętego
|
||||||
|
produktu — służy jako wyrocznia walidacyjna / tryb porównawczy (dev/CI).
|
||||||
|
|
||||||
|
Pełny tekst licencji AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.txt
|
||||||
|
|
||||||
|
Alternatywa: zamiast wariantu AGPL można nabyć komercyjną licencję Swiss
|
||||||
|
Ephemeris od Astrodienst AG — wówczas warunki tego komponentu należy dostosować
|
||||||
|
do tej licencji.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# engine-swisseph (silnik B — AGPL, izolowany)
|
||||||
|
|
||||||
|
Osobna, **opcjonalna** usługa będąca drugim silnikiem efemeryd (LOG‑27). Liczy
|
||||||
|
pozycje przez **pyswisseph / Swiss Ephemeris** i służy jako **wyrocznia
|
||||||
|
walidacyjna / tryb porównawczy** (LOG‑25/26) dla naszego silnika własnego.
|
||||||
|
|
||||||
|
## ⚠️ Licencja
|
||||||
|
Ten komponent jest **AGPL‑3.0** (bo linkuje Swiss Ephemeris) — patrz [LICENSE](LICENSE).
|
||||||
|
Jest **wydzielony jako osobny proces** i wołany przez HTTP, więc nie „zaraża"
|
||||||
|
permisywnej reszty systemu. **Nie wchodzi do dystrybucji zamkniętego produktu.**
|
||||||
|
|
||||||
|
## API
|
||||||
|
- `POST /positions` → `{when_utc, lat, lon, objects?}` → pozycje (ten sam kształt co silnik własny)
|
||||||
|
- `GET /health`
|
||||||
|
|
||||||
|
Tryb Moshiera (`FLG_MOSEPH`) — bez plików efemeryd, zero konfiguracji.
|
||||||
|
|
||||||
|
## Build obrazu
|
||||||
|
```bash
|
||||||
|
docker compose --profile comparison build engine-swisseph
|
||||||
|
```
|
||||||
|
Dockerfile jest **wieloetapowy** i to nie jest ozdobnik: `pyswisseph` to rozszerzenie
|
||||||
|
C, a na PyPI (2.10.3.2) gotowe wheels kończą się na **cp311** i obejmują wyłącznie
|
||||||
|
**i686/x86_64**. Dla Pythona 3.12 oraz dla arm64 pip musi kompilować ze źródeł, więc
|
||||||
|
sam `python:3.12-slim` (bez kompilatora) build wywracał. Kompilacja idzie w etapie
|
||||||
|
`builder` (`build-essential`), a do obrazu finalnego trafia już tylko gotowy wheel —
|
||||||
|
runtime zostaje czysty i mały. Pierwszy build trwa ~1–2 min, kolejne idą z cache warstw.
|
||||||
|
|
||||||
|
Build kończy się sanity-checkiem (`import swisseph`), żeby niedziałający silnik
|
||||||
|
wykrzaczył build, a nie dopiero pierwszy request.
|
||||||
|
|
||||||
|
## Uruchomienie (tylko profil porównawczy / dev / CI)
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt # wymaga kompilatora C (patrz wyżej)
|
||||||
|
uvicorn app.main:app --port 8003
|
||||||
|
```
|
||||||
|
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
|
||||||
|
aby włączyć silnik B (tryb dual-run i testy kontraktowe silnika B).
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""engine-swisseph — IZOLOWANA usługa silnika B (AGPL).
|
||||||
|
|
||||||
|
UWAGA LICENCYJNA: ta usługa linkuje pyswisseph / Swiss Ephemeris, więc jest
|
||||||
|
objęta **AGPL-3.0** i jest licencjonowana osobno (patrz ./LICENSE). Jest celowo
|
||||||
|
wydzielona jako osobny proces i wołana przez HTTP — dzięki temu permisywny
|
||||||
|
produkt (prezentacja + logika z silnikiem własnym + dane) NIE jest linkowany z
|
||||||
|
kodem AGPL i nie podlega jego obowiązkom (LOG-27).
|
||||||
|
|
||||||
|
Rola: wyrocznia walidacyjna / tryb porównawczy (LOG-25/26). Nie wchodzi do
|
||||||
|
dystrybucji zamkniętej — uruchamiana tylko w profilu porównawczym/dev/CI.
|
||||||
|
|
||||||
|
Udostępnia ten sam kontrakt co RemoteEngine po stronie warstwy logicznej.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import swisseph as swe
|
||||||
|
|
||||||
|
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)")
|
||||||
|
|
||||||
|
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
|
||||||
|
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
|
||||||
|
|
||||||
|
_PLANETS = {
|
||||||
|
"Sun": swe.SUN, "Moon": swe.MOON, "Mercury": swe.MERCURY, "Venus": swe.VENUS,
|
||||||
|
"Mars": swe.MARS, "Jupiter": swe.JUPITER, "Saturn": swe.SATURN,
|
||||||
|
"Uranus": swe.URANUS, "Neptune": swe.NEPTUNE, "Pluto": swe.PLUTO,
|
||||||
|
# punkty wirtualne — mean, jak w silniku własnym (parzystość LOG-28)
|
||||||
|
"North Node": swe.MEAN_NODE, "Lilith": swe.MEAN_APOG,
|
||||||
|
# "South Node" obsługiwany pochodnie w /positions: NN + 180°
|
||||||
|
}
|
||||||
|
DEFAULT_OBJECTS = [
|
||||||
|
"Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn",
|
||||||
|
"Uranus", "Neptune", "Pluto", "North Node", "South Node", "Lilith",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PositionsRequest(BaseModel):
|
||||||
|
when_utc: datetime
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
objects: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/positions")
|
||||||
|
def positions(req: PositionsRequest) -> dict:
|
||||||
|
d = req.when_utc
|
||||||
|
ut_hours = d.hour + d.minute / 60.0 + d.second / 3600.0
|
||||||
|
jd = swe.julday(d.year, d.month, d.day, ut_hours) # czas uniwersalny
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for name in (req.objects or DEFAULT_OBJECTS):
|
||||||
|
lookup = "North Node" if name == "South Node" else name
|
||||||
|
xx, _retflag = swe.calc_ut(jd, _PLANETS[lookup], _FLAGS)
|
||||||
|
lon, lat, _dist, lon_speed = xx[0], xx[1], xx[2], xx[3]
|
||||||
|
if name == "South Node":
|
||||||
|
lon, lat = lon + 180.0, -lat
|
||||||
|
rows.append({
|
||||||
|
"name": name,
|
||||||
|
"longitude": lon % 360.0,
|
||||||
|
"latitude": lat,
|
||||||
|
"speed": lon_speed,
|
||||||
|
"retrograde": lon_speed < 0,
|
||||||
|
})
|
||||||
|
return {"engine": "swisseph", "positions": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# UWAGA: pyswisseph (Swiss Ephemeris) jest na licencji AGPL-3.0 — dlatego ta
|
||||||
|
# usługa jest wydzielona i licencjonowana osobno (patrz LICENSE). Nie instaluj
|
||||||
|
# tego w obrazie permisywnego produktu.
|
||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
pyswisseph>=2.10
|
||||||
@@ -6,8 +6,31 @@ i nie w bazie.
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
||||||
|
- `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Zwraca też sektę i 7 Lots hermetycznych z domami (LOG-08). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
|
||||||
|
- `POST /chart/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów)
|
||||||
|
- `POST /chart/profections` → `{when_utc, lat, lon, start_age?, count?}` → profekcje roczne: wiek, profektowany Asc, Władca Roku (+MC/Su/Mo) (LOG-10)
|
||||||
|
- `POST /chart/return` → `{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12)
|
||||||
|
- `POST /chart/firdaria` → `{when_utc, lat, lon}` → Firdaria: sekta (dzień/noc), okresy główne i podokresy time-lordów (LOG-11)
|
||||||
|
- `POST /chart/timeline` → `{when_utc, lat, lon, from_date, to_date, techniques?}` → zbiorcza oś czasu: profekcje + Solar Return + dyrekcje solar-arc + Firdaria, posortowane (technique | significator | start | exact | end); z interpret=true dopina interpretacje z bazy do dat (LOG-14, 1B->2B)
|
||||||
|
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
|
||||||
- `GET /health` (sprawdza też warstwę bazodanową)
|
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||||
|
|
||||||
|
## Silnik efemeryd (LOG-24, „wymienny silnik liczący")
|
||||||
|
W `app/engine/` żyje pluggable silnik za interfejsem `EphemerisEngine`:
|
||||||
|
- **`SkyfieldEngine`** — własny, permisywny (Skyfield MIT + dane JPL public domain). Domyślny.
|
||||||
|
- **`RemoteEngine`** — klient OSOBNEJ, izolowanej usługi `engine-swisseph` (AGPL), używany tylko w trybie porównawczym.
|
||||||
|
|
||||||
|
Wybór: `EPHEMERIS_ENGINE=own|swisseph`. Silnik B włącza się przez `ENGINE_SWISSEPH_URL`.
|
||||||
|
|
||||||
|
Walidacja (LOG-25/28): `app/engine/compare.py` zestawia oba silniki z progiem tolerancji;
|
||||||
|
ten sam kontrakt parzystości obowiązuje każdy silnik. Nasz `SkyfieldEngine` zgadza się
|
||||||
|
ze Swiss Ephemeris **co do ~1″** na horoskopie referencyjnym (patrz `tests/`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
PYTHONPATH=. pytest tests -q # silnik B pomijany, jeśli ENGINE_SWISSEPH_URL nieustawiony
|
||||||
|
```
|
||||||
|
|
||||||
## Zależności w dół
|
## Zależności w dół
|
||||||
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
||||||
Nie wie, czy pod spodem jest Excel czy SQL.
|
Nie wie, czy pod spodem jest Excel czy SQL.
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Rozwijanie skrótów sygnifikatorów do postaci czytelnej (na bazie SIGNIFICATORS KEY).
|
||||||
|
|
||||||
|
W bazie zapis jest skrótowy z prefiksem `[` (np. `[Su in [Tau`, `[Sa [conj [Su in 6th H.`).
|
||||||
|
Ten moduł zamienia go na tekst czytelny: „Sun in Taurus", „Saturn conjunction Sun
|
||||||
|
in 6th house". Słownik pochodzi z pliku SIGNIFICATORS KEY (mały, stabilny — wpięty
|
||||||
|
jako built-in; można rozszerzać).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# skrót (bez nawiasu) -> pełna nazwa
|
||||||
|
ABBREVIATIONS: dict[str, str] = {
|
||||||
|
# znaki zodiaku
|
||||||
|
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer",
|
||||||
|
"Leo": "Leo", "Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio",
|
||||||
|
"Sag": "Sagittarius", "Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
|
||||||
|
# planety klasyczne + światła
|
||||||
|
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus",
|
||||||
|
"Ma": "Mars", "Ju": "Jupiter", "Sa": "Saturn",
|
||||||
|
# planety nowożytne
|
||||||
|
"Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
|
||||||
|
# węzły i punkty
|
||||||
|
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||||
|
# osie
|
||||||
|
"Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli",
|
||||||
|
# Lots (punkty arabskie)
|
||||||
|
"PF": "Part of Fortune", "Fortune": "Part of Fortune", "Spirit": "Lot of Spirit",
|
||||||
|
# aspekty
|
||||||
|
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
|
||||||
|
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
|
||||||
|
"sesquisq": "sesquisquare", "quincunx": "quincunx", "asp": "aspect",
|
||||||
|
# domy jako tokeny [h1..[h12
|
||||||
|
**{f"h{i}": f"{i}th house" for i in range(1, 13)},
|
||||||
|
# ruch
|
||||||
|
"Rx": "retrograde", "R": "retrograde",
|
||||||
|
}
|
||||||
|
# poprawki nieregularnych liczebników domów
|
||||||
|
ABBREVIATIONS.update({"h1": "1st house", "h2": "2nd house", "h3": "3rd house"})
|
||||||
|
|
||||||
|
# rozwinięcia słów spoza składni `[`
|
||||||
|
_WORDS = {
|
||||||
|
"affl.": "afflicted",
|
||||||
|
"P. Dec.": "parallel of declination",
|
||||||
|
"espec.": "especially",
|
||||||
|
}
|
||||||
|
|
||||||
|
_TOKEN = re.compile(r"\[([A-Za-z]+)")
|
||||||
|
_HOUSE = re.compile(r"(\d+(?:st|nd|rd|th))\s*H\.", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def expand(text: str) -> str:
|
||||||
|
"""Zamienia skróty na pełne nazwy; nieznane tokeny zostawia bez nawiasu."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
out = _TOKEN.sub(lambda m: ABBREVIATIONS.get(m.group(1), m.group(1)), text)
|
||||||
|
out = _HOUSE.sub(lambda m: f"{m.group(1)} house", out)
|
||||||
|
for k, v in _WORDS.items():
|
||||||
|
out = out.replace(k, v)
|
||||||
|
return out
|
||||||
@@ -5,26 +5,50 @@ Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers() -> dict[str, str]:
|
||||||
|
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
|
||||||
|
token = os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
return {"X-Astrololo-Token": token} if token else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _link() -> link_crypto.Link | None:
|
||||||
|
"""Klucz łącza logika↔dane. Czytany przy każdym wywołaniu, bo konfiguracja
|
||||||
|
może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
|
||||||
|
key = link_crypto.key_from_env(link_crypto.ENV_LOGIC_DATA)
|
||||||
|
return link_crypto.Link(key) if key else None
|
||||||
|
|
||||||
|
|
||||||
class DataClient:
|
class DataClient:
|
||||||
def __init__(self, base_url: str | None = None) -> None:
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||||
|
|
||||||
def search(self, key: str, value: str, exact: bool, limit: int) -> dict[str, Any]:
|
def search(
|
||||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit}
|
self,
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
key: str,
|
||||||
r = client.post(f"{self.base_url}/search", json=payload)
|
value: str,
|
||||||
r.raise_for_status()
|
exact: bool,
|
||||||
return r.json()
|
limit: int,
|
||||||
|
fields: list[str] | None = None,
|
||||||
|
) -> 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 health(self) -> dict[str, Any]:
|
def health(self) -> dict[str, Any]:
|
||||||
|
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
||||||
|
# nie mają, a nie przechodzi tędy nic z baz.
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
r = client.get(f"{self.base_url}/health")
|
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Aspekty — kąty między obiektami (LOG-06, wersja: aspekty główne).
|
||||||
|
|
||||||
|
Czysta matematyka na policzonych długościach ekliptycznych. Dla każdej pary
|
||||||
|
obiektów sprawdzamy, czy ich separacja kątowa mieści się w orbie któregoś z
|
||||||
|
aspektów głównych. Applying/separating (aplikacja/separacja) — na później.
|
||||||
|
|
||||||
|
Tokeny bazy (z SIGNIFICATORS KEY): [conj, [sex, [sq, [tri, [opp.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
MAJOR = {
|
||||||
|
"conjunction": 0.0,
|
||||||
|
"sextile": 60.0,
|
||||||
|
"square": 90.0,
|
||||||
|
"trine": 120.0,
|
||||||
|
"opposition": 180.0,
|
||||||
|
}
|
||||||
|
DB_TOKEN = {
|
||||||
|
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
|
||||||
|
"trine": "[tri", "opposition": "[opp",
|
||||||
|
}
|
||||||
|
PL_NAME = {
|
||||||
|
"conjunction": "koniunkcja", "sextile": "sekstyl", "square": "kwadratura",
|
||||||
|
"trine": "trygon", "opposition": "opozycja",
|
||||||
|
}
|
||||||
|
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]."""
|
||||||
|
d = abs(a - b) % 360.0
|
||||||
|
return min(d, 360.0 - d)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: float = 0.01) -> bool | None:
|
||||||
|
"""Czy aspekt aplikuje (dokładność 0° dopiero nastąpi)?
|
||||||
|
|
||||||
|
Porównujemy odchyłkę od dokładnego kąta teraz i po małym kroku czasu
|
||||||
|
(pozycje przesunięte o prędkość·dt). Malejąca odchyłka = applying.
|
||||||
|
dt celowo małe (0,01 doby), by szybki Księżyc nie „przeskoczył" dokładności.
|
||||||
|
Zwraca None, gdy brak prędkości (nie da się rozstrzygnąć).
|
||||||
|
"""
|
||||||
|
if sa is None or sb is None:
|
||||||
|
return None
|
||||||
|
dev_now = abs(separation(la, lb) - angle)
|
||||||
|
dev_next = abs(separation(la + sa * dt, lb + sb * dt) - angle)
|
||||||
|
return dev_next < dev_now
|
||||||
|
|
||||||
|
|
||||||
|
def find_aspects(
|
||||||
|
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 głównych; gdy znane są prędkości, każdy aspekt ma
|
||||||
|
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
|
||||||
|
|
||||||
|
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
n = len(positions)
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
a, b = positions[i], positions[j]
|
||||||
|
if _is_rigid(a["name"], b["name"]):
|
||||||
|
continue
|
||||||
|
la, lb = a.get("decimal"), b.get("decimal")
|
||||||
|
if la is None or lb is None:
|
||||||
|
continue
|
||||||
|
sep = separation(float(la), float(lb))
|
||||||
|
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
|
||||||
|
for asp, angle in MAJOR.items():
|
||||||
|
dev = abs(sep - angle)
|
||||||
|
if dev <= allowed:
|
||||||
|
applying = _is_applying(
|
||||||
|
float(la), float(lb), a.get("speed"), b.get("speed"), angle
|
||||||
|
)
|
||||||
|
row = {
|
||||||
|
"obj1": a["name"], "obj2": b["name"],
|
||||||
|
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
|
||||||
|
}
|
||||||
|
if applying is not None:
|
||||||
|
row["applying"] = applying
|
||||||
|
row["as"] = "A" if applying else "S"
|
||||||
|
out.append(row)
|
||||||
|
break # jedna para = jeden aspekt
|
||||||
|
return out
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Interfejs silnika efemeryd (LOG-24).
|
||||||
|
|
||||||
|
To jest „wymienny silnik liczący". Dziś realizują go: SkyfieldEngine (własny,
|
||||||
|
permisywny, in-process) i RemoteEngine (klient izolowanej usługi swisseph, AGPL).
|
||||||
|
Warstwa logiczna woła tylko ten interfejs — nie wie, który silnik liczy.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment, ObjectPosition
|
||||||
|
|
||||||
|
|
||||||
|
class EphemerisEngine(ABC):
|
||||||
|
name: str = "base"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def positions(
|
||||||
|
self, moment: ChartMoment, objects: list[str] | None = None
|
||||||
|
) -> list[ObjectPosition]:
|
||||||
|
"""Pozycje obiektów dla danego momentu (LOG-01). None = zestaw domyślny."""
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
return {"engine": self.name, "status": "ok"}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Złożenie pełnego horoskopu: pozycje + osie + domy (LOG-01 + LOG-05).
|
||||||
|
|
||||||
|
Silnik-agnostyczne: potrzebuje tylko `positions()` oraz (dla osi/domów)
|
||||||
|
`sidereal()`. Jeśli silnik nie umie policzyć czasu gwiazdowego, zwraca same
|
||||||
|
pozycje.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine import zodiac as Z
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
from app.engine.formats import SIGNS, absolute, decimal, in_sign, norm360, sign_index
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(name: str, lon: float, off: float = 0.0) -> dict:
|
||||||
|
lon = norm360(lon - off)
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"sign": SIGNS[sign_index(lon)],
|
||||||
|
"in_sign": in_sign(lon),
|
||||||
|
"decimal": round(lon, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_pos(pdict: dict, off: float) -> None:
|
||||||
|
"""Przelicza etykiety pozycji na wybrany zodiak (in-place). off=0 → bez zmian."""
|
||||||
|
if not off:
|
||||||
|
return
|
||||||
|
lon = norm360(pdict["decimal"] - off)
|
||||||
|
pdict["sign"] = SIGNS[sign_index(lon)]
|
||||||
|
pdict["in_sign"] = in_sign(lon)
|
||||||
|
pdict["absolute"] = absolute(lon)
|
||||||
|
pdict["decimal"] = decimal(lon)
|
||||||
|
|
||||||
|
|
||||||
|
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||||
|
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
||||||
|
from app.engine.aspects import find_aspects
|
||||||
|
|
||||||
|
positions = engine.positions(moment)
|
||||||
|
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
||||||
|
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
|
||||||
|
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
||||||
|
|
||||||
|
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
||||||
|
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
||||||
|
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
||||||
|
result["zodiac"] = zodiac
|
||||||
|
if zodiac in Z.SIDEREAL:
|
||||||
|
result["ayanamsha"] = round(off, 6)
|
||||||
|
for pdict in result["positions"]:
|
||||||
|
_shift_pos(pdict, off)
|
||||||
|
|
||||||
|
if not hasattr(engine, "sidereal"):
|
||||||
|
return result
|
||||||
|
|
||||||
|
ramc, eps = engine.sidereal(moment)
|
||||||
|
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||||
|
mc = H.compute_mc(ramc, eps)
|
||||||
|
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||||
|
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
||||||
|
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
|
||||||
|
from app.engine.firdaria import is_day_birth
|
||||||
|
from app.engine.lots import compute_lots
|
||||||
|
|
||||||
|
pts = {p.name: p.longitude for p in positions}
|
||||||
|
pts["Asc"] = asc
|
||||||
|
day = is_day_birth(pts["Sun"], asc, mc) if "Sun" in pts else True
|
||||||
|
result["sect"] = "day" if day else "night"
|
||||||
|
result["lots"] = [
|
||||||
|
{**lot,
|
||||||
|
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
||||||
|
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
||||||
|
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
||||||
|
"house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej
|
||||||
|
for lot in compute_lots(pts, day, lots_method)
|
||||||
|
]
|
||||||
|
return result
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Harness walidacyjno-porównawczy (LOG-25) i kontrakt parzystości (LOG-28).
|
||||||
|
|
||||||
|
`compare_positions` zestawia wyniki dwóch silników z progami tolerancji per
|
||||||
|
wielkość i flaguje rozbieżności — używane w testach regresyjnych (CI) i w trybie
|
||||||
|
dual-run na żądanie (LOG-26).
|
||||||
|
|
||||||
|
`check_engine_contract` to wspólny kontrakt, który MUSI spełnić każdy silnik —
|
||||||
|
ten sam test uruchamiamy dla EngineA i EngineB (LOG-28).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
|
||||||
|
|
||||||
|
|
||||||
|
def angular_delta(a: float, b: float) -> float:
|
||||||
|
"""Najmniejsza różnica kątów w stopniach (z obsługą zawinięcia 0/360)."""
|
||||||
|
return ((a - b + 180.0) % 360.0) - 180.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ObjectDiff:
|
||||||
|
name: str
|
||||||
|
lon_a: float
|
||||||
|
lon_b: float
|
||||||
|
delta_arcsec: float # różnica długości w sekundach łuku
|
||||||
|
lat_delta: float
|
||||||
|
speed_sign_mismatch: bool
|
||||||
|
over_tolerance: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CompareReport:
|
||||||
|
lon_tol_arcsec: float
|
||||||
|
diffs: list[ObjectDiff] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return not any(d.over_tolerance or d.speed_sign_mismatch for d in self.diffs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_arcsec(self) -> float:
|
||||||
|
return max((abs(d.delta_arcsec) for d in self.diffs), default=0.0)
|
||||||
|
|
||||||
|
def summary(self) -> dict:
|
||||||
|
return {
|
||||||
|
"ok": self.ok,
|
||||||
|
"objects": len(self.diffs),
|
||||||
|
"max_arcsec": round(self.max_arcsec, 2),
|
||||||
|
"tolerance_arcsec": self.lon_tol_arcsec,
|
||||||
|
"flagged": [d.name for d in self.diffs if d.over_tolerance or d.speed_sign_mismatch],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compare_positions(
|
||||||
|
a: list[ObjectPosition],
|
||||||
|
b: list[ObjectPosition],
|
||||||
|
lon_tol_arcsec: float = 120.0,
|
||||||
|
) -> CompareReport:
|
||||||
|
by_b = {p.name: p for p in b}
|
||||||
|
report = CompareReport(lon_tol_arcsec=lon_tol_arcsec)
|
||||||
|
for pa in a:
|
||||||
|
pb = by_b.get(pa.name)
|
||||||
|
if pb is None:
|
||||||
|
continue
|
||||||
|
d_arcsec = angular_delta(pa.longitude, pb.longitude) * 3600.0
|
||||||
|
report.diffs.append(
|
||||||
|
ObjectDiff(
|
||||||
|
name=pa.name,
|
||||||
|
lon_a=pa.longitude,
|
||||||
|
lon_b=pb.longitude,
|
||||||
|
delta_arcsec=d_arcsec,
|
||||||
|
lat_delta=pa.latitude - pb.latitude,
|
||||||
|
speed_sign_mismatch=(pa.retrograde != pb.retrograde),
|
||||||
|
over_tolerance=abs(d_arcsec) > lon_tol_arcsec,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def compare_engines(
|
||||||
|
engine_a: EphemerisEngine,
|
||||||
|
engine_b: EphemerisEngine,
|
||||||
|
moment: ChartMoment,
|
||||||
|
lon_tol_arcsec: float = 120.0,
|
||||||
|
) -> CompareReport:
|
||||||
|
return compare_positions(
|
||||||
|
engine_a.positions(moment), engine_b.positions(moment), lon_tol_arcsec
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_engine_contract(engine: EphemerisEngine, moment: ChartMoment) -> None:
|
||||||
|
"""Kontrakt parzystości (LOG-28). Rzuca AssertionError przy naruszeniu."""
|
||||||
|
positions = engine.positions(moment)
|
||||||
|
names = {p.name for p in positions}
|
||||||
|
assert set(DEFAULT_OBJECTS) <= names, f"brakuje obiektów: {set(DEFAULT_OBJECTS) - names}"
|
||||||
|
for p in positions:
|
||||||
|
assert 0.0 <= p.longitude < 360.0, f"{p.name}: długość poza zakresem ({p.longitude})"
|
||||||
|
assert -90.0 <= p.latitude <= 90.0, f"{p.name}: szerokość poza zakresem ({p.latitude})"
|
||||||
|
assert p.retrograde in (True, False)
|
||||||
|
d = p.as_dict()
|
||||||
|
assert d["sign"] and d["in_sign"], f"{p.name}: brak formatów"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Fabryka silników (LOG-24) — jedyne miejsce znające konkretne implementacje.
|
||||||
|
|
||||||
|
EPHEMERIS_ENGINE = own (domyślnie, permisywny Skyfield) | swisseph (zdalny AGPL).
|
||||||
|
`available_engines()` zwraca to, co da się dziś uruchomić — używane przez
|
||||||
|
harness porównawczy i testy parzystości.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
|
||||||
|
|
||||||
|
def build_engine(name: str | None = None) -> EphemerisEngine:
|
||||||
|
name = (name or os.getenv("EPHEMERIS_ENGINE", "own")).lower()
|
||||||
|
if name in ("swisseph", "remote", "b"):
|
||||||
|
from app.engine.remote_engine import RemoteEngine
|
||||||
|
|
||||||
|
return RemoteEngine()
|
||||||
|
from app.engine.skyfield_engine import SkyfieldEngine
|
||||||
|
|
||||||
|
return SkyfieldEngine()
|
||||||
|
|
||||||
|
|
||||||
|
def available_engines() -> dict[str, EphemerisEngine]:
|
||||||
|
"""Silniki gotowe do użycia teraz (own zawsze; swisseph jeśli skonfigurowany)."""
|
||||||
|
engines: dict[str, EphemerisEngine] = {"own": build_engine("own")}
|
||||||
|
if os.getenv("ENGINE_SWISSEPH_URL"):
|
||||||
|
engines["swisseph"] = build_engine("swisseph")
|
||||||
|
return engines
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Firdaria (LOG-11) — perska technika time-lord.
|
||||||
|
|
||||||
|
Sekwencja okresów głównych zależy od sekty (dzień/noc). Sekta: urodzenie dzienne,
|
||||||
|
gdy Słońce jest nad horyzontem, czyli po tej samej stronie osi Asc–Dsc co MC.
|
||||||
|
|
||||||
|
Klasyczne długości okresów (lata): Su 10, Ve 8, Me 13, Mo 9, Sa 11, Ju 12, Ma 7
|
||||||
|
(razem 70) + Węzeł Północny 3 + Węzeł Południowy 2 = 75 lat. Każdy okres główny
|
||||||
|
planety dzieli się na 7 podokresów (sub-lord w tej samej kolejności, cyklicznie).
|
||||||
|
Węzły — bez podokresów (najczęstsza konwencja).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
DAY_ORDER = ["Sun", "Venus", "Mercury", "Moon", "Saturn", "Jupiter", "Mars"]
|
||||||
|
NIGHT_ORDER = ["Moon", "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury"]
|
||||||
|
YEARS = {"Sun": 10, "Venus": 8, "Mercury": 13, "Moon": 9,
|
||||||
|
"Saturn": 11, "Jupiter": 12, "Mars": 7}
|
||||||
|
NODES = [("North Node", 3), ("South Node", 2)]
|
||||||
|
DAYS_PER_YEAR = 365.2422
|
||||||
|
|
||||||
|
|
||||||
|
def is_day_birth(sun_lon: float, asc: float, mc: float) -> bool:
|
||||||
|
"""Słońce nad horyzontem = ta sama półkula osi Asc–Dsc co MC."""
|
||||||
|
return (((sun_lon - asc) % 360.0) < 180.0) == (((mc - asc) % 360.0) < 180.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _date(birth: datetime, years: float) -> str:
|
||||||
|
return (birth + timedelta(days=years * DAYS_PER_YEAR)).date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def firdaria(birth: datetime, sun_lon: float, asc: float, mc: float) -> dict:
|
||||||
|
"""Pełny rozkład Firdarii: sekta, kolejność, okresy główne i podokresy."""
|
||||||
|
day = is_day_birth(sun_lon, asc, mc)
|
||||||
|
order = DAY_ORDER if day else NIGHT_ORDER
|
||||||
|
majors = [(lord, YEARS[lord]) for lord in order] + NODES
|
||||||
|
|
||||||
|
periods: list[dict] = []
|
||||||
|
age = 0.0
|
||||||
|
for lord, yrs in majors:
|
||||||
|
period = {"lord": lord, "years": yrs,
|
||||||
|
"start": _date(birth, age), "end": _date(birth, age + yrs)}
|
||||||
|
if lord in YEARS: # planeta -> 7 podokresów
|
||||||
|
sub_len = yrs / 7.0
|
||||||
|
i = order.index(lord)
|
||||||
|
sub_age = age
|
||||||
|
subs: list[dict] = []
|
||||||
|
for k in range(7):
|
||||||
|
sub_lord = order[(i + k) % 7]
|
||||||
|
subs.append({"lord": sub_lord,
|
||||||
|
"start": _date(birth, sub_age),
|
||||||
|
"end": _date(birth, sub_age + sub_len)})
|
||||||
|
sub_age += sub_len
|
||||||
|
period["sub"] = subs
|
||||||
|
periods.append(period)
|
||||||
|
age += yrs
|
||||||
|
return {"sect": "day" if day else "night", "order": order, "periods": periods}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Formatowanie długości ekliptycznej (LOG-01: kilka zapisów).
|
||||||
|
|
||||||
|
Astrolog myśli w stopniach/minutach/sekundach w znaku, Excel woli dziesiętne,
|
||||||
|
a część technik używa pozycji absolutnej 0–360°. Tu są czyste, bezstanowe
|
||||||
|
funkcje konwersji — bez zależności od jakiegokolwiek silnika.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
SIGNS = [
|
||||||
|
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||||
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||||||
|
]
|
||||||
|
SIGN_ABBR = ["Ari", "Tau", "Gem", "Can", "Leo", "Vir",
|
||||||
|
"Lib", "Sco", "Sag", "Cap", "Aqu", "Pis"]
|
||||||
|
|
||||||
|
|
||||||
|
def norm360(lon: float) -> float:
|
||||||
|
return lon % 360.0
|
||||||
|
|
||||||
|
|
||||||
|
def sign_index(lon: float) -> int:
|
||||||
|
"""0 = Aries … 11 = Pisces."""
|
||||||
|
return int(norm360(lon) // 30)
|
||||||
|
|
||||||
|
|
||||||
|
def _dms(deg: float) -> tuple[int, int, int]:
|
||||||
|
"""Rozkład stopni (>=0) na (°, ', ") z poprawnym przeniesieniem zaokrąglenia."""
|
||||||
|
total = round(deg * 3600)
|
||||||
|
d, rem = divmod(total, 3600)
|
||||||
|
m, s = divmod(rem, 60)
|
||||||
|
return d, m, s
|
||||||
|
|
||||||
|
|
||||||
|
def in_sign(lon: float) -> str:
|
||||||
|
"""Np. 'Tau 28°12'57\"' — pozycja w znaku."""
|
||||||
|
lon = norm360(lon)
|
||||||
|
idx = sign_index(lon)
|
||||||
|
d, m, s = _dms(lon - idx * 30)
|
||||||
|
if d >= 30: # zaokrąglenie przekroczyło granicę znaku
|
||||||
|
idx = (idx + 1) % 12
|
||||||
|
d -= 30
|
||||||
|
return f"{SIGN_ABBR[idx]} {d}°{m:02d}'{s:02d}\""
|
||||||
|
|
||||||
|
|
||||||
|
def absolute(lon: float) -> str:
|
||||||
|
"""Np. '58°12'57\"' — pozycja absolutna 0–360°."""
|
||||||
|
d, m, s = _dms(norm360(lon))
|
||||||
|
return f"{d}°{m:02d}'{s:02d}\""
|
||||||
|
|
||||||
|
|
||||||
|
def decimal(lon: float, places: int = 6) -> float:
|
||||||
|
return round(norm360(lon), places)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Osie i domy — czysta matematyka sferyczna (LOG-05).
|
||||||
|
|
||||||
|
Bezstanowe funkcje: z lokalnego czasu gwiazdowego (RAMC), nachylenia ekliptyki
|
||||||
|
(ε) i szerokości geograficznej (φ) wyliczają Ascendent i MC, a stąd cusps domów
|
||||||
|
dla prostych systemów (Whole Sign, Equal, Porphyry). Niezależne od silnika —
|
||||||
|
silnik dostarcza tylko RAMC i ε.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
||||||
|
|
||||||
|
WHOLE_SIGN = "whole_sign"
|
||||||
|
EQUAL = "equal"
|
||||||
|
PORPHYRY = "porphyry"
|
||||||
|
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
|
||||||
|
|
||||||
|
|
||||||
|
def mean_obliquity(tt_jd: float) -> float:
|
||||||
|
"""Średnie nachylenie ekliptyki [°] dla daty (Julian TT). Wystarcza do domów."""
|
||||||
|
t = (tt_jd - 2451545.0) / 36525.0
|
||||||
|
arcsec = 84381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t ** 3
|
||||||
|
return arcsec / 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mc(ramc_deg: float, eps_deg: float) -> float:
|
||||||
|
r, e = math.radians(ramc_deg), math.radians(eps_deg)
|
||||||
|
mc = math.atan2(math.sin(r), math.cos(r) * math.cos(e))
|
||||||
|
return norm360(math.degrees(mc))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
|
||||||
|
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
||||||
|
asc = math.atan2(
|
||||||
|
math.cos(r),
|
||||||
|
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
||||||
|
)
|
||||||
|
return norm360(math.degrees(asc))
|
||||||
|
|
||||||
|
|
||||||
|
def _trisect(a: float, b: float) -> tuple[float, float]:
|
||||||
|
"""Dwa punkty dzielące łuk a→b (w kierunku zodiaku) na trzy równe części."""
|
||||||
|
span = (b - a) % 360.0
|
||||||
|
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
|
||||||
|
|
||||||
|
|
||||||
|
def cusps(asc: float, mc: float, system: str) -> list[float]:
|
||||||
|
"""Zwraca 12 cusps (długości) domów 1..12."""
|
||||||
|
if system == WHOLE_SIGN:
|
||||||
|
start = sign_index(asc) * 30.0
|
||||||
|
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)]
|
||||||
|
if system == PORPHYRY:
|
||||||
|
dsc, ic = norm360(asc + 180.0), norm360(mc + 180.0)
|
||||||
|
c = [0.0] * 12
|
||||||
|
c[0], c[3], c[6], c[9] = asc, ic, dsc, mc
|
||||||
|
c[1], c[2] = _trisect(asc, ic) # domy 2,3
|
||||||
|
c[4], c[5] = _trisect(ic, dsc) # domy 5,6
|
||||||
|
c[7], c[8] = _trisect(dsc, mc) # domy 8,9
|
||||||
|
c[10], c[11] = _trisect(mc, asc) # domy 11,12
|
||||||
|
return c
|
||||||
|
raise ValueError(f"nieznany system domów: {system}")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
for i in range(12):
|
||||||
|
start = cusp_list[i]
|
||||||
|
end = cusp_list[(i + 1) % 12]
|
||||||
|
span = (end - start) % 360.0
|
||||||
|
offset = (lon - start) % 360.0
|
||||||
|
if offset < span:
|
||||||
|
return i + 1
|
||||||
|
return 12
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Lots / punkty arabskie (LOG-08) — 7 Lots hermetycznych.
|
||||||
|
|
||||||
|
Formuła: Lot = C + A − B (od punktu C odmierzamy odległość między A i B).
|
||||||
|
Większość Lots **odwraca się w horoskopach nocnych** (zamiana A↔B) — np.
|
||||||
|
Fortuna: dzień Asc + Mo − Su, noc Asc + Su − Mo.
|
||||||
|
|
||||||
|
Dwa warianty liczenia (notes3):
|
||||||
|
- `degree` (domyślny) — dokładny stopień,
|
||||||
|
- `sign` — liczone całymi znakami (Lot wypada na 0° wyliczonego znaku).
|
||||||
|
|
||||||
|
Kolejność ma znaczenie: Fortuna i Duch liczone są pierwsze, bo pozostałe Lots
|
||||||
|
odwołują się do nich.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine.formats import norm360, sign_index
|
||||||
|
|
||||||
|
# (nazwa, C, A, B, odwracalny w nocy)
|
||||||
|
LOT_DEFS: list[tuple[str, str, str, str, bool]] = [
|
||||||
|
("Fortune", "Asc", "Moon", "Sun", True),
|
||||||
|
("Spirit", "Asc", "Sun", "Moon", True),
|
||||||
|
("Eros", "Asc", "Venus", "Spirit", True),
|
||||||
|
("Necessity", "Asc", "Fortune", "Mercury", True),
|
||||||
|
("Courage", "Asc", "Fortune", "Mars", True),
|
||||||
|
("Victory", "Asc", "Jupiter", "Spirit", True),
|
||||||
|
("Nemesis", "Asc", "Fortune", "Saturn", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
METHODS = ("degree", "sign")
|
||||||
|
|
||||||
|
|
||||||
|
def compute_lots(
|
||||||
|
points: dict[str, float], is_day: bool, method: str = "degree"
|
||||||
|
) -> list[dict]:
|
||||||
|
"""points: nazwa → długość natalna (wymagane Asc + planety formuł).
|
||||||
|
|
||||||
|
Zwraca listę {name, longitude, formula} w kolejności definicji.
|
||||||
|
"""
|
||||||
|
if method not in METHODS:
|
||||||
|
raise ValueError(f"nieznana metoda liczenia Lots: {method}")
|
||||||
|
|
||||||
|
vals = dict(points)
|
||||||
|
out: list[dict] = []
|
||||||
|
for name, c, a, b, reversible in LOT_DEFS:
|
||||||
|
first, second = (a, b) if (is_day or not reversible) else (b, a)
|
||||||
|
if any(k not in vals for k in (c, first, second)):
|
||||||
|
continue # brak składnika — pomijamy
|
||||||
|
if method == "sign":
|
||||||
|
idx = (sign_index(vals[c]) + sign_index(vals[first])
|
||||||
|
- sign_index(vals[second])) % 12
|
||||||
|
lon = idx * 30.0
|
||||||
|
else:
|
||||||
|
lon = norm360(vals[c] + vals[first] - vals[second])
|
||||||
|
vals[name] = lon # dostępny dla kolejnych Lots
|
||||||
|
out.append({
|
||||||
|
"name": name,
|
||||||
|
"longitude": lon,
|
||||||
|
"formula": f"{c} + {first} − {second}",
|
||||||
|
})
|
||||||
|
return out
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Modele domenowe silnika efemeryd — wspólny kontrakt dla KAŻDEGO silnika.
|
||||||
|
|
||||||
|
To jest część LOG-28 (parzystość): każdy silnik (własny Skyfield czy zdalny
|
||||||
|
swisseph) przyjmuje `ChartMoment` i zwraca listę `ObjectPosition` w identycznym
|
||||||
|
kształcie i jednostkach. Dzięki temu wyniki są bezpośrednio porównywalne, a
|
||||||
|
prezentacja/dane nie wiedzą, który silnik liczył.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.engine import formats
|
||||||
|
|
||||||
|
# kanoniczny zestaw i kolejność obiektów (LOG-02: światła + 7 klasycznych +
|
||||||
|
# 3 nowożytne + punkty wirtualne: węzły mean i mean Lilith)
|
||||||
|
DEFAULT_OBJECTS = [
|
||||||
|
"Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||||
|
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
|
||||||
|
"North Node", "South Node", "Lilith",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChartMoment:
|
||||||
|
"""Wejście silnika: moment w UTC + lokalizacja geograficzna.
|
||||||
|
|
||||||
|
Pozycje obiektów zależą tylko od czasu (geocentrycznie); szerokość/długość
|
||||||
|
geograficzna będą potrzebne dopiero przy osiach i domach (LOG-05).
|
||||||
|
"""
|
||||||
|
|
||||||
|
when_utc: datetime # musi być świadome strefy (UTC)
|
||||||
|
lat: float = 0.0 # szerokość geograficzna, + na północ
|
||||||
|
lon: float = 0.0 # długość geograficzna, + na wschód
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ObjectPosition:
|
||||||
|
"""Wynik dla jednego obiektu — surowe wartości w jednostkach SI astrologii."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
longitude: float # długość ekliptyczna 0–360° (tropikalna, of-date)
|
||||||
|
latitude: float # szerokość ekliptyczna (°)
|
||||||
|
speed: float # prędkość w długości (°/dobę)
|
||||||
|
retrograde: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sign(self) -> str:
|
||||||
|
return formats.SIGNS[formats.sign_index(self.longitude)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def direction(self) -> str:
|
||||||
|
return "Rx" if self.retrograde else "D"
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"sign": self.sign,
|
||||||
|
"in_sign": formats.in_sign(self.longitude),
|
||||||
|
"absolute": formats.absolute(self.longitude),
|
||||||
|
"decimal": formats.decimal(self.longitude),
|
||||||
|
"latitude": round(self.latitude, 6),
|
||||||
|
"speed": round(self.speed, 6),
|
||||||
|
"direction": self.direction,
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Punkty wirtualne liczone analitycznie (LOG-02): mean Node i mean Lilith.
|
||||||
|
|
||||||
|
Wzory Meeusa (Astronomical Algorithms) w stuleciach juliańskich od J2000 (TT):
|
||||||
|
- Ω — średni węzeł wstępujący orbity Księżyca (mean ascending node). Porusza się
|
||||||
|
zawsze wstecz (~−0,053°/dobę) — stąd węzły są wiecznie Rx.
|
||||||
|
- średnie perygeum orbity Księżyca; mean Lilith (Black Moon) = średnie APOGEUM
|
||||||
|
= perygeum + 180° (~+0,111°/dobę).
|
||||||
|
|
||||||
|
Wersje TRUE (oskulacyjne) — osobny, późniejszy krok (notatki: mean to
|
||||||
|
historyczny standard i domyślne ustawienie programów).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
_DAYS_PER_CENTURY = 36525.0
|
||||||
|
|
||||||
|
|
||||||
|
def _t(tt_jd: float) -> float:
|
||||||
|
return (tt_jd - 2451545.0) / _DAYS_PER_CENTURY
|
||||||
|
|
||||||
|
|
||||||
|
def mean_lunar_node(tt_jd: float) -> float:
|
||||||
|
"""Długość ekliptyczna średniego Węzła Północnego (Ω) [°]."""
|
||||||
|
t = _t(tt_jd)
|
||||||
|
omega = (125.0445479 - 1934.1362891 * t + 0.0020754 * t * t
|
||||||
|
+ t ** 3 / 467441.0 - t ** 4 / 60616000.0)
|
||||||
|
return norm360(omega)
|
||||||
|
|
||||||
|
|
||||||
|
def mean_lilith(tt_jd: float) -> float:
|
||||||
|
"""Długość ekliptyczna mean Lilith (średnie apogeum Księżyca) [°]."""
|
||||||
|
t = _t(tt_jd)
|
||||||
|
perigee = (83.3532465 + 4069.0137287 * t - 0.0103200 * t * t
|
||||||
|
- t ** 3 / 80053.0 + t ** 4 / 18999000.0)
|
||||||
|
return norm360(perigee + 180.0)
|
||||||
|
|
||||||
|
|
||||||
|
def point_speed(fn, tt_jd: float, dt_days: float = 0.1) -> float:
|
||||||
|
"""Prędkość [°/dobę] punktu analitycznego — różnica po małym kroku."""
|
||||||
|
a = fn(tt_jd)
|
||||||
|
b = fn(tt_jd + dt_days)
|
||||||
|
return (((b - a + 180.0) % 360.0) - 180.0) / dt_days
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Profekcje roczne (LOG-10) — hellenistyczna technika time-lord.
|
||||||
|
|
||||||
|
Zasada (Whole Sign): co każde urodziny profektowany Ascendent przeskakuje o jeden
|
||||||
|
znak do przodu (wiek mod 12). Władca Roku (Lord of Year) = władca domicylowy
|
||||||
|
znaku profektowanego Asc. Profektować można każdy punkt natalny (MC, Słońce…) —
|
||||||
|
wszystkie przeskakują o tyle samo znaków.
|
||||||
|
|
||||||
|
Referencja: tabela profekcji w notes3 (astro-seek) dla horoskopu 30.04.1984
|
||||||
|
(wiek 0: Can/Moon, 1: Leo/Sun, …, 42: Cap/Saturn).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.engine.formats import SIGNS, sign_index
|
||||||
|
|
||||||
|
# władcy domicylowi (tradycyjni) — zgodni z tabelą referencyjną notes3
|
||||||
|
DOMICILE_RULERS = {
|
||||||
|
"Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon",
|
||||||
|
"Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars",
|
||||||
|
"Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn",
|
||||||
|
"Pisces": "Jupiter",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def age_at(birth_utc: datetime, when_utc: datetime) -> int:
|
||||||
|
"""Pełne lata między urodzeniem a danym momentem (wiek profekcyjny)."""
|
||||||
|
age = when_utc.year - birth_utc.year
|
||||||
|
if (when_utc.month, when_utc.day) < (birth_utc.month, birth_utc.day):
|
||||||
|
age -= 1
|
||||||
|
return max(age, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def profected_sign(natal_lon: float, age: int) -> str:
|
||||||
|
"""Znak, do którego profektował punkt natalny po `age` latach."""
|
||||||
|
return SIGNS[(sign_index(natal_lon) + age) % 12]
|
||||||
|
|
||||||
|
|
||||||
|
def profection_rows(
|
||||||
|
natal_points: dict[str, float],
|
||||||
|
birth_utc: datetime,
|
||||||
|
start_age: int,
|
||||||
|
count: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Tabela profekcji dla zakresu lat życia.
|
||||||
|
|
||||||
|
natal_points: nazwa -> natalna długość ekliptyczna (musi zawierać 'Asc').
|
||||||
|
Każdy wiersz: wiek, data początku roku profekcyjnego (urodziny), znak
|
||||||
|
profektowanego Asc, Władca Roku oraz profekcje pozostałych punktów.
|
||||||
|
"""
|
||||||
|
def _birthday(year: int) -> datetime:
|
||||||
|
try:
|
||||||
|
return birth_utc.replace(year=year)
|
||||||
|
except ValueError: # 29 lutego w roku nieprzestępnym
|
||||||
|
return birth_utc.replace(year=year, day=28)
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
for age in range(start_age, start_age + count):
|
||||||
|
asc_sign = profected_sign(natal_points["Asc"], age)
|
||||||
|
row = {
|
||||||
|
"age": age,
|
||||||
|
"from": _birthday(birth_utc.year + age).strftime("%Y-%m-%d"),
|
||||||
|
"profected_asc": asc_sign,
|
||||||
|
"lord_of_year": DOMICILE_RULERS[asc_sign],
|
||||||
|
}
|
||||||
|
for name, lon in natal_points.items():
|
||||||
|
if name != "Asc":
|
||||||
|
row[name] = profected_sign(lon, age)
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""RemoteEngine — klient izolowanej usługi silnika (LOG-24 backend nr 2, LOG-27).
|
||||||
|
|
||||||
|
Realizuje ten sam interfejs co SkyfieldEngine, ale liczenie deleguje przez HTTP
|
||||||
|
do OSOBNEJ usługi `engine-swisseph` (AGPL). Dzięki granicy sieciowej kod AGPL
|
||||||
|
nigdy nie jest linkowany do permisywnego produktu — patrz services/engine-swisseph.
|
||||||
|
|
||||||
|
Używany tylko, gdy skonfigurowano ENGINE_SWISSEPH_URL (tryb porównawczy/dev/CI).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteEngine(EphemerisEngine):
|
||||||
|
name = "swisseph"
|
||||||
|
|
||||||
|
def __init__(self, base_url: str | None = None, timeout: float = 15.0) -> None:
|
||||||
|
self.base_url = (base_url or os.getenv("ENGINE_SWISSEPH_URL", "")).rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def positions(
|
||||||
|
self, moment: ChartMoment, objects: list[str] | None = None
|
||||||
|
) -> list[ObjectPosition]:
|
||||||
|
if not self.base_url:
|
||||||
|
raise RuntimeError("ENGINE_SWISSEPH_URL nie ustawiony — silnik B niedostępny")
|
||||||
|
payload = {
|
||||||
|
"when_utc": moment.when_utc.isoformat(),
|
||||||
|
"lat": moment.lat,
|
||||||
|
"lon": moment.lon,
|
||||||
|
"objects": objects or DEFAULT_OBJECTS,
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
r = client.post(f"{self.base_url}/positions", json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
rows = r.json()["positions"]
|
||||||
|
return [
|
||||||
|
ObjectPosition(
|
||||||
|
name=row["name"],
|
||||||
|
longitude=norm360(row["longitude"]),
|
||||||
|
latitude=row["latitude"],
|
||||||
|
speed=row["speed"],
|
||||||
|
retrograde=row["retrograde"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
if not self.base_url:
|
||||||
|
return {"engine": self.name, "status": "disabled"}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
r = client.get(f"{self.base_url}/health")
|
||||||
|
r.raise_for_status()
|
||||||
|
return {"engine": self.name, "status": "ok", "remote": r.json()}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"engine": self.name, "status": "down", "error": str(e)}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Solar / Lunar Return (LOG-12) — moment powrotu do pozycji natalnej.
|
||||||
|
|
||||||
|
Solar Return (solariusz): moment, w którym Słońce wraca dokładnie do natalnej
|
||||||
|
długości ekliptycznej (raz na rok, w okolicy urodzin). Lunar Return: to samo
|
||||||
|
dla Księżyca (raz na ~27,3 dnia). Dwa warianty użycia (osobny horoskop vs
|
||||||
|
tranzyt do natalu) obsługujemy zwracając pełny horoskop na znaleziony moment —
|
||||||
|
interpretacja pozostaje po stronie technik wyżej.
|
||||||
|
|
||||||
|
Metoda: podpisana różnica długości Δ = lon − natal (zawinięta do ±180°) rośnie
|
||||||
|
monotonicznie i przechodzi przez 0 dokładnie w momencie powrotu. Skan dobowy
|
||||||
|
wykrywa przejście −→+ (skok +180→−180 to artefakt zawinięcia — pomijany,
|
||||||
|
warunek d_hi − d_lo < 180), potem bisekcja do ~sekundy.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
# szerokość okna skanu wokół `around` [dni]: solar kotwiczymy przy urodzinach,
|
||||||
|
# lunar musi objąć cały okres syderyczny Księżyca (27,3 d)
|
||||||
|
SCAN_WINDOW = {"solar": 6.0, "lunar": 15.0}
|
||||||
|
|
||||||
|
|
||||||
|
def _lon_delta(engine, body: str, natal_lon: float, when: datetime) -> float:
|
||||||
|
m = ChartMoment(when_utc=when)
|
||||||
|
lon = engine.positions(m, [body])[0].longitude
|
||||||
|
return ((lon - natal_lon + 180.0) % 360.0) - 180.0
|
||||||
|
|
||||||
|
|
||||||
|
def find_return(
|
||||||
|
engine, kind: str, natal_moment: ChartMoment, around: datetime
|
||||||
|
) -> datetime | None:
|
||||||
|
"""Moment powrotu (kind: 'solar'/'lunar') najbliższy dacie `around`."""
|
||||||
|
body = "Sun" if kind == "solar" else "Moon"
|
||||||
|
natal_lon = engine.positions(natal_moment, [body])[0].longitude
|
||||||
|
if around.tzinfo is None:
|
||||||
|
around = around.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
window = SCAN_WINDOW[kind]
|
||||||
|
step = timedelta(days=1.0)
|
||||||
|
t = around - timedelta(days=window)
|
||||||
|
end = around + timedelta(days=window)
|
||||||
|
|
||||||
|
candidates: list[datetime] = []
|
||||||
|
d_prev = _lon_delta(engine, body, natal_lon, t)
|
||||||
|
while t < end:
|
||||||
|
t_next = t + step
|
||||||
|
d_next = _lon_delta(engine, body, natal_lon, t_next)
|
||||||
|
# prawdziwe przejście przez zero: − -> + bez skoku zawinięcia
|
||||||
|
if d_prev < 0 <= d_next and (d_next - d_prev) < 180.0:
|
||||||
|
lo, hi, d_lo = t, t_next, d_prev
|
||||||
|
for _ in range(40): # bisekcja do ułamka sekundy
|
||||||
|
mid = lo + (hi - lo) / 2
|
||||||
|
if (_lon_delta(engine, body, natal_lon, mid) < 0) == (d_lo < 0):
|
||||||
|
lo = mid
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
candidates.append(lo + (hi - lo) / 2)
|
||||||
|
t, d_prev = t_next, d_next
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
return min(candidates, key=lambda c: abs(c - around))
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""SkyfieldEngine — własny, permisywny silnik (LOG-01).
|
||||||
|
|
||||||
|
Ścieżka A: Skyfield (MIT) + efemerydy JPL (public domain). Liczy geocentryczne
|
||||||
|
pozycje pozorne (apparent) i rzutuje je na ekliptykę daty → długość tropikalna,
|
||||||
|
szerokość, prędkość i kierunek. Brak zależności AGPL.
|
||||||
|
|
||||||
|
Prędkość liczymy numerycznie (różnica długości po małym kroku czasu) — wystarcza
|
||||||
|
do kierunku (D/Rx) i do wykrywania stacji w LOG-03.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
|
||||||
|
|
||||||
|
# nazwa obiektu -> cel w jądrze efemeryd (de421 ma centra Merkurego/Wenus,
|
||||||
|
# dla pozostałych planet używamy barycentrów — różnica nieistotna astrologicznie)
|
||||||
|
_TARGETS = {
|
||||||
|
"Sun": "sun",
|
||||||
|
"Moon": "moon",
|
||||||
|
"Mercury": "mercury",
|
||||||
|
"Venus": "venus",
|
||||||
|
"Mars": "mars barycenter",
|
||||||
|
"Jupiter": "jupiter barycenter",
|
||||||
|
"Saturn": "saturn barycenter",
|
||||||
|
"Uranus": "uranus barycenter",
|
||||||
|
"Neptune": "neptune barycenter",
|
||||||
|
"Pluto": "pluto barycenter",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def _load(kernel: str, data_dir: str):
|
||||||
|
"""Wczytuje skalę czasu i jądro efemeryd raz (kosztowne) i cache'uje."""
|
||||||
|
from skyfield.api import Loader
|
||||||
|
|
||||||
|
load = Loader(data_dir)
|
||||||
|
ts = load.timescale()
|
||||||
|
eph = load(kernel)
|
||||||
|
return ts, eph, eph["earth"]
|
||||||
|
|
||||||
|
|
||||||
|
class SkyfieldEngine(EphemerisEngine):
|
||||||
|
name = "skyfield"
|
||||||
|
|
||||||
|
def __init__(self, kernel: str | None = None, data_dir: str | None = None) -> None:
|
||||||
|
self.kernel = kernel or os.getenv("EPHEMERIS_KERNEL", "de421.bsp")
|
||||||
|
self.data_dir = data_dir or os.getenv(
|
||||||
|
"EPHEMERIS_DIR", os.path.join(os.path.dirname(__file__), "..", "..", ".ephemeris")
|
||||||
|
)
|
||||||
|
os.makedirs(self.data_dir, exist_ok=True)
|
||||||
|
self.ts, self.eph, self.earth = _load(self.kernel, os.path.abspath(self.data_dir))
|
||||||
|
|
||||||
|
def _ecliptic_lon_lat(self, target, t):
|
||||||
|
astrometric = self.earth.at(t).observe(target).apparent()
|
||||||
|
lat, lon, _dist = astrometric.ecliptic_latlon(epoch="date")
|
||||||
|
return lon.degrees, lat.degrees
|
||||||
|
|
||||||
|
def _virtual_point(self, name: str, tt_jd: float) -> ObjectPosition:
|
||||||
|
"""Punkty analityczne (LOG-02): mean Node (NN/SN) i mean Lilith.
|
||||||
|
|
||||||
|
Liczone wzorami Meeusa, nie z jądra JPL. SN = NN + 180° (ta sama prędkość).
|
||||||
|
Punkty leżą na ekliptyce (latitude = 0).
|
||||||
|
"""
|
||||||
|
from app.engine.points import mean_lilith, mean_lunar_node, point_speed
|
||||||
|
|
||||||
|
if name in ("North Node", "South Node"):
|
||||||
|
lon = mean_lunar_node(tt_jd)
|
||||||
|
if name == "South Node":
|
||||||
|
lon = norm360(lon + 180.0)
|
||||||
|
speed = point_speed(mean_lunar_node, tt_jd)
|
||||||
|
else: # Lilith
|
||||||
|
lon = mean_lilith(tt_jd)
|
||||||
|
speed = point_speed(mean_lilith, tt_jd)
|
||||||
|
return ObjectPosition(
|
||||||
|
name=name, longitude=float(lon), latitude=0.0,
|
||||||
|
speed=float(speed), retrograde=bool(speed < 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def positions(
|
||||||
|
self, moment: ChartMoment, objects: list[str] | None = None
|
||||||
|
) -> list[ObjectPosition]:
|
||||||
|
names = objects or DEFAULT_OBJECTS
|
||||||
|
t = self.ts.from_datetime(moment.when_utc)
|
||||||
|
dt = timedelta(hours=1)
|
||||||
|
t2 = self.ts.from_datetime(moment.when_utc + dt)
|
||||||
|
|
||||||
|
out: list[ObjectPosition] = []
|
||||||
|
for name in names:
|
||||||
|
if name not in _TARGETS: # punkt wirtualny (NN/SN/Lilith)
|
||||||
|
out.append(self._virtual_point(name, t.tt))
|
||||||
|
continue
|
||||||
|
target = self.eph[_TARGETS[name]]
|
||||||
|
lon, lat = self._ecliptic_lon_lat(target, t)
|
||||||
|
lon2, _ = self._ecliptic_lon_lat(target, t2)
|
||||||
|
# prędkość °/dobę z poprawką na przejście przez 0°/360°
|
||||||
|
step = ((lon2 - lon + 180.0) % 360.0) - 180.0
|
||||||
|
speed = step * 24.0
|
||||||
|
# rzutowanie na czysty float — Skyfield zwraca numpy.float64
|
||||||
|
out.append(
|
||||||
|
ObjectPosition(
|
||||||
|
name=name,
|
||||||
|
longitude=float(norm360(lon)),
|
||||||
|
latitude=float(lat),
|
||||||
|
speed=float(speed),
|
||||||
|
retrograde=bool(speed < 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def sidereal(self, moment: ChartMoment) -> tuple[float, float]:
|
||||||
|
"""(RAMC, ε) w stopniach — lokalny apparent sidereal time i nachylenie ekliptyki.
|
||||||
|
|
||||||
|
Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo.
|
||||||
|
"""
|
||||||
|
from app.engine.houses import mean_obliquity
|
||||||
|
|
||||||
|
t = self.ts.from_datetime(moment.when_utc)
|
||||||
|
ramc = norm360(t.gast * 15.0 + moment.lon)
|
||||||
|
eps = mean_obliquity(t.tt)
|
||||||
|
return ramc, eps
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Wykrywanie stacji planet (LOG-03): poprzednia/następna stacja, SD/SR, flaga <7 dni.
|
||||||
|
|
||||||
|
Stacja ścisła = moment, w którym prędkość zodiakalna przechodzi przez zero.
|
||||||
|
Metoda: próbki prędkości co 1 dzień w oknie ± SEARCH_DAYS → zmiana znaku →
|
||||||
|
bisekcja do dokładności ~1 minuty. Klasyfikacja: prędkość przed<0 i po>0 → SD
|
||||||
|
(stationary direct), odwrotnie → SR (stationary retrograde).
|
||||||
|
|
||||||
|
Pomijamy Słońce/Księżyc (nigdy Rx) i punkty mean (NN/SN/Lilith — ruch jednostajny).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app.engine.formats import in_sign
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
# obiekty bez stacji
|
||||||
|
NO_STATIONS = {"Sun", "Moon", "North Node", "South Node", "Lilith"}
|
||||||
|
|
||||||
|
# Okno musi pokryć najdłuższą przerwę między stacjami (Mars/Wenus: ~700 dni),
|
||||||
|
# a krok skanu musi być krótszy niż najkrótsza retrogradacja (Merkury ~21 dni).
|
||||||
|
SEARCH_DAYS = 800 # okno poszukiwań w każdą stronę
|
||||||
|
SCAN_STEP_DAYS = 4.0 # krok zgrubnego skanu (potem bisekcja)
|
||||||
|
STATION_SOON_DAYS = 7.0 # próg flagi "stacja blisko" (konfigurowalny, notes2)
|
||||||
|
|
||||||
|
|
||||||
|
def _speed_fn(engine, name: str):
|
||||||
|
"""Zwraca funkcję: dni_od_momentu_bazowego -> prędkość [°/dobę]."""
|
||||||
|
def speed(base_moment: ChartMoment, offset_days: float) -> float:
|
||||||
|
m = ChartMoment(
|
||||||
|
when_utc=base_moment.when_utc + timedelta(days=offset_days),
|
||||||
|
lat=base_moment.lat, lon=base_moment.lon,
|
||||||
|
)
|
||||||
|
return engine.positions(m, [name])[0].speed
|
||||||
|
return speed
|
||||||
|
|
||||||
|
|
||||||
|
def _bisect_zero(speed, moment: ChartMoment, lo: float, hi: float, iters: int = 20) -> float:
|
||||||
|
"""Bisekcja miejsca zerowego prędkości między dniami lo i hi."""
|
||||||
|
s_lo = speed(moment, lo)
|
||||||
|
for _ in range(iters):
|
||||||
|
mid = (lo + hi) / 2.0
|
||||||
|
s_mid = speed(moment, mid)
|
||||||
|
if (s_lo < 0) == (s_mid < 0):
|
||||||
|
lo, s_lo = mid, s_mid
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
return (lo + hi) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _station_info(engine, moment: ChartMoment, name: str, day: float, speed) -> dict:
|
||||||
|
"""Opis stacji w danym dniu (offset od momentu bazowego)."""
|
||||||
|
before = speed(moment, day - 0.5)
|
||||||
|
kind = "SD" if before < 0 else "SR"
|
||||||
|
when = moment.when_utc + timedelta(days=day)
|
||||||
|
m = ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon)
|
||||||
|
lon = engine.positions(m, [name])[0].longitude
|
||||||
|
return {
|
||||||
|
"type": kind,
|
||||||
|
"date": when.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"days": round(day, 1), # ujemne = w przeszłości
|
||||||
|
"degree": in_sign(lon),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_stations(engine, moment: ChartMoment, name: str, step_days: float = SCAN_STEP_DAYS) -> dict | None:
|
||||||
|
"""Poprzednia i następna stacja obiektu względem momentu horoskopu."""
|
||||||
|
if name in NO_STATIONS:
|
||||||
|
return None
|
||||||
|
speed = _speed_fn(engine, name)
|
||||||
|
|
||||||
|
prev_day = next_day = None
|
||||||
|
# w przeszłość
|
||||||
|
s_right = speed(moment, 0.0)
|
||||||
|
d = 0.0
|
||||||
|
while d > -SEARCH_DAYS:
|
||||||
|
s_left = speed(moment, d - step_days)
|
||||||
|
if (s_left < 0) != (s_right < 0):
|
||||||
|
prev_day = _bisect_zero(speed, moment, d - step_days, d)
|
||||||
|
break
|
||||||
|
d, s_right = d - step_days, s_left
|
||||||
|
# w przyszłość
|
||||||
|
s_left = speed(moment, 0.0)
|
||||||
|
d = 0.0
|
||||||
|
while d < SEARCH_DAYS:
|
||||||
|
s_right = speed(moment, d + step_days)
|
||||||
|
if (s_left < 0) != (s_right < 0):
|
||||||
|
next_day = _bisect_zero(speed, moment, d, d + step_days)
|
||||||
|
break
|
||||||
|
d, s_left = d + step_days, s_right
|
||||||
|
|
||||||
|
result: dict = {}
|
||||||
|
if prev_day is not None:
|
||||||
|
result["prev"] = _station_info(engine, moment, name, prev_day, speed)
|
||||||
|
if next_day is not None:
|
||||||
|
result["next"] = _station_info(engine, moment, name, next_day, speed)
|
||||||
|
result["station_soon"] = any(
|
||||||
|
abs(x["days"]) < STATION_SOON_DAYS for x in result.values() if isinstance(x, dict)
|
||||||
|
)
|
||||||
|
return result or None
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""Tabele pomocnicze horoskopu (LOG-23).
|
||||||
|
|
||||||
|
Zbiór wyliczeń, które astrolog czyta „obok" pozycji: bilans żywiołów i jakości,
|
||||||
|
faza Księżyca, stopnie krytyczne, dzień i godziny planetarne, syzygia prenatalna
|
||||||
|
oraz podziały (dwunastniki i nawamsa).
|
||||||
|
|
||||||
|
Dwie rzeczy wymagają prawdziwego liczenia, nie tabelki:
|
||||||
|
* **godziny planetarne** — są NIERÓWNE: dzień od wschodu do zachodu Słońca dzieli
|
||||||
|
się na 12 części, noc osobno. Bez faktycznego wschodu/zachodu wynik byłby
|
||||||
|
zmyślony, więc szukamy ich numerycznie (przejście wysokości Słońca przez −0°50′);
|
||||||
|
* **syzygia prenatalna** — ostatni nów albo pełnia PRZED urodzeniem; szukamy
|
||||||
|
wstecz momentu, w którym elongacja Księżyca przechodzi przez 0° lub 180°.
|
||||||
|
|
||||||
|
Moduł jest silnik-agnostyczny: potrzebuje tylko `positions()` i `sidereal()`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.zodiac import to_equatorial
|
||||||
|
|
||||||
|
# --- żywioły i jakości ------------------------------------------------------
|
||||||
|
ELEMENTS = ["Fire", "Earth", "Air", "Water"]
|
||||||
|
QUALITIES = ["Cardinal", "Fixed", "Mutable"]
|
||||||
|
ELEMENT_PL = {"Fire": "Ogień", "Earth": "Ziemia", "Air": "Powietrze", "Water": "Woda"}
|
||||||
|
QUALITY_PL = {"Cardinal": "Kardynalny", "Fixed": "Stały", "Mutable": "Zmienny"}
|
||||||
|
|
||||||
|
CLASSICAL = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"]
|
||||||
|
MODERN = CLASSICAL + ["Uranus", "Neptune", "Pluto"]
|
||||||
|
|
||||||
|
# --- dzień i godziny planetarne --------------------------------------------
|
||||||
|
# Kolejność chaldejska: od najwolniejszej do najszybszej planety
|
||||||
|
CHALDEAN = ["Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon"]
|
||||||
|
# Władca dnia wg dnia tygodnia (0 = poniedziałek, jak w datetime.weekday())
|
||||||
|
WEEKDAY_RULER = ["Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun"]
|
||||||
|
|
||||||
|
# wysokość środka tarczy Słońca przy wschodzie/zachodzie (refrakcja + promień tarczy)
|
||||||
|
SUNRISE_ALTITUDE = -0.833
|
||||||
|
|
||||||
|
|
||||||
|
def element_of(sign: str) -> str:
|
||||||
|
return ELEMENTS[SIGNS.index(sign) % 4]
|
||||||
|
|
||||||
|
|
||||||
|
def quality_of(sign: str) -> str:
|
||||||
|
return QUALITIES[SIGNS.index(sign) % 3]
|
||||||
|
|
||||||
|
|
||||||
|
def tally(positions: list[dict], asc_sign: str | None = None,
|
||||||
|
modern: bool = True) -> dict:
|
||||||
|
"""Bilans żywiołów i jakości (LOG-23).
|
||||||
|
|
||||||
|
Liczymy w dwóch wariantach naraz, bo szkoły się różnią: 7 planet klasycznych
|
||||||
|
i 10 z nowożytnymi. Ascendent doliczany osobno — bywa traktowany jak punkt
|
||||||
|
równorzędny planetom.
|
||||||
|
"""
|
||||||
|
wanted = MODERN if modern else CLASSICAL
|
||||||
|
by_name = {p.get("name"): p for p in positions}
|
||||||
|
|
||||||
|
def count(names: list[str], with_asc: bool) -> dict:
|
||||||
|
elements = dict.fromkeys(ELEMENTS, 0)
|
||||||
|
qualities = dict.fromkeys(QUALITIES, 0)
|
||||||
|
used = []
|
||||||
|
for name in names:
|
||||||
|
p = by_name.get(name)
|
||||||
|
if not p or not p.get("sign"):
|
||||||
|
continue
|
||||||
|
elements[element_of(p["sign"])] += 1
|
||||||
|
qualities[quality_of(p["sign"])] += 1
|
||||||
|
used.append(name)
|
||||||
|
if with_asc and asc_sign:
|
||||||
|
elements[element_of(asc_sign)] += 1
|
||||||
|
qualities[quality_of(asc_sign)] += 1
|
||||||
|
used.append("Asc")
|
||||||
|
return {"elements": elements, "qualities": qualities,
|
||||||
|
"counted": used, "total": len(used)}
|
||||||
|
|
||||||
|
classical = count(CLASSICAL, False)
|
||||||
|
result = {
|
||||||
|
"classical_7": classical,
|
||||||
|
"with_modern_10": count(wanted, False),
|
||||||
|
"classical_7_plus_asc": count(CLASSICAL, True),
|
||||||
|
"with_modern_10_plus_asc": count(wanted, True),
|
||||||
|
}
|
||||||
|
# brakujące żywioły — klasyczne „no air" itd., podstawa pod scoring (LOG-21)
|
||||||
|
base = result["with_modern_10_plus_asc"]
|
||||||
|
result["missing_elements"] = [e for e, n in base["elements"].items() if n == 0]
|
||||||
|
result["missing_qualities"] = [q for q, n in base["qualities"].items() if n == 0]
|
||||||
|
result["labels"] = {"elements": ELEMENT_PL, "qualities": QUALITY_PL}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --- faza Księżyca ----------------------------------------------------------
|
||||||
|
_PHASES = [
|
||||||
|
(0.0, "New Moon", "Nów"),
|
||||||
|
(45.0, "Waxing Crescent", "Sierp przybywający"),
|
||||||
|
(90.0, "First Quarter", "Pierwsza kwadra"),
|
||||||
|
(135.0, "Waxing Gibbous", "Garb przybywający"),
|
||||||
|
(180.0, "Full Moon", "Pełnia"),
|
||||||
|
(225.0, "Waning Gibbous", "Garb ubywający"),
|
||||||
|
(270.0, "Last Quarter", "Ostatnia kwadra"),
|
||||||
|
(315.0, "Waning Crescent", "Sierp ubywający"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def moon_phase(sun_lon: float, moon_lon: float) -> dict:
|
||||||
|
"""Faza Księżyca z elongacji (Księżyc − Słońce)."""
|
||||||
|
angle = norm360(moon_lon - sun_lon)
|
||||||
|
idx = int(((angle + 22.5) % 360.0) // 45.0)
|
||||||
|
_, name, name_pl = _PHASES[idx]
|
||||||
|
illumination = (1.0 - math.cos(math.radians(angle))) / 2.0
|
||||||
|
return {
|
||||||
|
"angle": round(angle, 4),
|
||||||
|
"phase": name,
|
||||||
|
"phase_pl": name_pl,
|
||||||
|
"illumination": round(illumination, 4),
|
||||||
|
"waxing": angle < 180.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- stopnie krytyczne ------------------------------------------------------
|
||||||
|
# klasyczne stopnie krytyczne zależą od jakości znaku
|
||||||
|
_CRITICAL = {"Cardinal": (0, 13, 26), "Fixed": (8, 21), "Mutable": (4, 17)}
|
||||||
|
CRITICAL_ORB = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def critical_degrees(positions: list[dict]) -> list[dict]:
|
||||||
|
"""Obiekty stojące na stopniach krytycznych, 0° albo 29° (anaretycznym)."""
|
||||||
|
out = []
|
||||||
|
for p in positions:
|
||||||
|
lon = p.get("decimal")
|
||||||
|
sign = p.get("sign")
|
||||||
|
if lon is None or not sign:
|
||||||
|
continue
|
||||||
|
deg = norm360(lon) - sign_index(lon) * 30.0
|
||||||
|
flags = []
|
||||||
|
for critical in _CRITICAL[quality_of(sign)]:
|
||||||
|
if abs(deg - critical) <= CRITICAL_ORB:
|
||||||
|
flags.append(f"stopień krytyczny {critical}° ({QUALITY_PL[quality_of(sign)].lower()})")
|
||||||
|
if deg >= 29.0:
|
||||||
|
flags.append("29° — stopień anaretyczny (koniec znaku)")
|
||||||
|
elif deg < 1.0:
|
||||||
|
flags.append("0° — wejście w znak")
|
||||||
|
if flags:
|
||||||
|
out.append({"name": p.get("name"), "sign": sign,
|
||||||
|
"in_sign": p.get("in_sign"), "flags": flags})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- podziały: dwunastnik i nawamsa ----------------------------------------
|
||||||
|
def dwadasamsa(lon: float) -> float:
|
||||||
|
"""12. część (dwadasamsa): znak dzielony na 12 po 2°30′, licząc od siebie."""
|
||||||
|
lon = norm360(lon)
|
||||||
|
start = sign_index(lon) * 30.0
|
||||||
|
return norm360(start + (lon - start) * 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
def navamsa(lon: float) -> float:
|
||||||
|
"""9. część (nawamsa): 108 podziałów po 3°20′ liczonych od 0° Barana."""
|
||||||
|
lon = norm360(lon)
|
||||||
|
part = int(lon // (30.0 / 9.0))
|
||||||
|
return norm360((part % 12) * 30.0 + (lon % (30.0 / 9.0)) * 9.0)
|
||||||
|
|
||||||
|
|
||||||
|
def divisional(positions: list[dict]) -> list[dict]:
|
||||||
|
"""Pozycje w podziałach 12. i 9. — obie tabele naraz."""
|
||||||
|
out = []
|
||||||
|
for p in positions:
|
||||||
|
lon = p.get("decimal")
|
||||||
|
if lon is None:
|
||||||
|
continue
|
||||||
|
d12, d9 = dwadasamsa(lon), navamsa(lon)
|
||||||
|
out.append({
|
||||||
|
"name": p.get("name"),
|
||||||
|
"d12_sign": SIGNS[sign_index(d12)], "d12_in_sign": in_sign(d12),
|
||||||
|
"d9_sign": SIGNS[sign_index(d9)], "d9_in_sign": in_sign(d9),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- wschód/zachód Słońca i godziny planetarne ------------------------------
|
||||||
|
def sun_altitude(engine, moment: ChartMoment) -> float:
|
||||||
|
"""Wysokość Słońca nad horyzontem [°] dla momentu i miejsca."""
|
||||||
|
ramc, eps = engine.sidereal(moment)
|
||||||
|
sun = engine.positions(moment, ["Sun"])[0]
|
||||||
|
ra, dec = to_equatorial(sun.longitude, sun.latitude, eps)
|
||||||
|
hour_angle = math.radians(norm360(ramc - ra))
|
||||||
|
phi, d = math.radians(moment.lat), math.radians(dec)
|
||||||
|
sin_alt = math.sin(d) * math.sin(phi) + math.cos(d) * math.cos(phi) * math.cos(hour_angle)
|
||||||
|
return math.degrees(math.asin(max(-1.0, min(1.0, sin_alt))))
|
||||||
|
|
||||||
|
|
||||||
|
def _at(moment: ChartMoment, when: datetime) -> ChartMoment:
|
||||||
|
return ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon)
|
||||||
|
|
||||||
|
|
||||||
|
def _crossings(engine, moment: ChartMoment, start: datetime, end: datetime,
|
||||||
|
step_minutes: int = 20) -> list[tuple[datetime, str]]:
|
||||||
|
"""Momenty przejścia Słońca przez horyzont w oknie [start, end].
|
||||||
|
|
||||||
|
Skan zgrubny + bisekcja — ten sam wzorzec co przy stacjach planet (LOG-03).
|
||||||
|
"""
|
||||||
|
out: list[tuple[datetime, str]] = []
|
||||||
|
step = timedelta(minutes=step_minutes)
|
||||||
|
t0 = start
|
||||||
|
f0 = sun_altitude(engine, _at(moment, t0)) - SUNRISE_ALTITUDE
|
||||||
|
while t0 < end:
|
||||||
|
t1 = min(t0 + step, end)
|
||||||
|
f1 = sun_altitude(engine, _at(moment, t1)) - SUNRISE_ALTITUDE
|
||||||
|
if f0 == 0.0 or (f0 < 0.0) != (f1 < 0.0):
|
||||||
|
lo, hi, flo = t0, t1, f0
|
||||||
|
for _ in range(40): # ~sekundowa dokładność
|
||||||
|
mid = lo + (hi - lo) / 2
|
||||||
|
fmid = sun_altitude(engine, _at(moment, mid)) - SUNRISE_ALTITUDE
|
||||||
|
if (flo < 0.0) != (fmid < 0.0):
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
lo, flo = mid, fmid
|
||||||
|
out.append((lo + (hi - lo) / 2, "sunrise" if f1 > f0 else "sunset"))
|
||||||
|
t0, f0 = t1, f1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def planetary_hours(engine, moment: ChartMoment) -> dict | None:
|
||||||
|
"""Dzień i godziny planetarne w porządku chaldejskim (LOG-23).
|
||||||
|
|
||||||
|
Godziny są NIERÓWNE: dzień (wschód→zachód) i noc (zachód→wschód) dzielą się
|
||||||
|
na 12 części każde. Doba planetarna zaczyna się o WSCHODZIE, nie o północy —
|
||||||
|
dlatego władcę dnia bierzemy z dnia tygodnia tego wschodu, który otworzył
|
||||||
|
bieżący okres.
|
||||||
|
|
||||||
|
Zwraca None dla dnia polarnego/nocy polarnej, gdzie wschód nie występuje.
|
||||||
|
"""
|
||||||
|
now = moment.when_utc
|
||||||
|
events = _crossings(engine, moment, now - timedelta(hours=30), now + timedelta(hours=30))
|
||||||
|
if not events:
|
||||||
|
return None # brak wschodu/zachodu w oknie
|
||||||
|
|
||||||
|
before = [e for e in events if e[0] <= now]
|
||||||
|
after = [e for e in events if e[0] > now]
|
||||||
|
if not before or not after:
|
||||||
|
return None
|
||||||
|
|
||||||
|
last_time, last_kind = before[-1]
|
||||||
|
next_time, _ = after[0]
|
||||||
|
|
||||||
|
daytime = last_kind == "sunrise"
|
||||||
|
period_start, period_end = last_time, next_time
|
||||||
|
# doba planetarna startuje o wschodzie: w nocy to wschód POPRZEDZAJĄCY zachód
|
||||||
|
day_start = last_time if daytime else next((t for t, k in reversed(before)
|
||||||
|
if k == "sunrise"), last_time)
|
||||||
|
|
||||||
|
length = (period_end - period_start) / 12
|
||||||
|
index = int((now - period_start) / length)
|
||||||
|
index = max(0, min(11, index))
|
||||||
|
|
||||||
|
day_ruler = WEEKDAY_RULER[day_start.weekday()]
|
||||||
|
hour_number = index if daytime else index + 12 # 0..23 od wschodu
|
||||||
|
ruler = CHALDEAN[(CHALDEAN.index(day_ruler) + hour_number) % 7]
|
||||||
|
|
||||||
|
hours = []
|
||||||
|
for i in range(12):
|
||||||
|
start = period_start + length * i
|
||||||
|
hours.append({
|
||||||
|
"index": i + 1,
|
||||||
|
"ruler": CHALDEAN[(CHALDEAN.index(day_ruler) + (i if daytime else i + 12)) % 7],
|
||||||
|
"start": start.isoformat(timespec="seconds"),
|
||||||
|
"end": (start + length).isoformat(timespec="seconds"),
|
||||||
|
"current": i == index,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"day_ruler": day_ruler,
|
||||||
|
"hour_ruler": ruler,
|
||||||
|
"hour_number": hour_number + 1,
|
||||||
|
"daytime": daytime,
|
||||||
|
"period": "dzień" if daytime else "noc",
|
||||||
|
"hour_length_minutes": round(length.total_seconds() / 60.0, 2),
|
||||||
|
"period_start": period_start.isoformat(timespec="seconds"),
|
||||||
|
"period_end": period_end.isoformat(timespec="seconds"),
|
||||||
|
"hours": hours,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- syzygia prenatalna -----------------------------------------------------
|
||||||
|
def prenatal_syzygy(engine, moment: ChartMoment, max_days: float = 32.0) -> dict | None:
|
||||||
|
"""Ostatni nów albo pełnia PRZED podanym momentem (LOG-23).
|
||||||
|
|
||||||
|
Szukamy wstecz przejścia elongacji przez 0° (nów) lub 180° (pełnia); bierzemy
|
||||||
|
to, które wypadło później. Cykl trwa ~29,5 dnia, więc okno 32 dni wystarcza.
|
||||||
|
"""
|
||||||
|
def elongation(when: datetime) -> float:
|
||||||
|
pts = {p.name: p.longitude for p in
|
||||||
|
engine.positions(_at(moment, when), ["Sun", "Moon"])}
|
||||||
|
return norm360(pts["Moon"] - pts["Sun"])
|
||||||
|
|
||||||
|
def signed(when: datetime, target: float) -> float:
|
||||||
|
"""Odległość od celu w [−180, 180] — zeruje się dokładnie w syzygii."""
|
||||||
|
return ((elongation(when) - target + 180.0) % 360.0) - 180.0
|
||||||
|
|
||||||
|
best: tuple[datetime, str] | None = None
|
||||||
|
for target, kind in ((0.0, "new_moon"), (180.0, "full_moon")):
|
||||||
|
step = timedelta(hours=6)
|
||||||
|
t1 = moment.when_utc
|
||||||
|
f1 = signed(t1, target)
|
||||||
|
scanned = timedelta()
|
||||||
|
while scanned < timedelta(days=max_days):
|
||||||
|
t0 = t1 - step
|
||||||
|
f0 = signed(t0, target)
|
||||||
|
if (f0 < 0.0) != (f1 < 0.0) and abs(f0 - f1) < 180.0:
|
||||||
|
lo, hi, flo = t0, t1, f0
|
||||||
|
for _ in range(40):
|
||||||
|
mid = lo + (hi - lo) / 2
|
||||||
|
fmid = signed(mid, target)
|
||||||
|
if (flo < 0.0) != (fmid < 0.0):
|
||||||
|
hi = mid
|
||||||
|
else:
|
||||||
|
lo, flo = mid, fmid
|
||||||
|
found = lo + (hi - lo) / 2
|
||||||
|
if best is None or found > best[0]:
|
||||||
|
best = (found, kind)
|
||||||
|
break
|
||||||
|
t1, f1 = t0, f0
|
||||||
|
scanned += step
|
||||||
|
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
when, kind = best
|
||||||
|
pts = {p.name: p.longitude for p in engine.positions(_at(moment, when), ["Sun", "Moon"])}
|
||||||
|
lon = pts["Sun"] if kind == "new_moon" else pts["Moon"]
|
||||||
|
return {
|
||||||
|
"type": kind,
|
||||||
|
"type_pl": "nów" if kind == "new_moon" else "pełnia",
|
||||||
|
"when_utc": when.isoformat(timespec="seconds"),
|
||||||
|
"days_before_birth": round((moment.when_utc - when).total_seconds() / 86400.0, 3),
|
||||||
|
"sign": SIGNS[sign_index(lon)],
|
||||||
|
"in_sign": in_sign(lon),
|
||||||
|
"decimal": round(norm360(lon), 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- złożenie wszystkiego ---------------------------------------------------
|
||||||
|
def build_tables(engine, moment: ChartMoment, chart: dict,
|
||||||
|
heavy: bool = True) -> dict:
|
||||||
|
"""Komplet tabel dla policzonego horoskopu.
|
||||||
|
|
||||||
|
`heavy=False` pomija wyliczenia wymagające szukania numerycznego (godziny
|
||||||
|
planetarne, syzygia) — przydatne, gdy liczy się czas odpowiedzi.
|
||||||
|
"""
|
||||||
|
positions = chart.get("positions") or []
|
||||||
|
by_name = {p.get("name"): p for p in positions}
|
||||||
|
asc_sign = (chart.get("angles") or {}).get("Asc", {}).get("sign")
|
||||||
|
|
||||||
|
out: dict = {
|
||||||
|
"tally": tally(positions, asc_sign),
|
||||||
|
"critical_degrees": critical_degrees(positions),
|
||||||
|
"divisional": divisional(positions),
|
||||||
|
}
|
||||||
|
if "Sun" in by_name and "Moon" in by_name:
|
||||||
|
out["moon_phase"] = moon_phase(by_name["Sun"]["decimal"], by_name["Moon"]["decimal"])
|
||||||
|
if heavy:
|
||||||
|
out["planetary_hours"] = planetary_hours(engine, moment)
|
||||||
|
out["prenatal_syzygy"] = prenatal_syzygy(engine, moment)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Zbiorcza tabela dat z technik (LOG-14).
|
||||||
|
|
||||||
|
Spina w jedną, posortowaną oś czasu daty z kilku technik:
|
||||||
|
- profekcje roczne (LOG-10) — rok życia,
|
||||||
|
- Solar Return (LOG-12) — moment powrotu Słońca,
|
||||||
|
- dyrekcje solar-arc — daty dokładnych aspektów kierowanych planet do punktów
|
||||||
|
natalnych (wzorzec z notes3: „Profection planet | Aspect | Birth planet |
|
||||||
|
Exact Date"). Klucz łuku konfigurowalny; domyślnie Naiboda (0°59'08"/rok).
|
||||||
|
|
||||||
|
Każdy wiersz ma kształt z notes2: technique | significator | start | exact | end.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.engine.aspects import MAJOR, PL_NAME
|
||||||
|
from app.engine.profections import DOMICILE_RULERS, profected_sign
|
||||||
|
from app.engine.returns import find_return
|
||||||
|
|
||||||
|
NAIBOD_KEY = 0.9856472 # °/rok (0°59'08") — domyślny klucz solar-arc
|
||||||
|
DAYS_PER_YEAR = 365.2422
|
||||||
|
DIRECTED = ["Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||||
|
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto"]
|
||||||
|
|
||||||
|
|
||||||
|
def _add_years(birth: datetime, years: float) -> datetime:
|
||||||
|
return birth + timedelta(days=years * DAYS_PER_YEAR)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(technique, significator, start, exact, end) -> dict:
|
||||||
|
def iso(x):
|
||||||
|
return x.date().isoformat() if isinstance(x, datetime) else x
|
||||||
|
return {"technique": technique, "significator": significator,
|
||||||
|
"start": iso(start), "exact": iso(exact), "end": iso(end)}
|
||||||
|
|
||||||
|
|
||||||
|
def solar_arc_directions(
|
||||||
|
natal: dict[str, float], birth: datetime, lo: datetime, hi: datetime,
|
||||||
|
key: float = NAIBOD_KEY, orb_years: float = 1.0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Daty dyrekcji solar-arc w oknie [lo, hi].
|
||||||
|
|
||||||
|
natal: nazwa punktu -> długość natalna (planety + Asc/MC). Kierowane są planety
|
||||||
|
(DIRECTED), celem każdy punkt natalny. Aspekt dokładny gdy łuk = odległość
|
||||||
|
kątowa (mod 360). Wiek = łuk/klucz; data = urodziny + wiek.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
lo_age = (lo - birth).days / DAYS_PER_YEAR - orb_years
|
||||||
|
hi_age = (hi - birth).days / DAYS_PER_YEAR + orb_years
|
||||||
|
for p in DIRECTED:
|
||||||
|
if p not in natal:
|
||||||
|
continue
|
||||||
|
for q, q_lon in natal.items():
|
||||||
|
for asp, angle in MAJOR.items():
|
||||||
|
for target in ({angle, (360.0 - angle) % 360.0}):
|
||||||
|
arc = (q_lon + target - natal[p]) % 360.0
|
||||||
|
age = arc / key
|
||||||
|
if not (lo_age <= age <= hi_age) or (p == q and arc < 1e-6):
|
||||||
|
continue
|
||||||
|
exact = _add_years(birth, age)
|
||||||
|
row = _row(
|
||||||
|
"solar_arc",
|
||||||
|
f"dyr. {p} {PL_NAME[asp]} {q}",
|
||||||
|
_add_years(birth, age - orb_years),
|
||||||
|
exact,
|
||||||
|
_add_years(birth, age + orb_years),
|
||||||
|
)
|
||||||
|
row.update(directed=p, aspect=asp, target=q) # do budowy tokenów (1B->2B)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def profection_events(natal_asc: float, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||||
|
"""Lata profekcyjne (LOG-10) nachodzące na okno."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for age in range((lo.year - birth.year) - 1, (hi.year - birth.year) + 1):
|
||||||
|
if age < 0:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
start = birth.replace(year=birth.year + age)
|
||||||
|
end = birth.replace(year=birth.year + age + 1)
|
||||||
|
except ValueError: # 29 lutego
|
||||||
|
start = birth.replace(year=birth.year + age, day=28)
|
||||||
|
end = birth.replace(year=birth.year + age + 1, day=28)
|
||||||
|
if end < lo or start > hi:
|
||||||
|
continue
|
||||||
|
sign = profected_sign(natal_asc, age)
|
||||||
|
lord = DOMICILE_RULERS[sign]
|
||||||
|
row = _row(
|
||||||
|
"profection", f"Władca Roku: {lord} (Asc {sign}, wiek {age})",
|
||||||
|
start, start, end,
|
||||||
|
)
|
||||||
|
row.update(lord=lord, sign=sign) # do budowy tokenów (1B->2B)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def solar_return_events(engine, natal_moment, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||||
|
"""Solariusze w oknie (LOG-12) — jeden na rok."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for year in range(lo.year, hi.year + 1):
|
||||||
|
try:
|
||||||
|
around = birth.replace(year=year)
|
||||||
|
except ValueError:
|
||||||
|
around = birth.replace(year=year, day=28)
|
||||||
|
hit = find_return(engine, "solar", natal_moment, around)
|
||||||
|
if hit and lo <= hit <= hi:
|
||||||
|
out.append(_row("solar_return", "Solar Return", hit, hit, _add_years(hit, 1)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def firdaria_events(natal_points: dict[str, float], birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||||
|
"""Starty okresów/podokresów Firdarii (LOG-11) nachodzące na okno."""
|
||||||
|
from app.engine.firdaria import firdaria
|
||||||
|
|
||||||
|
fd = firdaria(birth, natal_points["Sun"], natal_points["Asc"], natal_points["MC"])
|
||||||
|
out: list[dict] = []
|
||||||
|
for period in fd["periods"]:
|
||||||
|
if "sub" in period:
|
||||||
|
for s in period["sub"]:
|
||||||
|
if lo <= _as_dt(s["start"]) <= hi:
|
||||||
|
row = _row("firdaria", f"Firdaria: {period['lord']} / {s['lord']}",
|
||||||
|
s["start"], s["start"], s["end"])
|
||||||
|
row.update(fd_major=period["lord"], fd_sub=s["lord"])
|
||||||
|
out.append(row)
|
||||||
|
elif lo <= _as_dt(period["start"]) <= hi: # węzeł — bez podokresów
|
||||||
|
row = _row("firdaria", f"Firdaria: {period['lord']}",
|
||||||
|
period["start"], period["start"], period["end"])
|
||||||
|
row.update(fd_major=period["lord"])
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dt(d) -> datetime:
|
||||||
|
if isinstance(d, datetime):
|
||||||
|
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
||||||
|
if isinstance(d, date):
|
||||||
|
return datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||||
|
return datetime.fromisoformat(str(d)).replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def build_timeline(
|
||||||
|
engine, natal_moment, natal_points: dict[str, float],
|
||||||
|
from_d, to_d, techniques: list[str] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Scala wybrane techniki w jedną oś czasu, posortowaną po dacie dokładnej."""
|
||||||
|
lo, hi = _as_dt(from_d), _as_dt(to_d)
|
||||||
|
birth = natal_moment.when_utc
|
||||||
|
want = set(techniques or ["profection", "solar_return", "solar_arc", "firdaria"])
|
||||||
|
events: list[dict] = []
|
||||||
|
if "profection" in want:
|
||||||
|
events += profection_events(natal_points["Asc"], birth, lo, hi)
|
||||||
|
if "solar_return" in want:
|
||||||
|
events += solar_return_events(engine, natal_moment, birth, lo, hi)
|
||||||
|
if "solar_arc" in want:
|
||||||
|
events += solar_arc_directions(natal_points, birth, lo, hi)
|
||||||
|
if "firdaria" in want:
|
||||||
|
events += firdaria_events(natal_points, birth, lo, hi)
|
||||||
|
events.sort(key=lambda e: e["exact"])
|
||||||
|
return events
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Systemy zodiaku (LOG-04): tropikalny, syderyczny (ayanamsy), draconic + RA.
|
||||||
|
|
||||||
|
Wszystkie pozycje silnika są liczone **tropikalnie of-date** (kontrakt LOG-28).
|
||||||
|
Zmiana zodiaku to — dla zodiaków ekliptycznych — jednolite przesunięcie długości:
|
||||||
|
|
||||||
|
długość_docelowa = (długość_tropikalna − offset) mod 360
|
||||||
|
|
||||||
|
gdzie offset to:
|
||||||
|
- **syderyczny**: ayanamsa (kąt między tropikalnym a syderycznym punktem Barana),
|
||||||
|
- **draconic**: długość wznoszącego węzła Księżyca (węzeł = 0° draconic),
|
||||||
|
- **tropikalny**: 0.
|
||||||
|
|
||||||
|
Ponieważ to stałe przesunięcie obiektu ORAZ cusps, **numery domów się nie zmieniają**
|
||||||
|
(geometria jest niezmiennicza względem obrotu) — przesuwamy tylko etykiety znaków.
|
||||||
|
|
||||||
|
Model ayanamsy: `ayan(jd) = ayan0 + B·x + C·x²`, gdzie `x = jd − J2000`. Prędkość
|
||||||
|
precesji (B, C) jest **wspólna** dla wszystkich ayanams; różni je tylko stała `ayan0`
|
||||||
|
(wybór syderycznego zera). Stałe skalibrowano do Swiss Ephemeris jako wyroczni —
|
||||||
|
zgodność do ~0,02" w latach 1900–2100 (patrz tests/test_zodiac.py).
|
||||||
|
|
||||||
|
RA (right ascension): konwersja ekliptyka→równik dla przyszłego widoku równikowego.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
TROPICAL = "tropical"
|
||||||
|
DRACONIC = "draconic"
|
||||||
|
|
||||||
|
# stała ayanamsy w J2000.0 (°) — skalibrowana do swisseph (get_ayanamsa_ut)
|
||||||
|
_AYAN0 = {
|
||||||
|
"lahiri": 23.857092,
|
||||||
|
"fagan_bradley": 24.740300,
|
||||||
|
"krishnamurti": 23.760240,
|
||||||
|
}
|
||||||
|
_J2000 = 2451545.0
|
||||||
|
_B = 3.824459e-5 # °/dobę — liniowy człon precesji (wspólny)
|
||||||
|
_C = 2.304e-13 # °/dobę² — drobne przyspieszenie (wspólne)
|
||||||
|
|
||||||
|
# nazwy zodiaków akceptowane przez API
|
||||||
|
SIDEREAL = tuple(f"sidereal_{k}" for k in _AYAN0) # sidereal_lahiri, ...
|
||||||
|
SYSTEMS = (TROPICAL, *SIDEREAL, DRACONIC)
|
||||||
|
|
||||||
|
|
||||||
|
def julian_day(dt: datetime) -> float:
|
||||||
|
"""Julian Day (UT) z momentu UTC — algorytm Meeusa (kalendarz gregoriański)."""
|
||||||
|
y, m = dt.year, dt.month
|
||||||
|
day = dt.day + (dt.hour + dt.minute / 60.0 + dt.second / 3600.0
|
||||||
|
+ dt.microsecond / 3.6e9) / 24.0
|
||||||
|
if m <= 2:
|
||||||
|
y -= 1
|
||||||
|
m += 12
|
||||||
|
a = y // 100
|
||||||
|
b = 2 - a + a // 4
|
||||||
|
return math.floor(365.25 * (y + 4716)) + math.floor(30.6001 * (m + 1)) + day + b - 1524.5
|
||||||
|
|
||||||
|
|
||||||
|
def ayanamsha(name: str, jd: float) -> float:
|
||||||
|
"""Ayanamsa [°] danej szkoły dla Julian Day (UT)."""
|
||||||
|
key = name[len("sidereal_"):] if name.startswith("sidereal_") else name
|
||||||
|
if key not in _AYAN0:
|
||||||
|
raise ValueError(f"Nieznana ayanamsa: {name!r} (dostępne: {', '.join(_AYAN0)})")
|
||||||
|
x = jd - _J2000
|
||||||
|
return _AYAN0[key] + _B * x + _C * x * x
|
||||||
|
|
||||||
|
|
||||||
|
def offset(zodiac: str, jd: float, node_lon: float | None = None) -> float:
|
||||||
|
"""Ile odjąć od długości tropikalnej, by dostać wybrany zodiak.
|
||||||
|
|
||||||
|
`node_lon` (tropikalna długość węzła wznoszącego) wymagana tylko dla draconic.
|
||||||
|
"""
|
||||||
|
if zodiac == TROPICAL:
|
||||||
|
return 0.0
|
||||||
|
if zodiac == DRACONIC:
|
||||||
|
if node_lon is None:
|
||||||
|
raise ValueError("draconic wymaga długości węzła (node_lon)")
|
||||||
|
return norm360(node_lon)
|
||||||
|
if zodiac in SIDEREAL:
|
||||||
|
return ayanamsha(zodiac, jd)
|
||||||
|
raise ValueError(f"Nieznany zodiak: {zodiac!r} (dostępne: {', '.join(SYSTEMS)})")
|
||||||
|
|
||||||
|
|
||||||
|
def apply(lon: float, off: float) -> float:
|
||||||
|
"""Długość w docelowym zodiaku."""
|
||||||
|
return norm360(lon - off)
|
||||||
|
|
||||||
|
|
||||||
|
def to_equatorial(lon: float, lat: float, eps: float) -> tuple[float, float]:
|
||||||
|
"""Ekliptyka (λ, β) → równik: (RA, deklinacja) w stopniach. Wszystko w °.
|
||||||
|
|
||||||
|
RA rośnie 0–360°; deklinacja w [−90, 90].
|
||||||
|
"""
|
||||||
|
lam, bet, e = math.radians(lon), math.radians(lat), math.radians(eps)
|
||||||
|
sin_dec = math.sin(bet) * math.cos(e) + math.cos(bet) * math.sin(e) * math.sin(lam)
|
||||||
|
dec = math.asin(max(-1.0, min(1.0, sin_dec)))
|
||||||
|
ra = math.atan2(
|
||||||
|
math.sin(lam) * math.cos(e) - math.tan(bet) * math.sin(e),
|
||||||
|
math.cos(lam),
|
||||||
|
)
|
||||||
|
return norm360(math.degrees(ra)), math.degrees(dec)
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
||||||
|
|
||||||
|
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
||||||
|
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
||||||
|
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
||||||
|
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
||||||
|
zrzut z węzła), miał je w całości.
|
||||||
|
|
||||||
|
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
||||||
|
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
||||||
|
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
||||||
|
podatne na modyfikację".
|
||||||
|
|
||||||
|
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
||||||
|
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
||||||
|
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
||||||
|
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
||||||
|
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
||||||
|
|
||||||
|
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
||||||
|
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
||||||
|
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
||||||
|
|
||||||
|
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
||||||
|
|
||||||
|
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
||||||
|
|
||||||
|
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
||||||
|
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
||||||
|
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
||||||
|
|
||||||
|
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
||||||
|
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
||||||
|
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
||||||
|
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.link")
|
||||||
|
|
||||||
|
MAGIC = b"AL1"
|
||||||
|
VERSION = "v1"
|
||||||
|
NONCE_BYTES = 12
|
||||||
|
KEY_BYTES = 32 # AES-256
|
||||||
|
LENGTH_PREFIX = 4
|
||||||
|
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
||||||
|
MAX_SKEW_SECONDS = 300.0
|
||||||
|
|
||||||
|
HEADER_ENC = "X-Astrololo-Enc"
|
||||||
|
HEADER_TS = "X-Astrololo-Enc-Ts"
|
||||||
|
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
||||||
|
|
||||||
|
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||||
|
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||||
|
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||||
|
|
||||||
|
REQUEST, RESPONSE = b"req", b"res"
|
||||||
|
|
||||||
|
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
||||||
|
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
class LinkError(Exception):
|
||||||
|
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- klucze
|
||||||
|
|
||||||
|
def parse_key(raw: str) -> bytes:
|
||||||
|
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
raise LinkError("pusty klucz łącza")
|
||||||
|
try:
|
||||||
|
key = bytes.fromhex(text)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(text, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
||||||
|
if len(key) != KEY_BYTES:
|
||||||
|
raise LinkError(
|
||||||
|
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
||||||
|
f"`openssl rand -hex 32`"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def key_from_env(env_name: str) -> bytes | None:
|
||||||
|
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
||||||
|
raw = os.getenv(env_name, "")
|
||||||
|
return parse_key(raw) if raw.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_required() -> bool:
|
||||||
|
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
||||||
|
|
||||||
|
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
||||||
|
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
||||||
|
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
||||||
|
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
||||||
|
"""
|
||||||
|
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
||||||
|
return HKDF(
|
||||||
|
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
||||||
|
info=b"astrololo/link/" + direction,
|
||||||
|
).derive(link_key)
|
||||||
|
|
||||||
|
|
||||||
|
class Link:
|
||||||
|
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
||||||
|
|
||||||
|
def __init__(self, link_key: bytes) -> None:
|
||||||
|
self._by_direction = {
|
||||||
|
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
||||||
|
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- pojedyncza ramka
|
||||||
|
|
||||||
|
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
||||||
|
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
||||||
|
stamp.encode("ascii"), str(seq).encode("ascii")])
|
||||||
|
|
||||||
|
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
plaintext: bytes) -> bytes:
|
||||||
|
nonce = os.urandom(NONCE_BYTES)
|
||||||
|
sealed = self._by_direction[direction].encrypt(
|
||||||
|
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
||||||
|
return MAGIC + nonce + sealed
|
||||||
|
|
||||||
|
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
frame: bytes) -> bytes:
|
||||||
|
if not frame.startswith(MAGIC):
|
||||||
|
raise LinkError("ramka bez znacznika astrololo")
|
||||||
|
body = frame[len(MAGIC):]
|
||||||
|
if len(body) <= NONCE_BYTES:
|
||||||
|
raise LinkError("ramka za krótka")
|
||||||
|
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
|
||||||
|
try:
|
||||||
|
return self._by_direction[direction].decrypt(
|
||||||
|
nonce, sealed, self._aad(direction, path, stamp, seq))
|
||||||
|
except InvalidTag as exc:
|
||||||
|
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
|
||||||
|
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
|
||||||
|
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
|
||||||
|
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ strumień ramek
|
||||||
|
|
||||||
|
def seal_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
chunks: Iterable[bytes]) -> Iterator[bytes]:
|
||||||
|
for seq, chunk in enumerate(chunks):
|
||||||
|
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
|
||||||
|
|
||||||
|
def open_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
raw: bytes) -> Iterator[bytes]:
|
||||||
|
for seq, frame in enumerate(frames_in(raw)):
|
||||||
|
yield self.open(direction, path, stamp, seq, frame)
|
||||||
|
|
||||||
|
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
|
||||||
|
return b"".join(self.open_stream(direction, path, stamp, raw))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ramkowanie
|
||||||
|
|
||||||
|
def frame_out(payload: bytes) -> bytes:
|
||||||
|
return struct.pack(">I", len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def frames_in(raw: bytes) -> Iterator[bytes]:
|
||||||
|
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
|
||||||
|
offset = 0
|
||||||
|
while offset < len(raw):
|
||||||
|
if offset + LENGTH_PREFIX > len(raw):
|
||||||
|
raise LinkError("urwana ramka (brak nagłówka długości)")
|
||||||
|
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
offset += LENGTH_PREFIX
|
||||||
|
if offset + size > len(raw):
|
||||||
|
raise LinkError("urwana ramka (za mało danych)")
|
||||||
|
yield raw[offset:offset + size]
|
||||||
|
offset += size
|
||||||
|
|
||||||
|
|
||||||
|
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
|
||||||
|
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
|
||||||
|
|
||||||
|
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
|
||||||
|
rozjechać się między dwa odczyty.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
if len(buffer) < LENGTH_PREFIX:
|
||||||
|
return
|
||||||
|
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
if len(buffer) < LENGTH_PREFIX + size:
|
||||||
|
return
|
||||||
|
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
|
||||||
|
del buffer[:LENGTH_PREFIX + size]
|
||||||
|
yield frame
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- świeżość ruchu
|
||||||
|
|
||||||
|
def stamp_now() -> str:
|
||||||
|
return f"{time.time():.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def check_stamp(stamp: str) -> None:
|
||||||
|
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
|
||||||
|
odtworzyć w dowolnym momencie w przyszłości."""
|
||||||
|
try:
|
||||||
|
sent = float(stamp)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise LinkError("brak albo błędny znacznik czasu") from exc
|
||||||
|
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
|
||||||
|
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================== strona serwerowa
|
||||||
|
|
||||||
|
class LinkCryptoMiddleware:
|
||||||
|
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
|
||||||
|
|
||||||
|
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
|
||||||
|
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
|
||||||
|
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, link: Link | None, layer: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.link = link
|
||||||
|
self.layer = layer
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
path = scope["path"]
|
||||||
|
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
|
||||||
|
|
||||||
|
if headers.get(HEADER_ENC.lower()) != VERSION:
|
||||||
|
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
|
||||||
|
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
|
||||||
|
# chcemy po cichu wrócić do jawnego ruchu.
|
||||||
|
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
|
||||||
|
self.layer, path)
|
||||||
|
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
|
||||||
|
|
||||||
|
stamp = headers.get(HEADER_TS.lower(), "")
|
||||||
|
try:
|
||||||
|
check_stamp(stamp)
|
||||||
|
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
|
||||||
|
except LinkError as exc:
|
||||||
|
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
|
||||||
|
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
|
||||||
|
|
||||||
|
scope = dict(scope)
|
||||||
|
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
|
||||||
|
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
|
||||||
|
|
||||||
|
def _sealing_send(self, send, path: str):
|
||||||
|
state: dict = {"stamp": "", "seq": 0}
|
||||||
|
|
||||||
|
async def sealing(message):
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
state["stamp"] = stamp_now()
|
||||||
|
keep = [(k, v) for k, v in message.get("headers", [])
|
||||||
|
if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
message = dict(message)
|
||||||
|
message["headers"] = keep + [
|
||||||
|
(b"content-type", CONTENT_TYPE.encode()),
|
||||||
|
(HEADER_ENC.lower().encode(), VERSION.encode()),
|
||||||
|
(HEADER_TS.lower().encode(), state["stamp"].encode()),
|
||||||
|
]
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
if message["type"] == "http.response.body":
|
||||||
|
chunk = message.get("body", b"")
|
||||||
|
sealed = b""
|
||||||
|
if chunk:
|
||||||
|
sealed = frame_out(self.link.seal(
|
||||||
|
RESPONSE, path, state["stamp"], state["seq"], chunk))
|
||||||
|
state["seq"] += 1
|
||||||
|
return await send({"type": "http.response.body", "body": sealed,
|
||||||
|
"more_body": message.get("more_body", False)})
|
||||||
|
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
return sealing
|
||||||
|
|
||||||
|
|
||||||
|
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
|
||||||
|
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
|
||||||
|
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
|
||||||
|
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
kept.append((b"content-length", str(length).encode()))
|
||||||
|
if length:
|
||||||
|
kept.append((b"content-type", b"application/json"))
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_body(receive) -> bytes:
|
||||||
|
body = bytearray()
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.disconnect":
|
||||||
|
raise LinkError("rozłączenie w trakcie odbioru żądania")
|
||||||
|
body += message.get("body", b"")
|
||||||
|
if not message.get("more_body", False):
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _replay(body: bytes, original):
|
||||||
|
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
|
||||||
|
|
||||||
|
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
|
||||||
|
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
|
||||||
|
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
|
||||||
|
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
|
||||||
|
"""
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
if delivered:
|
||||||
|
return await original()
|
||||||
|
delivered = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return receive
|
||||||
|
|
||||||
|
|
||||||
|
async def _refuse(send, detail: str) -> None:
|
||||||
|
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
|
||||||
|
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
|
||||||
|
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
|
||||||
|
await send({"type": "http.response.start", "status": 400, "headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"content-length", str(len(payload)).encode()),
|
||||||
|
]})
|
||||||
|
await send({"type": "http.response.body", "body": payload})
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, env_name: str, layer: str):
|
||||||
|
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
|
||||||
|
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
|
||||||
|
link_key = key_from_env(env_name)
|
||||||
|
if link_key is None and encryption_required():
|
||||||
|
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
|
||||||
|
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
|
||||||
|
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
|
||||||
|
f"warstwa {layer} nie wystartuje bez klucza łącza"
|
||||||
|
)
|
||||||
|
if link_key is None:
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
|
||||||
|
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
|
||||||
|
"ruch wewnątrz sieci.", env_name, layer,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
link = Link(link_key)
|
||||||
|
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
|
||||||
|
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ strona kliencka
|
||||||
|
|
||||||
|
def call(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
|
||||||
|
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
|
||||||
|
|
||||||
|
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
|
||||||
|
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
|
||||||
|
się niejasnym błędem odszyfrowania.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
|
||||||
|
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
|
||||||
|
# sygnifikatory, o które pytamy bazę.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
|
||||||
|
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
response = client.request(method, url, json=payload, headers=request_headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
path = httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
|
||||||
|
"Content-Type": CONTENT_TYPE})
|
||||||
|
|
||||||
|
response = client.request(method, url, content=body, headers=request_headers)
|
||||||
|
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
log.error("łącze %s odmówiło: %s", path, response.text[:200])
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
|
||||||
|
reply_stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(reply_stamp)
|
||||||
|
return link.open_all(RESPONSE, path, reply_stamp, response.content)
|
||||||
|
|
||||||
|
|
||||||
|
def call_json(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None):
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
return _json.loads(call(client, method, url, payload=payload,
|
||||||
|
headers=headers, link=link))
|
||||||
|
|
||||||
|
|
||||||
|
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
|
||||||
|
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
|
||||||
|
|
||||||
|
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
|
||||||
|
w buforze zamiast zakładać, że każdy kawałek to komplet.
|
||||||
|
"""
|
||||||
|
if link is None:
|
||||||
|
yield from response.iter_bytes()
|
||||||
|
return
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
|
||||||
|
stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(stamp)
|
||||||
|
path = response.request.url.path
|
||||||
|
buffer = bytearray()
|
||||||
|
seq = 0
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
buffer += chunk
|
||||||
|
for frame in unframe_incremental(buffer):
|
||||||
|
yield link.open(RESPONSE, path, stamp, seq, frame)
|
||||||
|
seq += 1
|
||||||
|
if buffer:
|
||||||
|
raise LinkError("strumień urwał się w połowie ramki")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
|
||||||
|
link: Link | None) -> Iterator[str]:
|
||||||
|
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
|
||||||
|
|
||||||
|
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
|
||||||
|
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
|
||||||
|
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
|
||||||
|
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
|
||||||
|
dopiero na znakach nowej linii.
|
||||||
|
|
||||||
|
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
|
||||||
|
działał bez zmian.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx as _httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
|
||||||
|
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
|
||||||
|
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
|
||||||
|
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
with client.stream("POST", url, json=payload, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
for text_line in response.iter_lines():
|
||||||
|
if text_line:
|
||||||
|
yield text_line
|
||||||
|
return
|
||||||
|
|
||||||
|
path = _httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
|
||||||
|
with client.stream("POST", url, content=body, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
buffer = bytearray()
|
||||||
|
for plain in open_response_stream(response, link):
|
||||||
|
buffer += plain
|
||||||
|
while True:
|
||||||
|
nl = buffer.find(b"\n")
|
||||||
|
if nl < 0:
|
||||||
|
break
|
||||||
|
text_line = bytes(buffer[:nl])
|
||||||
|
del buffer[:nl + 1]
|
||||||
|
if text_line:
|
||||||
|
yield text_line.decode("utf-8")
|
||||||
|
if buffer:
|
||||||
|
yield bytes(buffer).decode("utf-8")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Warstwa dostawców modeli językowych (LOG-31)."""
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Kontrakt dostawcy modelu językowego (LOG-31).
|
||||||
|
|
||||||
|
Analogicznie do `EphemerisEngine` (LOG-24): prezentacja i reszta logiki nie wiedzą,
|
||||||
|
kto pisze tekst — lokalny model na naszym sprzęcie czy dostawca w chmurze.
|
||||||
|
|
||||||
|
Kluczowa własność dla bezpieczeństwa baz (LOG-32): każdy dostawca deklaruje
|
||||||
|
`leaves_lan`. Prompt niesie ORYGINALNE opisy z baz, więc interfejs musi jawnie
|
||||||
|
mówić, czy ta treść opuszcza naszą sieć — UI ma na tej podstawie ostrzegać.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Completion:
|
||||||
|
"""Wynik generowania — tekst + metryki do rozliczenia i podglądu."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
model: str
|
||||||
|
provider: str
|
||||||
|
leaves_lan: bool
|
||||||
|
usage: dict = field(default_factory=dict) # prompt_tokens / completion_tokens
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(RuntimeError):
|
||||||
|
"""Błąd wołania modelu — z komunikatem nadającym się do pokazania użytkownikowi."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider(ABC):
|
||||||
|
name: str = "?"
|
||||||
|
#: czy treść promptu (a więc opisy z baz) opuszcza naszą sieć
|
||||||
|
leaves_lan: bool = True
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||||
|
"""Zwraca gotowy tekst. Rzuca LLMError przy niepowodzeniu."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def health(self) -> dict:
|
||||||
|
"""Czy dostawca jest osiągalny i skonfigurowany."""
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Katalog modeli do wyboru w UI (LOG-31).
|
||||||
|
|
||||||
|
To są **podpowiedzi**, nie zamknięta lista. Pole modelu w UI jest tekstowe z
|
||||||
|
datalistą, więc można wpisać dowolny identyfikator — konto może mieć dostęp do
|
||||||
|
modeli, których tu nie ma, a nowe wychodzą szybciej, niż aktualizuje się kod.
|
||||||
|
Puste pole = model domyślny dostawcy.
|
||||||
|
|
||||||
|
Uwaga o pewności danych:
|
||||||
|
* modele **Anthropic** pochodzą z oficjalnej dokumentacji API (okna kontekstu
|
||||||
|
i limity wyjścia zgadzają się z `app/llm/limits.py`);
|
||||||
|
* modele **OpenAI** to podpowiedzi — nie weryfikowałem ich katalogu, więc
|
||||||
|
traktuj je jako wygodę, a nie źródło prawdy;
|
||||||
|
* modele **lokalne** zależą wyłącznie od tego, co masz pobrane w Ollamie/vLLM.
|
||||||
|
|
||||||
|
Katalog można nadpisać/rozszerzyć zmienną `<DOSTAWCA>_MODELS` (lista po przecinku),
|
||||||
|
np. `OPENAI_MODELS="gpt-5,gpt-4o"`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.llm.limits import limits_for
|
||||||
|
|
||||||
|
# dostawca -> [(id modelu, krótki opis dla człowieka)]
|
||||||
|
_CATALOG: dict[str, list[tuple[str, str]]] = {
|
||||||
|
"anthropic": [
|
||||||
|
("claude-opus-4-8", "Opus 4.8 — domyślny, bardzo zdolny, 1M kontekstu"),
|
||||||
|
("claude-fable-5", "Fable 5 — najbardziej zdolny, do najtrudniejszych zadań"),
|
||||||
|
("claude-sonnet-5", "Sonnet 5 — szybszy i tańszy, jakość blisko Opusa"),
|
||||||
|
("claude-opus-4-7", "Opus 4.7 — poprzednia generacja Opusa"),
|
||||||
|
("claude-haiku-4-5", "Haiku 4.5 — najszybszy i najtańszy, mniejsze okno"),
|
||||||
|
],
|
||||||
|
"openai": [
|
||||||
|
("gpt-4o-mini", "GPT-4o mini — tani i szybki"),
|
||||||
|
("gpt-4o", "GPT-4o"),
|
||||||
|
("gpt-5", "GPT-5 — jeśli Twoje konto ma dostęp"),
|
||||||
|
("gpt-4.1", "GPT-4.1"),
|
||||||
|
("gpt-4.1-mini", "GPT-4.1 mini"),
|
||||||
|
],
|
||||||
|
"local": [
|
||||||
|
("llama3.1:8b", "Llama 3.1 8B"),
|
||||||
|
("llama3.2", "Llama 3.2"),
|
||||||
|
("qwen2.5", "Qwen 2.5 — większe okno kontekstu"),
|
||||||
|
("mistral", "Mistral"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def models_for(provider: str) -> list[dict]:
|
||||||
|
"""Podpowiedzi modeli dla dostawcy, wraz z oknem kontekstu.
|
||||||
|
|
||||||
|
Okno kontekstu podajemy, bo wprost przekłada się na opcję „maksymalny
|
||||||
|
kontekst modelu" — użytkownik widzi, na ile budżetu promptu może liczyć.
|
||||||
|
"""
|
||||||
|
override = os.getenv(f"{provider.upper()}_MODELS", "").strip()
|
||||||
|
if override:
|
||||||
|
entries = [(m.strip(), "") for m in override.split(",") if m.strip()]
|
||||||
|
else:
|
||||||
|
entries = _CATALOG.get(provider, [])
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for model_id, label in entries:
|
||||||
|
context_window, max_output = limits_for(provider, model_id)
|
||||||
|
out.append({
|
||||||
|
"id": model_id,
|
||||||
|
"label": label or model_id,
|
||||||
|
"context_window": context_window,
|
||||||
|
"max_output": max_output,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def catalog() -> dict[str, list[dict]]:
|
||||||
|
"""Pełny katalog dla UI — jedno żądanie zamiast trzech."""
|
||||||
|
return {provider: models_for(provider) for provider in ("local", "anthropic", "openai")}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Wybór dostawcy LLM (LOG-31) — jedyne miejsce znające konkretne implementacje.
|
||||||
|
|
||||||
|
Domyślny jest **model lokalny**: prompt niesie oryginalne opisy z baz, więc
|
||||||
|
domyślnie nic nie opuszcza naszej sieci (LOG-32). Chmurę włącza się świadomie —
|
||||||
|
przez konfigurację albo pojedyncze żądanie.
|
||||||
|
|
||||||
|
Konfiguracja jest **per dostawca**, bo UI pozwala przełączać go przy każdym żądaniu.
|
||||||
|
Wspólne `LLM_*` nie wystarczy: ustawienie `LLM_BASE_URL` na lokalny model kierowałoby
|
||||||
|
tam także żądania do OpenAI, a `LLM_MODEL=llama3.1:8b` kazałoby Anthropic użyć modelu
|
||||||
|
llama. Dlatego każdy dostawca ma własny komplet zmiennych.
|
||||||
|
|
||||||
|
Zmienne środowiskowe:
|
||||||
|
LLM_PROVIDER local (domyślnie) | openai | anthropic — dostawca domyślny
|
||||||
|
LLM_TIMEOUT sekundy (domyślnie 120)
|
||||||
|
LLM_MAX_TOKENS limit długości odpowiedzi (domyślnie 2000)
|
||||||
|
|
||||||
|
<DOSTAWCA>_MODEL / _BASE_URL / _API_KEY — konfiguracja konkretnego dostawcy:
|
||||||
|
LOCAL_MODEL, LOCAL_BASE_URL (klucz zwykle zbędny)
|
||||||
|
OPENAI_MODEL, OPENAI_BASE_URL, OPENAI_API_KEY
|
||||||
|
ANTHROPIC_MODEL, ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY
|
||||||
|
|
||||||
|
Klucze WYŁĄCZNIE z sekretu — nigdy w repo, w UI ani w logach.
|
||||||
|
|
||||||
|
Zgodność wstecz: wspólne `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` nadal działają,
|
||||||
|
ale stosują się TYLKO do dostawcy domyślnego (LLM_PROVIDER) — czyli konfiguracja
|
||||||
|
instalacji jednodostawcowej zostaje nietknięta, a pozostali dostawcy jej nie dziedziczą.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.llm.base import LLMError, LLMProvider
|
||||||
|
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
|
||||||
|
|
||||||
|
LOCAL = "local"
|
||||||
|
OPENAI = "openai"
|
||||||
|
ANTHROPIC = "anthropic"
|
||||||
|
PROVIDERS = (LOCAL, OPENAI, ANTHROPIC)
|
||||||
|
|
||||||
|
_DEFAULT_MODEL = {
|
||||||
|
LOCAL: "llama3.1:8b",
|
||||||
|
OPENAI: "gpt-4o-mini",
|
||||||
|
# Opus 4.8 świadomie zamiast Sonnet 5: Sonnet uruchamia myślenie adaptacyjne,
|
||||||
|
# gdy pominąć parametr `thinking`, a jego tokeny liczą się do max_tokens —
|
||||||
|
# przy ciasnym limicie cała tura wychodziła jako samo myślenie z pustym
|
||||||
|
# tekstem. To była przyczyna pustych odpowiedzi na Anthropicu.
|
||||||
|
ANTHROPIC: "claude-opus-4-8",
|
||||||
|
}
|
||||||
|
_DEFAULT_URL = {
|
||||||
|
# Ollama i vLLM wystawiają zgodne API pod /v1
|
||||||
|
LOCAL: "http://localhost:11434/v1",
|
||||||
|
OPENAI: "https://api.openai.com/v1",
|
||||||
|
ANTHROPIC: "https://api.anthropic.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_provider_name() -> str:
|
||||||
|
return os.getenv("LLM_PROVIDER", LOCAL).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def max_tokens() -> int:
|
||||||
|
return int(os.getenv("LLM_MAX_TOKENS", "2000"))
|
||||||
|
|
||||||
|
|
||||||
|
def timeout() -> float:
|
||||||
|
return float(os.getenv("LLM_TIMEOUT", "120"))
|
||||||
|
|
||||||
|
|
||||||
|
def setting(provider: str, suffix: str, fallback: str = "") -> str:
|
||||||
|
"""Ustawienie dostawcy: <DOSTAWCA>_<SUFIKS> → LLM_<SUFIKS> → wbudowana domyślna.
|
||||||
|
|
||||||
|
Wspólne `LLM_*` stosuje się WYŁĄCZNIE do dostawcy domyślnego — inaczej adres
|
||||||
|
lokalnego modelu przejąłby żądania do chmury (i odwrotnie).
|
||||||
|
"""
|
||||||
|
specific = os.getenv(f"{provider.upper()}_{suffix}")
|
||||||
|
if specific:
|
||||||
|
return specific
|
||||||
|
if provider == default_provider_name():
|
||||||
|
generic = os.getenv(f"LLM_{suffix}")
|
||||||
|
if generic:
|
||||||
|
return generic
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_model(name: str | None = None, model: str | None = None) -> tuple[str, str]:
|
||||||
|
"""(dostawca, model) BEZ budowania dostawcy — czyli bez wymogu klucza API.
|
||||||
|
|
||||||
|
Rozmiar budżetu promptu zależy tylko od okna kontekstu modelu, więc nie może
|
||||||
|
zależeć od tego, czy klucz jest już skonfigurowany.
|
||||||
|
"""
|
||||||
|
provider = (name or default_provider_name()).lower()
|
||||||
|
if provider not in PROVIDERS:
|
||||||
|
provider = default_provider_name()
|
||||||
|
chosen = (model or "").strip() or setting(provider, "MODEL", _DEFAULT_MODEL[provider])
|
||||||
|
return provider, chosen
|
||||||
|
|
||||||
|
|
||||||
|
def build_provider(name: str | None = None, model: str | None = None) -> LLMProvider:
|
||||||
|
"""Dostawca modelu. `model` z żądania wygrywa nad konfiguracją — użytkownik
|
||||||
|
wybiera model w UI, a konfiguracja podaje tylko wartość domyślną."""
|
||||||
|
name = (name or default_provider_name()).lower()
|
||||||
|
if name not in PROVIDERS:
|
||||||
|
raise LLMError(f"Nieznany dostawca LLM: {name!r} (dostępne: {', '.join(PROVIDERS)})")
|
||||||
|
|
||||||
|
model = (model or "").strip() or setting(name, "MODEL", _DEFAULT_MODEL[name])
|
||||||
|
base_url = setting(name, "BASE_URL", _DEFAULT_URL[name])
|
||||||
|
api_key = setting(name, "API_KEY")
|
||||||
|
|
||||||
|
if name in (OPENAI, ANTHROPIC) and not api_key:
|
||||||
|
raise LLMError(
|
||||||
|
f"Brak klucza dla dostawcy {name} — ustaw {name.upper()}_API_KEY "
|
||||||
|
f"(z sekretu). Model lokalny klucza nie wymaga."
|
||||||
|
)
|
||||||
|
if name == ANTHROPIC:
|
||||||
|
return AnthropicProvider(base_url, model, api_key, timeout())
|
||||||
|
if name == OPENAI:
|
||||||
|
return ChatCompletionsProvider(OPENAI, base_url, model, api_key, timeout(),
|
||||||
|
leaves_lan=True)
|
||||||
|
# lokalny — klucz zwykle zbędny; treść NIE opuszcza sieci
|
||||||
|
return ChatCompletionsProvider(LOCAL, base_url, model, api_key, timeout(),
|
||||||
|
leaves_lan=False)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Okna kontekstu modeli i planowanie budżetu tokenów (LOG-30/31).
|
||||||
|
|
||||||
|
Po co to istnieje: horoskop MA powstać niezależnie od objętości promptu. Żeby to
|
||||||
|
zagwarantować, trzeba wiedzieć dwie rzeczy o każdym modelu — ile zmieści na
|
||||||
|
wejściu (okno kontekstu) i ile maksymalnie wypisze na wyjściu. Bez tego łatwo
|
||||||
|
wysłać prompt, który wypełnia całe okno i **nie zostawia miejsca na odpowiedź** —
|
||||||
|
model kończy wtedy na `max_tokens` z pustą albo uciętą treścią.
|
||||||
|
|
||||||
|
Zasada naczelna: **zawsze rezerwuj miejsce na odpowiedź.** Budżet promptu liczy
|
||||||
|
się jako `okno_kontekstu − zarezerwowane_wyjście − margines`, nigdy odwrotnie.
|
||||||
|
|
||||||
|
Wartości są zaszyte jako rozsądne domyślne i nadpisywalne środowiskiem
|
||||||
|
(`<DOSTAWCA>_CONTEXT_WINDOW`, `<DOSTAWCA>_MAX_OUTPUT`) — modele wychodzą szybciej,
|
||||||
|
niż aktualizuje się ten plik.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# model -> (okno kontekstu, maksymalne wyjście) w tokenach
|
||||||
|
_MODEL_LIMITS: dict[str, tuple[int, int]] = {
|
||||||
|
# Anthropic
|
||||||
|
"claude-opus-4-8": (1_000_000, 128_000),
|
||||||
|
"claude-opus-4-7": (1_000_000, 128_000),
|
||||||
|
"claude-opus-4-6": (1_000_000, 128_000),
|
||||||
|
"claude-sonnet-5": (1_000_000, 128_000),
|
||||||
|
"claude-sonnet-4-6": (1_000_000, 128_000),
|
||||||
|
"claude-fable-5": (1_000_000, 128_000),
|
||||||
|
"claude-haiku-4-5": (200_000, 64_000),
|
||||||
|
# OpenAI
|
||||||
|
"gpt-4o": (128_000, 16_384),
|
||||||
|
"gpt-4o-mini": (128_000, 16_384),
|
||||||
|
"gpt-4.1": (1_000_000, 32_768),
|
||||||
|
"gpt-4.1-mini": (1_000_000, 32_768),
|
||||||
|
# lokalne (Ollama/vLLM) — zwykle małe okno, dlatego ostrożna domyślna
|
||||||
|
"llama3.1": (8_192, 4_096),
|
||||||
|
"llama3.2": (8_192, 4_096),
|
||||||
|
"qwen2.5": (32_768, 8_192),
|
||||||
|
"mistral": (32_768, 8_192),
|
||||||
|
}
|
||||||
|
|
||||||
|
# gdy modelu nie ma w tabeli — zachowawczo, żeby nie obiecywać nieistniejącego okna
|
||||||
|
_FALLBACK: dict[str, tuple[int, int]] = {
|
||||||
|
"anthropic": (200_000, 32_000),
|
||||||
|
"openai": (128_000, 16_384),
|
||||||
|
"local": (8_192, 4_096),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ile tokenów zostawiamy jako bufor na narzut protokołu i niedokładność liczenia
|
||||||
|
SAFETY_MARGIN = 2_000
|
||||||
|
# poniżej tylu tokenów wyjścia nie ma sensu wołać modelu — nie zmieści horoskopu
|
||||||
|
MIN_OUTPUT = 1_500
|
||||||
|
# powyżej tylu tokenów promptu ostrzegamy użytkownika (nadal pozwalając wysłać)
|
||||||
|
WARN_PROMPT_TOKENS = 90_000
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(provider: str, suffix: str) -> int | None:
|
||||||
|
raw = os.getenv(f"{provider.upper()}_{suffix}")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return value if value > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def limits_for(provider: str, model: str) -> tuple[int, int]:
|
||||||
|
"""(okno kontekstu, maksymalne wyjście) dla modelu — z nadpisaniem z ENV.
|
||||||
|
|
||||||
|
Dopasowanie po prefiksie, bo nazwy modeli lokalnych niosą tag (`llama3.1:8b`).
|
||||||
|
"""
|
||||||
|
env_ctx = _env_int(provider, "CONTEXT_WINDOW")
|
||||||
|
env_out = _env_int(provider, "MAX_OUTPUT")
|
||||||
|
|
||||||
|
key = (model or "").strip().lower()
|
||||||
|
known: tuple[int, int] | None = _MODEL_LIMITS.get(key)
|
||||||
|
if known is None:
|
||||||
|
for name, pair in _MODEL_LIMITS.items():
|
||||||
|
if key.startswith(name):
|
||||||
|
known = pair
|
||||||
|
break
|
||||||
|
if known is None:
|
||||||
|
known = _FALLBACK.get(provider, _FALLBACK["local"])
|
||||||
|
|
||||||
|
return (env_ctx or known[0], env_out or known[1])
|
||||||
|
|
||||||
|
|
||||||
|
def plan(provider: str, model: str, prompt_tokens: int,
|
||||||
|
want_output: int | None = None) -> dict:
|
||||||
|
"""Ile tokenów wyjścia zamówić dla promptu tej wielkości.
|
||||||
|
|
||||||
|
Zwraca plan z jawną diagnostyką — UI ma z czego zbudować ostrzeżenie, a błąd
|
||||||
|
ma czym wytłumaczyć, dlaczego się nie udało.
|
||||||
|
"""
|
||||||
|
context_window, model_max_output = limits_for(provider, model)
|
||||||
|
room = context_window - prompt_tokens - SAFETY_MARGIN
|
||||||
|
target = want_output or model_max_output
|
||||||
|
max_output = max(0, min(model_max_output, target, room))
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
if prompt_tokens > WARN_PROMPT_TOKENS:
|
||||||
|
warnings.append(
|
||||||
|
f"Prompt ma ~{prompt_tokens} tokenów — to dużo. Zapytanie zostanie wysłane, "
|
||||||
|
f"ale potrwa dłużej i będzie odpowiednio kosztowne."
|
||||||
|
)
|
||||||
|
if max_output < MIN_OUTPUT:
|
||||||
|
warnings.append(
|
||||||
|
f"Po zmieszczeniu promptu zostaje tylko {max_output} tokenów na odpowiedź "
|
||||||
|
f"(minimum {MIN_OUTPUT}). Zmniejsz budżet promptu albo wybierz model "
|
||||||
|
f"z większym oknem kontekstu."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
"context_window": context_window,
|
||||||
|
"model_max_output": model_max_output,
|
||||||
|
"prompt_tokens": prompt_tokens,
|
||||||
|
"max_output": max_output,
|
||||||
|
"fits": max_output >= MIN_OUTPUT,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_token_budget(provider: str, model: str, reserve_output: int | None = None) -> int:
|
||||||
|
"""Ile tokenów promptu wolno wysłać, ZAWSZE zostawiając miejsce na odpowiedź.
|
||||||
|
|
||||||
|
To jest podstawa opcji „maksymalny kontekst modelu" w UI.
|
||||||
|
"""
|
||||||
|
context_window, model_max_output = limits_for(provider, model)
|
||||||
|
reserve = reserve_output or model_max_output
|
||||||
|
return max(0, context_window - reserve - SAFETY_MARGIN)
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""Implementacje dostawców LLM (LOG-31) — na samym httpx, bez SDK.
|
||||||
|
|
||||||
|
Świadomie bez bibliotek `openai` / `anthropic`: lokalny serwer modelu (Ollama,
|
||||||
|
vLLM, llama.cpp) i OpenAI mówią **tym samym** protokołem `/chat/completions`,
|
||||||
|
więc jedna implementacja obsługuje oba — różni je tylko adres i klucz. Anthropic
|
||||||
|
ma własny kształt `/v1/messages`, stąd druga klasa. Mniej zależności, mniej
|
||||||
|
powierzchni ataku, pełna kontrola nad tym, co wychodzi z sieci.
|
||||||
|
|
||||||
|
**Gwarancja niepustej odpowiedzi.** Horoskop ma powstać niezależnie od objętości
|
||||||
|
promptu, więc `generate()` nie jest pojedynczym strzałem, tylko pętlą:
|
||||||
|
1. wyślij turę z policzonym limitem wyjścia,
|
||||||
|
2. jeśli model urwał na limicie — dopisz turę „kontynuuj" i sklej tekst,
|
||||||
|
3. jeśli tura nie dała ani znaku tekstu — ponów z podpowiedzią,
|
||||||
|
4. dopiero brak tekstu po wszystkich próbach jest błędem (z diagnostyką).
|
||||||
|
Kontynuacja jest pewniejsza niż jedno wielkie żądanie: każda tura mieści się
|
||||||
|
w timeoucie HTTP, a długość odpowiedzi przestaje być ograniczona jedną turą.
|
||||||
|
|
||||||
|
**Anthropic i myślenie.** Modele Claude potrafią mieć włączone myślenie, którego
|
||||||
|
tokeny liczą się do `max_tokens`. Przy ciasnym limicie cała tura potrafi wyjść
|
||||||
|
jako same bloki `thinking` z pustym tekstem — dokładnie ten objaw, który
|
||||||
|
zgłoszono. Traktujemy taką turę jak ucięcie i kontynuujemy, zamiast zwracać pustkę.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.llm.base import Completion, LLMError, LLMProvider
|
||||||
|
|
||||||
|
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||||
|
MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
|
# ile razy wolno poprosić model o dokończenie urwanej odpowiedzi
|
||||||
|
MAX_CONTINUATIONS = 12
|
||||||
|
# ile tokenów zamawiać na jedną turę — mieści się w timeoucie, a pętla i tak
|
||||||
|
# dociągnie resztę; zbyt duża wartość ryzykuje zerwanie połączenia w trakcie
|
||||||
|
TURN_TOKENS_CAP = 16_000
|
||||||
|
|
||||||
|
_CONTINUE = (
|
||||||
|
"Kontynuuj dokładnie od miejsca, w którym przerwałeś — nie powtarzaj tego, "
|
||||||
|
"co już napisałeś, i nie zaczynaj od nowa. Jeśli skończyłeś całą odpowiedź, "
|
||||||
|
"napisz wyłącznie: KONIEC"
|
||||||
|
)
|
||||||
|
_NUDGE = (
|
||||||
|
"Nie otrzymałem żadnej treści. Napisz odpowiedź zgodnie z powyższym poleceniem, "
|
||||||
|
"zaczynając od razu od treści horoskopu."
|
||||||
|
)
|
||||||
|
_DONE_MARKER = "KONIEC"
|
||||||
|
|
||||||
|
|
||||||
|
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict:
|
||||||
|
"""POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx."""
|
||||||
|
last: Exception | None = None
|
||||||
|
for attempt in range(MAX_ATTEMPTS):
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
r = client.post(url, json=payload, headers=headers)
|
||||||
|
if r.status_code in RETRY_STATUSES and attempt < MAX_ATTEMPTS - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
if r.status_code >= 400:
|
||||||
|
raise LLMError(f"Model odpowiedział błędem {r.status_code}: {r.text[:300]}")
|
||||||
|
return r.json()
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
last = e
|
||||||
|
if attempt < MAX_ATTEMPTS - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise LLMError(
|
||||||
|
f"Model nie odpowiedział w czasie {timeout:.0f}s. Zwiększ LLM_TIMEOUT "
|
||||||
|
f"albo zmniejsz budżet promptu."
|
||||||
|
) from e
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
last = e
|
||||||
|
if attempt < MAX_ATTEMPTS - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise LLMError(f"Nie udało się połączyć z modelem: {e}") from e
|
||||||
|
raise LLMError(f"Nie udało się wywołać modelu: {last}")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_usage(total: dict, turn: dict) -> dict:
|
||||||
|
"""Sumuje zużycie tokenów przez wszystkie tury jednej odpowiedzi."""
|
||||||
|
for key, value in (turn or {}).items():
|
||||||
|
if isinstance(value, int):
|
||||||
|
total[key] = total.get(key, 0) + value
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _join(parts: list[str]) -> str:
|
||||||
|
return "".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _explain_empty(turns: int, usage: dict, stop: str | None) -> str:
|
||||||
|
detail = []
|
||||||
|
if stop:
|
||||||
|
detail.append(f"powód zakończenia: {stop}")
|
||||||
|
for key in ("completion_tokens", "output_tokens"):
|
||||||
|
if usage.get(key) is not None:
|
||||||
|
detail.append(f"tokeny odpowiedzi: {usage[key]}")
|
||||||
|
break
|
||||||
|
suffix = f" ({', '.join(detail)})" if detail else ""
|
||||||
|
return (
|
||||||
|
f"Model nie zwrócił żadnej treści po {turns} próbach{suffix}. "
|
||||||
|
f"Najczęstsza przyczyna: prompt wypełnił okno kontekstu i nie zostało miejsca "
|
||||||
|
f"na odpowiedź. Zmniejsz budżet promptu albo wybierz model z większym oknem."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Driver:
|
||||||
|
"""Wspólna pętla: tura → ewentualna kontynuacja → sklejony tekst.
|
||||||
|
|
||||||
|
Podklasy dostarczają tylko `_turn()` — reszta (kontynuacje, ponawianie pustej
|
||||||
|
tury, sumowanie zużycia) jest identyczna dla obu protokołów.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
model: str
|
||||||
|
leaves_lan: bool
|
||||||
|
|
||||||
|
def _turn(self, messages: list[dict], max_tokens: int):
|
||||||
|
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def generate(self, prompt: str, max_tokens: int, on_event=None) -> Completion:
|
||||||
|
"""`on_event(dict)` dostaje zdarzenia postępu — UI pokazuje z nich log.
|
||||||
|
Raportujemy KAŻDĄ turę, bo to ona trwa; bez tego pasek postępu byłby
|
||||||
|
ozdobnikiem, a nie informacją."""
|
||||||
|
def emit(kind: str, message: str, **extra):
|
||||||
|
if on_event:
|
||||||
|
on_event({"type": kind, "message": message, **extra})
|
||||||
|
|
||||||
|
messages: list[dict] = [{"role": "user", "content": prompt}]
|
||||||
|
parts: list[str] = []
|
||||||
|
usage: dict = {}
|
||||||
|
model_name = self.model
|
||||||
|
remaining = max(max_tokens, 256)
|
||||||
|
stop: str | None = None
|
||||||
|
turns = 0
|
||||||
|
nudged = False
|
||||||
|
|
||||||
|
while turns <= MAX_CONTINUATIONS:
|
||||||
|
turns += 1
|
||||||
|
budget = max(256, min(remaining, TURN_TOKENS_CAP))
|
||||||
|
emit("turn_start",
|
||||||
|
f"Tura {turns}: wysyłam do modelu {self.model} (limit {budget} tokenów)…",
|
||||||
|
turn=turns)
|
||||||
|
started = time.monotonic()
|
||||||
|
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
|
||||||
|
took = time.monotonic() - started
|
||||||
|
_merge_usage(usage, turn_usage)
|
||||||
|
|
||||||
|
# Odejmujemy tokeny FAKTYCZNIE wyprodukowane, nie zamówiony limit tury.
|
||||||
|
# Inaczej pierwsza tura zjadałaby cały budżet i urwana odpowiedź nigdy
|
||||||
|
# nie doczekałaby się kontynuacji — wracałby do użytkownika fragment
|
||||||
|
# udający całość.
|
||||||
|
produced = (turn_usage or {}).get("completion_tokens")
|
||||||
|
if produced is None:
|
||||||
|
produced = (turn_usage or {}).get("output_tokens")
|
||||||
|
if produced is None:
|
||||||
|
produced = max(1, int(len(text) / 3.6))
|
||||||
|
remaining -= max(1, int(produced))
|
||||||
|
|
||||||
|
emit("turn_end",
|
||||||
|
f"Tura {turns}: odebrano {len(text.strip())} znaków w {took:.1f}s"
|
||||||
|
+ (" — odpowiedź urwana, poproszę o dokończenie" if truncated else ""),
|
||||||
|
turn=turns, chars=len(text.strip()), truncated=truncated)
|
||||||
|
|
||||||
|
chunk = text.strip()
|
||||||
|
if chunk:
|
||||||
|
if chunk.endswith(_DONE_MARKER): # model zgłasza koniec
|
||||||
|
parts.append(("\n" if parts else "") + chunk[: -len(_DONE_MARKER)].rstrip())
|
||||||
|
break
|
||||||
|
parts.append(("\n" if parts else "") + chunk)
|
||||||
|
if not truncated:
|
||||||
|
break
|
||||||
|
elif not truncated:
|
||||||
|
# pusta i NIE ucięta: jedna próba z podpowiedzią, potem koniec
|
||||||
|
if nudged or parts:
|
||||||
|
break
|
||||||
|
nudged = True
|
||||||
|
messages = messages + [
|
||||||
|
{"role": "assistant", "content": "…"},
|
||||||
|
{"role": "user", "content": _NUDGE},
|
||||||
|
]
|
||||||
|
continue
|
||||||
|
# ucięta (także tura złożona z samego myślenia) — poproś o dokończenie
|
||||||
|
|
||||||
|
if remaining < 256:
|
||||||
|
break
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
{"role": "assistant", "content": _join(parts) or "…"},
|
||||||
|
{"role": "user", "content": _CONTINUE},
|
||||||
|
]
|
||||||
|
|
||||||
|
final = _join(parts)
|
||||||
|
if not final:
|
||||||
|
raise LLMError(_explain_empty(turns, usage, stop))
|
||||||
|
emit("generated", f"Gotowe: {len(final)} znaków w {turns} turach.",
|
||||||
|
chars=len(final), turns=turns)
|
||||||
|
|
||||||
|
usage["turns"] = turns
|
||||||
|
return Completion(text=final, model=model_name, provider=self.name,
|
||||||
|
leaves_lan=self.leaves_lan, usage=usage)
|
||||||
|
|
||||||
|
def count_tokens(self, prompt: str) -> int:
|
||||||
|
"""Szacunek tokenów promptu. Dostawcy z własnym licznikiem nadpisują."""
|
||||||
|
return int(len(prompt) / 3.6)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionsProvider(_Driver, LLMProvider):
|
||||||
|
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, base_url: str, model: str, api_key: str = "",
|
||||||
|
timeout: float = 120.0, leaves_lan: bool = True) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.leaves_lan = leaves_lan
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
h = {"Content-Type": "application/json"}
|
||||||
|
if self.api_key:
|
||||||
|
h["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
return h
|
||||||
|
|
||||||
|
def _turn(self, messages: list[dict], max_tokens: int):
|
||||||
|
data = _post_with_retry(
|
||||||
|
f"{self.base_url}/chat/completions", self._headers(),
|
||||||
|
{"model": self.model, "max_tokens": max_tokens, "messages": messages},
|
||||||
|
self.timeout,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
choice = data["choices"][0]
|
||||||
|
text = choice["message"].get("content") or ""
|
||||||
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
|
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||||
|
stop = choice.get("finish_reason")
|
||||||
|
return (text, stop == "length", data.get("usage") or {},
|
||||||
|
data.get("model", self.model), stop)
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=min(self.timeout, 10.0)) as client:
|
||||||
|
r = client.get(f"{self.base_url}/models", headers=self._headers())
|
||||||
|
info["status"] = "ok" if r.status_code < 400 else f"http {r.status_code}"
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
info["status"] = f"down: {e}"
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicProvider(_Driver, LLMProvider):
|
||||||
|
"""Protokół Anthropic `/v1/messages`."""
|
||||||
|
|
||||||
|
leaves_lan = True
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, model: str, api_key: str = "",
|
||||||
|
timeout: float = 120.0) -> None:
|
||||||
|
self.name = "anthropic"
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": self.api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _thinking(self) -> dict:
|
||||||
|
"""Konfiguracja myślenia. Domyślnie adaptacyjne — podnosi jakość tekstu.
|
||||||
|
|
||||||
|
UWAGA: tokeny myślenia liczą się do `max_tokens`, więc przy ciasnym limicie
|
||||||
|
cała tura potrafi wyjść jako samo myślenie z pustym tekstem. Pętla
|
||||||
|
kontynuacji to obsługuje, ale ANTHROPIC_THINKING=off wyłącza myślenie,
|
||||||
|
gdy zależy nam na przewidywalnym zużyciu tokenów.
|
||||||
|
"""
|
||||||
|
mode = os.getenv("ANTHROPIC_THINKING", "adaptive").lower()
|
||||||
|
if mode in ("off", "disabled", "0", "false"):
|
||||||
|
return {"thinking": {"type": "disabled"}}
|
||||||
|
return {
|
||||||
|
"thinking": {"type": "adaptive"},
|
||||||
|
"output_config": {"effort": os.getenv("ANTHROPIC_EFFORT", "high")},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _turn(self, messages: list[dict], max_tokens: int):
|
||||||
|
if not self.api_key:
|
||||||
|
raise LLMError("Brak ANTHROPIC_API_KEY — dostawca anthropic wymaga klucza.")
|
||||||
|
payload = {"model": self.model, "max_tokens": max_tokens, "messages": messages}
|
||||||
|
payload.update(self._thinking())
|
||||||
|
data = _post_with_retry(f"{self.base_url}/v1/messages", self._headers(),
|
||||||
|
payload, self.timeout)
|
||||||
|
try:
|
||||||
|
blocks = data["content"]
|
||||||
|
text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||||
|
|
||||||
|
stop = data.get("stop_reason")
|
||||||
|
# tura złożona z samego myślenia = budżet poszedł na rozumowanie; traktujemy
|
||||||
|
# jak ucięcie, żeby pętla poprosiła o treść zamiast zwrócić pustkę
|
||||||
|
thinking_only = not text.strip() and any(
|
||||||
|
b.get("type") in ("thinking", "redacted_thinking") for b in blocks
|
||||||
|
)
|
||||||
|
return (text, stop == "max_tokens" or thinking_only, data.get("usage") or {},
|
||||||
|
data.get("model", self.model), stop)
|
||||||
|
|
||||||
|
def count_tokens(self, prompt: str) -> int:
|
||||||
|
"""Dokładny licznik Anthropic — nie szacunek. Od tego zależy, czy po
|
||||||
|
zmieszczeniu promptu zostanie miejsce na odpowiedź."""
|
||||||
|
if not self.api_key:
|
||||||
|
return super().count_tokens(prompt)
|
||||||
|
try:
|
||||||
|
data = _post_with_retry(
|
||||||
|
f"{self.base_url}/v1/messages/count_tokens", self._headers(),
|
||||||
|
{"model": self.model, "messages": [{"role": "user", "content": prompt}]},
|
||||||
|
min(self.timeout, 30.0),
|
||||||
|
)
|
||||||
|
return int(data.get("input_tokens") or super().count_tokens(prompt))
|
||||||
|
except LLMError:
|
||||||
|
return super().count_tokens(prompt)
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
return {
|
||||||
|
"provider": self.name, "model": self.model, "leaves_lan": True,
|
||||||
|
"status": "ok (klucz ustawiony)" if self.api_key else "brak ANTHROPIC_API_KEY",
|
||||||
|
}
|
||||||
@@ -6,15 +6,46 @@ Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app import link_crypto, security
|
||||||
from app.clients.data_client import DataClient
|
from app.clients.data_client import DataClient
|
||||||
from app.models import QueryRequest, QueryResponse
|
from app.models import QueryRequest, QueryResponse
|
||||||
from app.service import QueryService
|
from app.service import QueryService
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||||
service = QueryService()
|
service = QueryService()
|
||||||
|
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
||||||
|
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
|
||||||
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
|
link_crypto.install(app, link_crypto.ENV_PRESENTATION_LOGIC, "logiczna")
|
||||||
|
|
||||||
|
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
|
||||||
|
_engine = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
from app.engine.factory import build_engine
|
||||||
|
|
||||||
|
_engine = build_engine()
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
class PositionsRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment w UTC (świadomy strefy)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
objects: list[str] | None = None
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/query", response_model=QueryResponse)
|
@app.post("/api/query", response_model=QueryResponse)
|
||||||
@@ -25,6 +56,430 @@ def query(req: QueryRequest) -> QueryResponse:
|
|||||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/positions")
|
||||||
|
def chart_positions(req: PositionsRequest) -> dict:
|
||||||
|
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty (LOG-06);
|
||||||
|
opcjonalnie stacje planet (LOG-03, stations=true)."""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
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)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
|
if req.stations:
|
||||||
|
from app.engine.stations import find_stations
|
||||||
|
|
||||||
|
for p in chart["positions"]:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/compare")
|
||||||
|
def chart_compare(req: PositionsRequest) -> dict:
|
||||||
|
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
|
||||||
|
|
||||||
|
Wymaga skonfigurowanego ENGINE_SWISSEPH_URL (silnik B). W przeciwnym razie
|
||||||
|
zwraca informację, że porównanie jest niedostępne.
|
||||||
|
"""
|
||||||
|
from app.engine.compare import compare_engines
|
||||||
|
from app.engine.factory import build_engine
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
try:
|
||||||
|
report = compare_engines(build_engine("own"), build_engine("swisseph"), moment)
|
||||||
|
except (RuntimeError, httpx.HTTPError) as e:
|
||||||
|
raise HTTPException(status_code=503, detail=f"Silnik B niedostępny: {e}")
|
||||||
|
return report.summary()
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRequest(BaseModel):
|
||||||
|
when_utc: datetime
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
limit: int = 5000
|
||||||
|
group: bool = False # grupowanie identycznych opisów
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/report")
|
||||||
|
def chart_report(req: ReportRequest) -> dict:
|
||||||
|
"""Wynik obliczeń szukany w bazie: z pozycji + domów + aspektów generuje
|
||||||
|
sygnifikatory (fasety znak/dom/aspekt) i pyta warstwę danych o interpretacje."""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.significators import build_report
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
chart = build_chart(engine, moment) # pozycje z domami + aspekty
|
||||||
|
try:
|
||||||
|
report = build_report(
|
||||||
|
chart["positions"], DataClient(),
|
||||||
|
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}"}
|
||||||
|
return {"engine": engine.name, **report}
|
||||||
|
|
||||||
|
|
||||||
|
class PromptRequest(BaseModel):
|
||||||
|
"""Wejście generatora promptu (LOG-29/30)."""
|
||||||
|
profile: str = "natal" # natal | period
|
||||||
|
when_utc: datetime
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
budget: str = "medium" # concise | medium | extensive | huge | max
|
||||||
|
limit: int = 5000
|
||||||
|
provider: str | None = None # do wyliczenia budżetu „max" wg okna modelu
|
||||||
|
model: str | None = None
|
||||||
|
# tylko dla profilu period:
|
||||||
|
from_date: str | None = None
|
||||||
|
to_date: str | None = None
|
||||||
|
techniques: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/prompt")
|
||||||
|
def chart_prompt(req: PromptRequest) -> dict:
|
||||||
|
"""Gotowy prompt do LLM z naszych wyliczeń (LOG-29) z budżetowaniem (LOG-30).
|
||||||
|
|
||||||
|
profile=natal → horoskop urodzeniowy (ekran Interpretacje)
|
||||||
|
profile=period → horoskop na wybrany okres (ekran Kalendarz)
|
||||||
|
|
||||||
|
Nie woła żadnego modelu — zwraca sam prompt i statystyki redukcji, żeby dało się
|
||||||
|
go obejrzeć i skopiować. Wysyłkę do modelu doda LOG-31.
|
||||||
|
"""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.prompt import CHARS_PER_TOKEN, MAX_BUDGET, build_natal_prompt, build_period_prompt
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
chart = build_chart(engine, moment)
|
||||||
|
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
data_error = None
|
||||||
|
|
||||||
|
# Budżet „maksymalny kontekst modelu": limit znaków liczymy z okna kontekstu
|
||||||
|
# WYBRANEGO modelu, zawsze po odjęciu miejsca zarezerwowanego na odpowiedź.
|
||||||
|
budget_chars = None
|
||||||
|
if req.budget == MAX_BUDGET:
|
||||||
|
from app.llm.factory import resolve_model
|
||||||
|
from app.llm.limits import prompt_token_budget
|
||||||
|
# celowo bez build_provider(): budżet zależy TYLKO od okna kontekstu modelu,
|
||||||
|
# więc nie może wymagać skonfigurowanego klucza API
|
||||||
|
provider_name, model_name = resolve_model(req.provider, req.model)
|
||||||
|
budget_chars = int(prompt_token_budget(provider_name, model_name) * CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
# Warstwa danych dokłada wyłącznie WSKAZANIA. Wyliczenia (horoskop, oś czasu) są
|
||||||
|
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
|
||||||
|
try:
|
||||||
|
if req.profile == "natal":
|
||||||
|
from app.significators import build_report
|
||||||
|
|
||||||
|
report: dict = {"objects": []}
|
||||||
|
try:
|
||||||
|
report = build_report(
|
||||||
|
chart["positions"], DataClient(),
|
||||||
|
aspects=chart.get("aspects"), per_object_limit=req.limit,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
data_error = f"Warstwa danych niedostępna: {e}"
|
||||||
|
out = build_natal_prompt(chart, report, req.budget, label, budget_chars)
|
||||||
|
|
||||||
|
elif req.profile == "period":
|
||||||
|
if not (req.from_date and req.to_date):
|
||||||
|
raise HTTPException(422, "profile=period wymaga from_date i to_date")
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.timeline import build_timeline
|
||||||
|
from app.significators import interpret_events
|
||||||
|
|
||||||
|
ramc, eps = engine.sidereal(moment)
|
||||||
|
points = {"Asc": H.compute_asc(ramc, eps, moment.lat), "MC": H.compute_mc(ramc, eps)}
|
||||||
|
for p in engine.positions(moment):
|
||||||
|
points[p.name] = p.longitude
|
||||||
|
events = build_timeline(engine, moment, points, req.from_date, req.to_date,
|
||||||
|
req.techniques)
|
||||||
|
try:
|
||||||
|
interpret_events(events, DataClient())
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
|
||||||
|
out = build_period_prompt(chart, events, req.from_date, req.to_date,
|
||||||
|
req.budget, label, budget_chars)
|
||||||
|
else:
|
||||||
|
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
|
||||||
|
except ValueError as e: # nieznany budżet
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
|
||||||
|
out["engine"] = engine.name
|
||||||
|
if data_error:
|
||||||
|
out["data_error"] = data_error
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class HoroscopeRequest(PromptRequest):
|
||||||
|
"""Jak PromptRequest (niesie już provider i model) + limit wyjścia (LOG-31)."""
|
||||||
|
max_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/horoscope")
|
||||||
|
def chart_horoscope(req: HoroscopeRequest) -> dict:
|
||||||
|
"""Napisany horoskop (LOG-31): prompt z LOG-29/30 → model → gotowy tekst.
|
||||||
|
|
||||||
|
Zwraca TAKŻE użyty prompt — również gdy wywołanie modelu padnie — żeby dało się
|
||||||
|
go obejrzeć i użyć ręcznie. `leaves_lan` mówi, czy treść baz opuściła naszą sieć
|
||||||
|
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
|
||||||
|
"""
|
||||||
|
from app.llm.base import LLMError
|
||||||
|
from app.llm.factory import build_provider
|
||||||
|
from app.llm.limits import plan
|
||||||
|
|
||||||
|
out = chart_prompt(req) # ten sam prompt co w podglądzie
|
||||||
|
try:
|
||||||
|
provider = build_provider(req.provider, req.model)
|
||||||
|
|
||||||
|
# Ile tokenów ma naprawdę ten prompt i ile zostaje na odpowiedź. Anthropic
|
||||||
|
# liczy dokładnie (własny endpoint), reszta szacuje — od tego zależy, czy
|
||||||
|
# w oknie kontekstu w ogóle zmieści się miejsce na horoskop.
|
||||||
|
prompt_tokens = provider.count_tokens(out["prompt"])
|
||||||
|
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
||||||
|
out["token_plan"] = budget
|
||||||
|
if budget["warnings"]:
|
||||||
|
out["warnings"] = budget["warnings"]
|
||||||
|
if not budget["fits"]:
|
||||||
|
out["llm_error"] = " ".join(budget["warnings"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
result = provider.generate(out["prompt"], budget["max_output"])
|
||||||
|
except LLMError as e:
|
||||||
|
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
|
||||||
|
out["llm_error"] = str(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
out.update(
|
||||||
|
horoscope=result.text,
|
||||||
|
provider=result.provider,
|
||||||
|
model=result.model,
|
||||||
|
leaves_lan=result.leaves_lan,
|
||||||
|
usage=result.usage,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/horoscope/stream")
|
||||||
|
def chart_horoscope_stream(req: HoroscopeRequest):
|
||||||
|
"""To samo co /chart/horoscope, ale strumieniuje POSTĘP w trakcie pracy.
|
||||||
|
|
||||||
|
Pisanie horoskopu trwa minutami — bez sygnału aplikacja wygląda na zawieszoną.
|
||||||
|
Strumień (NDJSON, jedna linia = jedno zdarzenie) niesie RZECZYWISTE etapy:
|
||||||
|
budowę promptu, limity modelu i każdą turę generowania. Ostatnie zdarzenie
|
||||||
|
(`result`) ma identyczny kształt co odpowiedź zwykłego endpointu.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.progress import stream
|
||||||
|
|
||||||
|
def work(emit) -> dict:
|
||||||
|
from app.llm.base import LLMError
|
||||||
|
from app.llm.factory import build_provider
|
||||||
|
from app.llm.limits import plan
|
||||||
|
|
||||||
|
emit({"type": "stage", "message": "Liczę horoskop i szukam wskazań w bazach…"})
|
||||||
|
out = chart_prompt(req)
|
||||||
|
st = out.get("stats", {})
|
||||||
|
emit({"type": "stage", "message":
|
||||||
|
f"Prompt gotowy: {st.get('chars', 0)} znaków, "
|
||||||
|
f"wskazań {st.get('included', 0)}"
|
||||||
|
+ (f", pominięto {st['omitted']}" if st.get("omitted") else "")})
|
||||||
|
if out.get("data_error"):
|
||||||
|
emit({"type": "warn", "message": out["data_error"]})
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = build_provider(req.provider, req.model)
|
||||||
|
emit({"type": "stage", "message":
|
||||||
|
f"Dostawca: {provider.name}, model: {provider.model}"
|
||||||
|
+ ("" if not provider.leaves_lan else " — dane opuszczają sieć")})
|
||||||
|
|
||||||
|
emit({"type": "stage", "message": "Liczę tokeny promptu…"})
|
||||||
|
prompt_tokens = provider.count_tokens(out["prompt"])
|
||||||
|
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
||||||
|
out["token_plan"] = budget
|
||||||
|
emit({"type": "stage", "message":
|
||||||
|
f"Prompt {prompt_tokens} tok. · okno modelu {budget['context_window']} · "
|
||||||
|
f"na odpowiedź {budget['max_output']}"})
|
||||||
|
for warning in budget["warnings"]:
|
||||||
|
emit({"type": "warn", "message": warning})
|
||||||
|
if budget["warnings"]:
|
||||||
|
out["warnings"] = budget["warnings"]
|
||||||
|
if not budget["fits"]:
|
||||||
|
out["llm_error"] = " ".join(budget["warnings"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
result = provider.generate(out["prompt"], budget["max_output"], on_event=emit)
|
||||||
|
except LLMError as e:
|
||||||
|
emit({"type": "warn", "message": f"Model zawiódł: {e}"})
|
||||||
|
out["llm_error"] = str(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
out.update(horoscope=result.text, provider=result.provider, model=result.model,
|
||||||
|
leaves_lan=result.leaves_lan, usage=result.usage)
|
||||||
|
return out
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream(work),
|
||||||
|
media_type="application/x-ndjson",
|
||||||
|
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/llm/models")
|
||||||
|
def llm_models() -> dict:
|
||||||
|
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
||||||
|
|
||||||
|
To nie jest lista zamknięta: pole modelu jest tekstowe, więc można wpisać
|
||||||
|
dowolny identyfikator, do którego konto ma dostęp.
|
||||||
|
"""
|
||||||
|
from app.llm.catalog import catalog
|
||||||
|
from app.llm.factory import _DEFAULT_MODEL
|
||||||
|
|
||||||
|
return {"providers": catalog(), "defaults": dict(_DEFAULT_MODEL)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/llm/health")
|
||||||
|
def llm_health(provider: str | None = None) -> dict:
|
||||||
|
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
|
||||||
|
from app.llm.base import LLMError
|
||||||
|
from app.llm.factory import build_provider
|
||||||
|
|
||||||
|
try:
|
||||||
|
return build_provider(provider).health()
|
||||||
|
except LLMError as e:
|
||||||
|
return {"status": f"blad konfiguracji: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
class ProfectionsRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
start_age: int = 0
|
||||||
|
count: int = 13 # domyślnie pełny cykl 12 lat + rok startowy
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/profections")
|
||||||
|
def chart_profections(req: ProfectionsRequest) -> dict:
|
||||||
|
"""Profekcje roczne (LOG-10): wiek, profektowany Asc, Władca Roku (+MC/Su/Mo)."""
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.profections import profection_rows
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
ramc, eps = engine.sidereal(natal)
|
||||||
|
points = {
|
||||||
|
"Asc": H.compute_asc(ramc, eps, natal.lat),
|
||||||
|
"MC": H.compute_mc(ramc, eps),
|
||||||
|
}
|
||||||
|
for p in engine.positions(natal, ["Sun", "Moon"]):
|
||||||
|
points[p.name] = p.longitude
|
||||||
|
rows = profection_rows(points, req.when_utc, req.start_age, req.count)
|
||||||
|
return {"engine": engine.name, "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
kind: str = "solar" # solar | lunar
|
||||||
|
around: datetime | None = None # data, wokół której szukać powrotu
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/return")
|
||||||
|
def chart_return(req: ReturnRequest) -> dict:
|
||||||
|
"""Solar/Lunar Return (LOG-12): moment powrotu + pełny horoskop na ten moment."""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.returns import find_return
|
||||||
|
|
||||||
|
if req.kind not in ("solar", "lunar"):
|
||||||
|
raise HTTPException(status_code=422, detail="kind: solar albo lunar")
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
around = req.around or req.when_utc
|
||||||
|
hit = find_return(engine, req.kind, natal, around)
|
||||||
|
if hit is None:
|
||||||
|
raise HTTPException(status_code=404, detail="nie znaleziono powrotu w oknie skanu")
|
||||||
|
chart = build_chart(engine, ChartMoment(when_utc=hit, lat=req.lat, lon=req.lon))
|
||||||
|
return {"engine": engine.name, "kind": req.kind,
|
||||||
|
"return_utc": hit.isoformat(), **chart}
|
||||||
|
|
||||||
|
|
||||||
|
class FirdariaRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/firdaria")
|
||||||
|
def chart_firdaria(req: FirdariaRequest) -> dict:
|
||||||
|
"""Firdaria (LOG-11): sekta + okresy główne i podokresy time-lordów."""
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.firdaria import firdaria
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
ramc, eps = engine.sidereal(natal)
|
||||||
|
asc, mc = H.compute_asc(ramc, eps, natal.lat), H.compute_mc(ramc, eps)
|
||||||
|
sun = engine.positions(natal, ["Sun"])[0].longitude
|
||||||
|
return {"engine": engine.name, **firdaria(req.when_utc, sun, asc, mc)}
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
from_date: str # zakres: YYYY-MM-DD
|
||||||
|
to_date: str
|
||||||
|
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
||||||
|
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/timeline")
|
||||||
|
def chart_timeline(req: TimelineRequest) -> dict:
|
||||||
|
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.
|
||||||
|
|
||||||
|
Z interpret=true dopina do zdarzeń interpretacje z warstwy danych (LOG-19, 1B->2B).
|
||||||
|
"""
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.timeline import build_timeline
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
ramc, eps = engine.sidereal(natal)
|
||||||
|
points = {"Asc": H.compute_asc(ramc, eps, natal.lat), "MC": H.compute_mc(ramc, eps)}
|
||||||
|
for p in engine.positions(natal):
|
||||||
|
points[p.name] = p.longitude
|
||||||
|
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
|
||||||
|
|
||||||
|
out = {"engine": engine.name, "from": req.from_date, "to": req.to_date}
|
||||||
|
if req.interpret:
|
||||||
|
from app.significators import interpret_events
|
||||||
|
try:
|
||||||
|
interpret_events(events, DataClient())
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
out["data_error"] = f"Warstwa danych niedostępna: {e}"
|
||||||
|
out.update(count=len(events), events=events)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
info = {"status": "ok", "layer": "logic"}
|
info = {"status": "ok", "layer": "logic"}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Strumień postępu długiej operacji (NDJSON).
|
||||||
|
|
||||||
|
Po co: pisanie horoskopu trwa — czasem minuty. Bez sygnału aplikacja wygląda na
|
||||||
|
zawieszoną. Zamiast udawanego paska postępu strumieniujemy **rzeczywiste**
|
||||||
|
zdarzenia z kolejnych etapów, żeby log pokazywał to, co faktycznie się dzieje.
|
||||||
|
|
||||||
|
Dlaczego NDJSON, a nie SSE: `EventSource` w przeglądarce obsługuje wyłącznie GET,
|
||||||
|
a to jest POST z ciałem. Strumień „jedna linia = jeden obiekt JSON" czyta się
|
||||||
|
zwykłym `fetch()` i jest trywialny do sparsowania.
|
||||||
|
|
||||||
|
Dlaczego wątek: właściwa praca (silnik, baza, model) jest synchroniczna. Puszczamy
|
||||||
|
ją w wątku roboczym, a generator odpompowuje kolejkę zdarzeń — dzięki temu
|
||||||
|
zdarzenia docierają w trakcie pracy, a nie dopiero na końcu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
_HEARTBEAT_SECONDS = 10.0
|
||||||
|
_DONE = object()
|
||||||
|
|
||||||
|
|
||||||
|
def line(kind: str, message: str, **extra: Any) -> str:
|
||||||
|
return json.dumps({"type": kind, "message": message, **extra}, ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def stream(work: Callable[[Callable[[dict], None]], dict]) -> Iterator[str]:
|
||||||
|
"""Uruchamia `work(emit)` w wątku i strumieniuje zdarzenia w czasie rzeczywistym.
|
||||||
|
|
||||||
|
`work` dostaje funkcję `emit(zdarzenie)` i zwraca końcowy wynik, który leci
|
||||||
|
jako ostatnie zdarzenie typu `result`. Wyjątek zamienia się w zdarzenie `error`
|
||||||
|
— połączenie nigdy nie urywa się bez wyjaśnienia.
|
||||||
|
"""
|
||||||
|
events: queue.Queue = queue.Queue()
|
||||||
|
|
||||||
|
def emit(event: dict) -> None:
|
||||||
|
events.put(event)
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
try:
|
||||||
|
result = work(emit)
|
||||||
|
events.put({"type": "result", "message": "Gotowe.", "result": result})
|
||||||
|
except Exception as e: # noqa: BLE001 — zgłaszamy KAŻDY błąd
|
||||||
|
events.put({
|
||||||
|
"type": "error",
|
||||||
|
"message": f"{type(e).__name__}: {e}",
|
||||||
|
"detail": traceback.format_exc(limit=3),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
events.put(_DONE)
|
||||||
|
|
||||||
|
worker = threading.Thread(target=run, daemon=True)
|
||||||
|
worker.start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = events.get(timeout=_HEARTBEAT_SECONDS)
|
||||||
|
except queue.Empty:
|
||||||
|
# cisza dłuższa niż heartbeat: dajemy znak życia, żeby pośredniki
|
||||||
|
# (proxy, load balancer) nie uznały połączenia za martwe
|
||||||
|
yield line("ping", "…")
|
||||||
|
continue
|
||||||
|
if event is _DONE:
|
||||||
|
break
|
||||||
|
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Generator promptów do LLM (LOG-29) + budżetowanie rozmiaru (LOG-30).
|
||||||
|
|
||||||
|
Składa z naszych wyliczeń gotowe, profesjonalne polecenie po polsku:
|
||||||
|
- profil **natal** — horoskop urodzeniowy (na bazie /chart/report),
|
||||||
|
- profil **period** — horoskop na wybrany okres (na bazie /chart/timeline).
|
||||||
|
|
||||||
|
Zasada naczelna promptu: model ma pisać WYŁĄCZNIE na podstawie dostarczonych danych
|
||||||
|
i przy każdej tezie wskazać konkretny sygnifikator, z którego ona wynika. To odróżnia
|
||||||
|
wynik od ogólnikowej wróżby i pozwala go zweryfikować.
|
||||||
|
|
||||||
|
Budżetowanie (LOG-30) — kolejność redukcji:
|
||||||
|
1. deduplikacja (ten sam sygnifikator ORAZ ten sam opis),
|
||||||
|
2. grupowanie identycznych opisów z licznikiem wystąpień,
|
||||||
|
3. sortowanie malejąco wg punktacji siły (LOG-21),
|
||||||
|
4. obcięcie ogona do budżetu — jednostką obcięcia jest CAŁE wskazanie,
|
||||||
|
5. skracanie nadmiernie długich opisów (z jawnym oznaczeniem).
|
||||||
|
Zawsze raportujemy, ile wskazań weszło i ile pominięto — użytkownik ma wiedzieć,
|
||||||
|
czego brakuje, i móc zwiększyć budżet.
|
||||||
|
|
||||||
|
Generowanie jest deterministyczne: ten sam horoskop + ten sam budżet = ten sam prompt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# budżety w znakach (całego promptu); dobrane pod wklejanie do ChatGPT/Claude
|
||||||
|
BUDGETS: dict[str, int] = {
|
||||||
|
"concise": 4_000,
|
||||||
|
"medium": 12_000,
|
||||||
|
"extensive": 30_000,
|
||||||
|
"huge": 120_000,
|
||||||
|
# „maksymalny kontekst modelu" — wyliczany dynamicznie z okna kontekstu
|
||||||
|
# wybranego modelu, ZAWSZE po odjęciu miejsca zarezerwowanego na odpowiedź.
|
||||||
|
# Wartość poniżej jest tylko zapasem, gdy limity modelu są nieznane.
|
||||||
|
"max": 400_000,
|
||||||
|
}
|
||||||
|
DEFAULT_BUDGET = "medium"
|
||||||
|
MAX_BUDGET = "max"
|
||||||
|
|
||||||
|
MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji)
|
||||||
|
CHARS_PER_TOKEN = 4.0 # zgrubny szacunek tokenów do podglądu w UI
|
||||||
|
|
||||||
|
DISCLAIMER = (
|
||||||
|
"Zakończ krótką notą: treść jest interpretacją astrologiczną i nie stanowi porady "
|
||||||
|
"medycznej, prawnej ani finansowej."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(text: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", (text or "").strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def est_tokens(text: str) -> int:
|
||||||
|
return int(len(text) / CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def _shorten(text: str, limit: int = MAX_EFFECT_CHARS) -> tuple[str, bool]:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text, False
|
||||||
|
return text[:limit].rstrip() + " […skrócono]", True
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- wskazania
|
||||||
|
|
||||||
|
def natal_indications(report: dict) -> list[dict]:
|
||||||
|
"""Płaska lista wskazań z raportu natalnego: obiekt + faseta + opis + waga."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for obj in report.get("objects") or []:
|
||||||
|
name = obj.get("object")
|
||||||
|
for facet in obj.get("facets") or []:
|
||||||
|
score = float(facet.get("score") or 0.0)
|
||||||
|
for s in facet.get("samples") or []:
|
||||||
|
out.append({
|
||||||
|
"context": f"{name} — {facet.get('label')}",
|
||||||
|
"sort_key": (name or "", str(facet.get("label") or "")),
|
||||||
|
"score": score,
|
||||||
|
"significator": s.get("expanded") or s.get("significator") or "",
|
||||||
|
"effect": s.get("effect") or "",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def period_indications(events: list[dict]) -> list[dict]:
|
||||||
|
"""Wskazania z osi czasu — waga rośnie z liczbą trafień w bazie."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for ev in events or []:
|
||||||
|
head = f"{ev.get('technique')} · {ev.get('significator')} · {ev.get('exact')}"
|
||||||
|
total = float(ev.get("interpretations_count") or 0)
|
||||||
|
for s in ev.get("interpretations") or []:
|
||||||
|
out.append({
|
||||||
|
"context": head,
|
||||||
|
"sort_key": (str(ev.get("exact") or ""), str(ev.get("technique") or "")),
|
||||||
|
"score": total,
|
||||||
|
"significator": s.get("expanded") or s.get("significator") or "",
|
||||||
|
"effect": s.get("effect") or "",
|
||||||
|
"date": ev.get("exact"),
|
||||||
|
"start": ev.get("start"),
|
||||||
|
"end": ev.get("end"),
|
||||||
|
"technique": ev.get("technique"),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def reduce_indications(items: list[dict], budget_chars: int) -> tuple[list[dict], dict]:
|
||||||
|
"""Dedup → grupowanie → sortowanie wg wagi → obcięcie do budżetu.
|
||||||
|
|
||||||
|
Zwraca (wybrane wskazania, statystyki). Jednostką obcięcia jest całe wskazanie.
|
||||||
|
"""
|
||||||
|
# 1+2: dedup i grupowanie identycznych (kontekst, opis) z licznikiem
|
||||||
|
grouped: dict[tuple[str, str], dict] = {}
|
||||||
|
order: list[tuple[str, str]] = []
|
||||||
|
for it in items:
|
||||||
|
key = (_norm(it["context"]), _norm(it["effect"]))
|
||||||
|
g = grouped.get(key)
|
||||||
|
if g is None:
|
||||||
|
g = {**it, "count": 0, "significators": []}
|
||||||
|
grouped[key] = g
|
||||||
|
order.append(key)
|
||||||
|
g["count"] += 1
|
||||||
|
if it["significator"] not in g["significators"]:
|
||||||
|
g["significators"].append(it["significator"])
|
||||||
|
merged = [grouped[k] for k in order]
|
||||||
|
deduped = len(items) - len(merged)
|
||||||
|
|
||||||
|
# 3: sortowanie malejąco wg wagi; remis rozstrzygany deterministycznie
|
||||||
|
merged.sort(key=lambda g: (-g["score"], -g["count"], g["sort_key"], g["significator"]))
|
||||||
|
|
||||||
|
# 5 (przed obcięciem, bo wpływa na rozmiar): skracanie długich opisów
|
||||||
|
shortened = 0
|
||||||
|
for g in merged:
|
||||||
|
g["effect"], was = _shorten(g["effect"])
|
||||||
|
shortened += 1 if was else 0
|
||||||
|
|
||||||
|
# 4: obcięcie ogona do budżetu — cała pozycja albo nic
|
||||||
|
chosen: list[dict] = []
|
||||||
|
used = 0
|
||||||
|
for g in merged:
|
||||||
|
cost = len(_render_indication(g)) + 1
|
||||||
|
if used + cost > budget_chars and chosen:
|
||||||
|
break
|
||||||
|
chosen.append(g)
|
||||||
|
used += cost
|
||||||
|
|
||||||
|
omitted = len(merged) - len(chosen)
|
||||||
|
stats = {
|
||||||
|
"source_rows": len(items),
|
||||||
|
"after_grouping": len(merged),
|
||||||
|
"deduplicated": deduped,
|
||||||
|
"included": len(chosen),
|
||||||
|
"omitted": omitted,
|
||||||
|
"shortened": shortened,
|
||||||
|
"min_score_included": round(chosen[-1]["score"], 3) if chosen else None,
|
||||||
|
"max_score_omitted": round(merged[len(chosen)]["score"], 3) if omitted else None,
|
||||||
|
}
|
||||||
|
return chosen, stats
|
||||||
|
|
||||||
|
|
||||||
|
def _render_indication(g: dict) -> str:
|
||||||
|
times = f" (×{g['count']})" if g.get("count", 1) > 1 else ""
|
||||||
|
sig = g.get("significator") or ""
|
||||||
|
return f"- {sig} → {g.get('effect')}{times}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_grouped(chosen: list[dict]) -> str:
|
||||||
|
"""Renderuje wskazania pogrupowane po kontekście, zachowując kolejność wagi."""
|
||||||
|
blocks: list[str] = []
|
||||||
|
seen: dict[str, list[dict]] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for g in chosen:
|
||||||
|
ctx = g["context"]
|
||||||
|
if ctx not in seen:
|
||||||
|
seen[ctx] = []
|
||||||
|
order.append(ctx)
|
||||||
|
seen[ctx].append(g)
|
||||||
|
for ctx in order:
|
||||||
|
rows = seen[ctx]
|
||||||
|
blocks.append(f"## {ctx} [waga {rows[0]['score']:.2f}]")
|
||||||
|
blocks.extend(_render_indication(g) for g in rows)
|
||||||
|
blocks.append("")
|
||||||
|
return "\n".join(blocks).rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- sekcje danych
|
||||||
|
|
||||||
|
def _chart_section(chart: dict, moment_label: str | None) -> str:
|
||||||
|
lines: list[str] = ["# DANE HOROSKOPU"]
|
||||||
|
meta = []
|
||||||
|
if moment_label:
|
||||||
|
meta.append(f"moment: {moment_label}")
|
||||||
|
if chart.get("zodiac"):
|
||||||
|
z = chart["zodiac"]
|
||||||
|
if chart.get("ayanamsha") is not None:
|
||||||
|
z += f" (ayanamsa {chart['ayanamsha']:.4f}°)"
|
||||||
|
meta.append(f"zodiak: {z}")
|
||||||
|
if chart.get("house_system"):
|
||||||
|
meta.append(f"system domów: {chart['house_system']}")
|
||||||
|
if chart.get("sect"):
|
||||||
|
meta.append(f"sekta: {'dzienna' if chart['sect'] == 'day' else 'nocna'}")
|
||||||
|
if meta:
|
||||||
|
lines.append(" · ".join(meta))
|
||||||
|
|
||||||
|
if chart.get("positions"):
|
||||||
|
lines.append("\n## Pozycje")
|
||||||
|
for p in chart["positions"]:
|
||||||
|
house = f"dom {p['house']}" if p.get("house") else "—"
|
||||||
|
lines.append(f"{p['name']:<12} {p.get('in_sign',''):<16} {house:<8} {p.get('direction','')}")
|
||||||
|
|
||||||
|
angles = chart.get("angles") or {}
|
||||||
|
if angles:
|
||||||
|
lines.append("\n## Osie")
|
||||||
|
for key in ("Asc", "MC", "Dsc", "IC"):
|
||||||
|
a = angles.get(key)
|
||||||
|
if a:
|
||||||
|
lines.append(f"{a['name']:<5} {a.get('in_sign','')}")
|
||||||
|
|
||||||
|
if chart.get("lots"):
|
||||||
|
lines.append("\n## Lots (punkty arabskie)")
|
||||||
|
for lot in chart["lots"]:
|
||||||
|
lines.append(
|
||||||
|
f"{lot['name']:<10} {lot.get('in_sign',''):<16} dom {lot.get('house','—')}"
|
||||||
|
f" ({lot.get('formula','')})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if chart.get("aspects"):
|
||||||
|
lines.append("\n## Aspekty (orb; A = aplikacyjny, S = separacyjny)")
|
||||||
|
for a in chart["aspects"]:
|
||||||
|
mark = a.get("as") or ""
|
||||||
|
lines.append(
|
||||||
|
f"{a['obj1']} {a['aspect']} {a['obj2']} orb {a.get('orb', 0):.2f}° {mark}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _indications_section(chosen: list[dict], stats: dict) -> str:
|
||||||
|
if not chosen:
|
||||||
|
return (
|
||||||
|
"# WSKAZANIA Z BAZ\n"
|
||||||
|
"(brak trafień w bazach dla tego horoskopu — oprzyj interpretację wyłącznie "
|
||||||
|
"na danych horoskopu powyżej)"
|
||||||
|
)
|
||||||
|
head = [
|
||||||
|
"# WSKAZANIA Z BAZ INTERPRETACYJNYCH",
|
||||||
|
"Wskazania dopasowane do tego horoskopu, uporządkowane od najsilniejszych.",
|
||||||
|
"Zapis: `- sygnifikator → opis (×ile razy wystąpiło w bazach)`.",
|
||||||
|
]
|
||||||
|
if stats.get("omitted"):
|
||||||
|
head.append(
|
||||||
|
f"UWAGA: pokazano {stats['included']} najsilniejszych wskazań, pominięto "
|
||||||
|
f"{stats['omitted']} słabszych (limit długości)."
|
||||||
|
)
|
||||||
|
return "\n".join(head) + "\n\n" + _render_grouped(chosen)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ prompty
|
||||||
|
|
||||||
|
_NATAL_TASK = """# ZADANIE
|
||||||
|
Jesteś doświadczonym astrologiem. Napisz profesjonalną interpretację HOROSKOPU
|
||||||
|
URODZENIOWEGO po polsku, opierając się wyłącznie na danych podanych niżej."""
|
||||||
|
|
||||||
|
_PERIOD_TASK = """# ZADANIE
|
||||||
|
Jesteś doświadczonym astrologiem. Napisz profesjonalną prognozę astrologiczną
|
||||||
|
NA WYBRANY OKRES po polsku, opierając się wyłącznie na danych podanych niżej."""
|
||||||
|
|
||||||
|
_NATAL_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
|
||||||
|
1. Struktura: (a) portret ogólny, (b) temperament i sekta, (c) obszary życia według domów,
|
||||||
|
(d) napięcia i wyzwania, (e) zasoby i mocne strony, (f) zwięzłe podsumowanie.
|
||||||
|
2. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie, np. „(Moon in 12th house)".
|
||||||
|
Teza bez wskazania jest niedopuszczalna.
|
||||||
|
3. Nie dodawaj twierdzeń, których nie da się wywieść z powyższych danych. Nie zmyślaj
|
||||||
|
pozycji, aspektów ani wskazań; nie korzystaj z wiedzy spoza tego promptu.
|
||||||
|
4. Gdy wskazania są sprzeczne, powiedz to wprost i wskaż obie strony, zamiast wybierać jedną.
|
||||||
|
5. Wagę wskazania traktuj jako siłę świadectwa — mocniejsze mają pierwszeństwo w syntezie.
|
||||||
|
6. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
|
||||||
|
|
||||||
|
_PERIOD_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
|
||||||
|
1. Uporządkuj prognozę CHRONOLOGICZNIE; przy każdym okresie podaj daty (start / dokładna / koniec).
|
||||||
|
2. Dla każdej daty napisz, która technika ją wyznacza (profekcja, solariusz, dyrekcja solar-arc,
|
||||||
|
Firdaria) i co z niej wynika.
|
||||||
|
3. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie. Teza bez wskazania jest
|
||||||
|
niedopuszczalna.
|
||||||
|
4. Nie zmyślaj dat ani zdarzeń spoza podanych. Nie korzystaj z wiedzy spoza tego promptu.
|
||||||
|
5. Rozróżniaj okresy o mocnym świadectwie (wysoka waga, kilka technik zbieżnych w czasie)
|
||||||
|
od słabych — i powiedz wprost, które są które.
|
||||||
|
6. Na końcu dodaj krótkie zestawienie: najważniejsze okresy w kolejności ważności.
|
||||||
|
7. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble(task: str, chart_sec: str, ind_sec: str, output: str) -> str:
|
||||||
|
return "\n\n".join([task, chart_sec, ind_sec, output, f"# ZASTRZEŻENIE\n{DISCLAIMER}"])
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_chars(budget: str, budget_chars: int | None = None) -> int:
|
||||||
|
"""Limit znaków promptu. `budget_chars` nadpisuje tabelę — używane dla opcji
|
||||||
|
„maksymalny kontekst modelu", gdzie limit zależy od wybranego modelu i musi
|
||||||
|
być policzony po odjęciu miejsca zarezerwowanego na odpowiedź."""
|
||||||
|
if budget not in BUDGETS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
|
||||||
|
)
|
||||||
|
if budget_chars and budget_chars > 0:
|
||||||
|
return budget_chars
|
||||||
|
return BUDGETS[budget]
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(prompt: str, budget: str, limit: int, stats: dict, profile: str) -> dict:
|
||||||
|
stats = {
|
||||||
|
**stats,
|
||||||
|
"profile": profile,
|
||||||
|
"budget": budget,
|
||||||
|
"limit_chars": limit,
|
||||||
|
"chars": len(prompt),
|
||||||
|
"est_tokens": est_tokens(prompt),
|
||||||
|
}
|
||||||
|
return {"profile": profile, "prompt": prompt, "stats": stats}
|
||||||
|
|
||||||
|
|
||||||
|
def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
|
||||||
|
moment_label: str | None = None,
|
||||||
|
budget_chars: int | None = None) -> dict:
|
||||||
|
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
|
||||||
|
limit = _budget_chars(budget, budget_chars)
|
||||||
|
chart_sec = _chart_section(chart, moment_label)
|
||||||
|
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
|
||||||
|
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
|
||||||
|
prompt = _assemble(_NATAL_TASK, chart_sec, _indications_section(chosen, stats), _NATAL_OUTPUT)
|
||||||
|
return _finish(prompt, budget, limit, stats, "natal")
|
||||||
|
|
||||||
|
|
||||||
|
def build_period_prompt(chart: dict, events: list[dict], from_date: str, to_date: str,
|
||||||
|
budget: str = DEFAULT_BUDGET, moment_label: str | None = None,
|
||||||
|
budget_chars: int | None = None) -> dict:
|
||||||
|
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
|
||||||
|
limit = _budget_chars(budget, budget_chars)
|
||||||
|
chart_sec = _chart_section(chart, moment_label)
|
||||||
|
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date} — {to_date}**."
|
||||||
|
|
||||||
|
ev_lines = ["# OŚ CZASU (techniki predykcyjne)",
|
||||||
|
"Zapis: technika · sygnifikator · start → dokładna → koniec."]
|
||||||
|
for ev in events or []:
|
||||||
|
ev_lines.append(
|
||||||
|
f"- {ev.get('technique')} · {ev.get('significator')} · "
|
||||||
|
f"{ev.get('start')} → {ev.get('exact')} → {ev.get('end')}"
|
||||||
|
)
|
||||||
|
events_sec = "\n".join(ev_lines)
|
||||||
|
|
||||||
|
fixed = len(task) + len(chart_sec) + len(events_sec) + len(_PERIOD_OUTPUT) + len(DISCLAIMER) + 200
|
||||||
|
chosen, stats = reduce_indications(period_indications(events), max(limit - fixed, 500))
|
||||||
|
body = events_sec + "\n\n" + _indications_section(chosen, stats)
|
||||||
|
prompt = _assemble(task, chart_sec, body, _PERIOD_OUTPUT)
|
||||||
|
stats["events"] = len(events or [])
|
||||||
|
return _finish(prompt, budget, limit, stats, "period")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Punktacja siły trafień — do rankingowania faset (zalążek LOG-21).
|
||||||
|
|
||||||
|
v1 liczy siłę z sygnałów OBLICZALNYCH:
|
||||||
|
- typ fasety (aspekt zwykle mocniejszy od znaku/domu),
|
||||||
|
- rodzaj aspektu (koniunkcja/opozycja mocniejsze od sekstyla),
|
||||||
|
- ciasnota orbu (im bliżej dokładności, tym mocniej).
|
||||||
|
|
||||||
|
Wszystko konfigurowalne. HOOK NA PRZYSZŁOŚĆ: gdy w SIGNIFICATORS KEY zostaną
|
||||||
|
wypełnione kolumny `countas*`/`level*`, można je tu domieszać per sygnifikator.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# bazowe wagi faset (łatwe do strojenia)
|
||||||
|
FACET_BASE = {"sign": 5.0, "house": 5.0, "aspect": 6.0}
|
||||||
|
|
||||||
|
# względna siła aspektów głównych
|
||||||
|
ASPECT_WEIGHT = {
|
||||||
|
"conjunction": 1.0, "opposition": 0.95, "square": 0.85,
|
||||||
|
"trine": 0.85, "sextile": 0.65,
|
||||||
|
}
|
||||||
|
# aspekt aplikacyjny (A) jest silniejszy niż separacyjny (S) — notes3
|
||||||
|
APPLYING_BONUS = 1.15
|
||||||
|
|
||||||
|
|
||||||
|
def score_facet(facet: dict) -> float:
|
||||||
|
"""Siła fasety w skali ~0–12. Deterministyczna, konfigurowalna."""
|
||||||
|
base = FACET_BASE.get(facet.get("type"), 1.0)
|
||||||
|
if facet.get("type") == "aspect":
|
||||||
|
weight = ASPECT_WEIGHT.get(facet.get("aspect"), 0.7)
|
||||||
|
orb = facet.get("orb")
|
||||||
|
allowed = facet.get("allowed") or 10.0
|
||||||
|
tight = max(0.0, 1.0 - orb / allowed) if orb is not None and allowed else 0.0
|
||||||
|
score = base * weight * (1.0 + tight)
|
||||||
|
if facet.get("applying"):
|
||||||
|
score *= APPLYING_BONUS
|
||||||
|
return round(score, 2)
|
||||||
|
return round(base, 2)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Uwierzytelnianie międzywarstwowe (LOG-32).
|
||||||
|
|
||||||
|
Warstwa logiczna oddaje treść baz interpretacyjnych, więc samo zalogowanie w
|
||||||
|
prezentacji nie wystarczy — bez tego kontrolera wystarczyłoby uderzyć w logikę
|
||||||
|
z pominięciem UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
|
||||||
|
musi go przynieść w nagłówku X-Astrololo-Token.
|
||||||
|
|
||||||
|
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
|
||||||
|
starcie leci ostrzeżenie.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.security")
|
||||||
|
|
||||||
|
HEADER = "X-Astrololo-Token"
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
def token() -> str:
|
||||||
|
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
|
||||||
|
return os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool(token())
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, layer: str) -> None:
|
||||||
|
if not enabled():
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
|
||||||
|
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _guard(request: Request, call_next):
|
||||||
|
if request.url.path in PUBLIC_PATHS or not enabled():
|
||||||
|
return await call_next(request)
|
||||||
|
got = request.headers.get(HEADER, "")
|
||||||
|
if not secrets.compare_digest(got, token()):
|
||||||
|
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
|
||||||
|
status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Most: policzony horoskop → tokeny sygnifikatorów → wyszukiwanie w bazie.
|
||||||
|
|
||||||
|
Zalążek LOG-15/16: z pozycji obiektów (wraz z domami) generujemy tokeny w składni
|
||||||
|
bazy i wyszukujemy pasujące rekordy. Dla każdego obiektu tworzymy kilka **faset**:
|
||||||
|
- „w znaku" — planeta + token znaku (`[Su` + `[Tau`)
|
||||||
|
- „w domu" — planeta + token domu (`[Su` + `11th H.`)
|
||||||
|
Aspekty (`[conj`,`[sq`,`[opp`) wymagają policzenia aspektów (LOG-06) — na później.
|
||||||
|
|
||||||
|
Format skrótów odczytany z realnej bazy: planety `[Su`,`[Mo`,…; znaki
|
||||||
|
`[Ari`,`[Tau`,…; domy `12th H.`; np. `[Sa [conj [Su in 6th H.`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from app.abbreviations import expand
|
||||||
|
from app.engine.formats import SIGN_ABBR, SIGNS
|
||||||
|
from app.scoring import score_facet
|
||||||
|
|
||||||
|
PLANET_ABBR = {
|
||||||
|
"Sun": "Su", "Moon": "Mo", "Mercury": "Me", "Venus": "Ve", "Mars": "Ma",
|
||||||
|
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
|
||||||
|
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith)
|
||||||
|
"North Node": "NN", "South Node": "SN", "Lilith": "Lilith",
|
||||||
|
# Lots — w bazie Fortuna występuje jako [PF (Part of Fortune)
|
||||||
|
"Fortune": "PF", "Spirit": "Spirit",
|
||||||
|
}
|
||||||
|
SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
|
||||||
|
|
||||||
|
|
||||||
|
class DataSource(Protocol):
|
||||||
|
def search(
|
||||||
|
self, key: str, value: str, exact: bool, limit: int, fields: list[str] | None = None
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _effect(row: dict) -> str:
|
||||||
|
for col in ("actioneffect", "topicresult", "bodypart"):
|
||||||
|
v = row.get(col)
|
||||||
|
if v and str(v).strip().lower() not in ("", "nan"):
|
||||||
|
return str(v).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_noise(sig: str, effect: str) -> bool:
|
||||||
|
s = sig.strip().lower()
|
||||||
|
e = effect.strip().lower()
|
||||||
|
return (
|
||||||
|
e in ("", "nan", "x", "x?", "?", "-") # efekt pusty/zastępczy
|
||||||
|
or s.startswith("significator") # wiersz-legenda/nagłówek
|
||||||
|
or "header" in s
|
||||||
|
or s.startswith("*") # *MARKER / *header
|
||||||
|
or s in ("x", "x?", "nan") # znacznik „pomiń rekord"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ordinal(n: int) -> str:
|
||||||
|
if 10 <= n % 100 <= 20:
|
||||||
|
suffix = "th"
|
||||||
|
else:
|
||||||
|
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
|
||||||
|
return f"{n}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(s: str) -> str:
|
||||||
|
"""Normalizacja do porównań duplikatów: bez skrajnych spacji, jedna spacja, lower."""
|
||||||
|
return " ".join(str(s).strip().lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
def _facet_samples(rows: list[dict], tokens: list[str]) -> list[dict]:
|
||||||
|
"""Rekordy, których sygnifikator zawiera WSZYSTKIE tokeny — bez szumu i bez duplikatów.
|
||||||
|
|
||||||
|
Duplikat = ten sam sygnifikator ORAZ ten sam opis (po normalizacji). Dedup
|
||||||
|
działa na zagregowanym wyniku, więc odsiewa też powtórki między wieloma bazami.
|
||||||
|
"""
|
||||||
|
toks = [t.lower() for t in tokens if t]
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for r in rows:
|
||||||
|
sig = str(r.get("significator") or "").strip()
|
||||||
|
low = sig.lower()
|
||||||
|
if not all(t in low for t in toks):
|
||||||
|
continue
|
||||||
|
eff = _effect(r)
|
||||||
|
if _is_noise(sig, eff):
|
||||||
|
continue
|
||||||
|
key = (_norm(sig), _norm(eff))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append({"significator": sig, "expanded": expand(sig), "effect": eff})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _group_by_effect(samples: list[dict]) -> list[dict]:
|
||||||
|
"""Grupuje próbki po opisie: ten sam efekt = jedna grupa z listą sygnifikatorów."""
|
||||||
|
groups: dict[str, dict] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for s in samples:
|
||||||
|
key = _norm(s["effect"])
|
||||||
|
g = groups.get(key)
|
||||||
|
if g is None:
|
||||||
|
g = {"effect": s["effect"], "count": 0, "significators": []}
|
||||||
|
groups[key] = g
|
||||||
|
order.append(key)
|
||||||
|
g["count"] += 1
|
||||||
|
g["significators"].append(s["expanded"])
|
||||||
|
result = [groups[k] for k in order]
|
||||||
|
result.sort(key=lambda g: g["count"], reverse=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(
|
||||||
|
positions: list[dict],
|
||||||
|
data: DataSource,
|
||||||
|
aspects: list[dict] | None = None,
|
||||||
|
per_object_limit: int = 5000,
|
||||||
|
group: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""positions: pozycje z build_chart (name, sign, direction, house).
|
||||||
|
|
||||||
|
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
|
||||||
|
aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty
|
||||||
|
(ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety.
|
||||||
|
"""
|
||||||
|
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
|
||||||
|
|
||||||
|
items: list[dict] = []
|
||||||
|
provider = None
|
||||||
|
for p in positions:
|
||||||
|
name = p.get("name")
|
||||||
|
if name not in PLANET_ABBR:
|
||||||
|
continue
|
||||||
|
planet_tok = "[" + PLANET_ABBR[name]
|
||||||
|
raw = data.search(
|
||||||
|
key="significator",
|
||||||
|
value=planet_tok,
|
||||||
|
exact=False,
|
||||||
|
limit=per_object_limit,
|
||||||
|
fields=["significator", "actioneffect", "topicresult", "bodypart"],
|
||||||
|
)
|
||||||
|
provider = raw.get("provider", provider)
|
||||||
|
rows = raw.get("rows", [])
|
||||||
|
|
||||||
|
facets: list[dict] = []
|
||||||
|
sign = p.get("sign")
|
||||||
|
sign_tok = "[" + SIGN_TO_ABBR.get(sign, "")
|
||||||
|
sign_samples = _facet_samples(rows, [sign_tok])
|
||||||
|
facets.append({
|
||||||
|
"type": "sign", "label": f"w znaku {sign}", "token": sign_tok,
|
||||||
|
"count": len(sign_samples), "samples": sign_samples,
|
||||||
|
})
|
||||||
|
|
||||||
|
house = p.get("house")
|
||||||
|
if house:
|
||||||
|
ordn = _ordinal(int(house))
|
||||||
|
house_samples = _facet_samples(rows, [f"{ordn} h"]) # matcuje '12th H.'
|
||||||
|
facets.append({
|
||||||
|
"type": "house", "label": f"w {ordn} domu", "token": f"{ordn} H.",
|
||||||
|
"count": len(house_samples), "samples": house_samples,
|
||||||
|
})
|
||||||
|
|
||||||
|
for asp in (aspects or []):
|
||||||
|
if name not in (asp.get("obj1"), asp.get("obj2")):
|
||||||
|
continue
|
||||||
|
other = asp["obj2"] if asp["obj1"] == name else asp["obj1"]
|
||||||
|
asp_tok = ASP_TOKEN.get(asp["aspect"])
|
||||||
|
if other not in PLANET_ABBR or not asp_tok:
|
||||||
|
continue
|
||||||
|
other_tok = "[" + PLANET_ABBR[other]
|
||||||
|
asp_samples = _facet_samples(rows, [asp_tok, other_tok])
|
||||||
|
if not asp_samples: # pokazujemy tylko aspekty z trafieniami
|
||||||
|
continue
|
||||||
|
as_suffix = f" ({asp['as']})" if asp.get("as") else ""
|
||||||
|
facets.append({
|
||||||
|
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}{as_suffix}",
|
||||||
|
"token": f"{asp_tok} + {other_tok}",
|
||||||
|
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
|
||||||
|
"applying": asp.get("applying"),
|
||||||
|
"count": len(asp_samples), "samples": asp_samples,
|
||||||
|
})
|
||||||
|
|
||||||
|
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
|
||||||
|
for f in facets:
|
||||||
|
f["score"] = score_facet(f)
|
||||||
|
if group:
|
||||||
|
f["groups"] = _group_by_effect(f["samples"])
|
||||||
|
facets.sort(key=lambda f: f["score"], reverse=True)
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"object": name,
|
||||||
|
"sign": sign,
|
||||||
|
"house": house,
|
||||||
|
"direction": p.get("direction"),
|
||||||
|
"planet_token": planet_tok,
|
||||||
|
"planet_total": raw.get("total", 0),
|
||||||
|
"facets": facets,
|
||||||
|
})
|
||||||
|
return {"provider": provider, "objects": items}
|
||||||
|
|
||||||
|
|
||||||
|
def _event_tokens(event: dict) -> list[str]:
|
||||||
|
"""Tokeny bazy dla zdarzenia osi czasu (spięcie 1B→2B). Pierwszy = planeta."""
|
||||||
|
from app.engine.aspects import DB_TOKEN
|
||||||
|
|
||||||
|
if event.get("technique") == "solar_arc":
|
||||||
|
p = PLANET_ABBR.get(event.get("directed"))
|
||||||
|
a = DB_TOKEN.get(event.get("aspect"))
|
||||||
|
q = PLANET_ABBR.get(event.get("target")) # None dla Asc/MC (nie ma tokenu)
|
||||||
|
toks = []
|
||||||
|
if p:
|
||||||
|
toks.append("[" + p)
|
||||||
|
if a:
|
||||||
|
toks.append(a)
|
||||||
|
if q:
|
||||||
|
toks.append("[" + q)
|
||||||
|
return toks if p else []
|
||||||
|
if event.get("technique") == "profection":
|
||||||
|
lord = PLANET_ABBR.get(event.get("lord"))
|
||||||
|
sign = SIGN_TO_ABBR.get(event.get("sign"))
|
||||||
|
toks = []
|
||||||
|
if lord:
|
||||||
|
toks.append("[" + lord)
|
||||||
|
if sign:
|
||||||
|
toks.append("[" + sign)
|
||||||
|
return toks if lord else []
|
||||||
|
if event.get("technique") == "firdaria":
|
||||||
|
major = PLANET_ABBR.get(event.get("fd_major"))
|
||||||
|
sub = PLANET_ABBR.get(event.get("fd_sub"))
|
||||||
|
toks = []
|
||||||
|
if major:
|
||||||
|
toks.append("[" + major)
|
||||||
|
if sub:
|
||||||
|
toks.append("[" + sub)
|
||||||
|
return toks if major else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def interpret_events(events: list[dict], data: DataSource, limit: int = 4,
|
||||||
|
per_object_limit: int = 5000) -> list[dict]:
|
||||||
|
"""Dopina interpretacje z bazy do zdarzeń osi czasu (predykcyjne 1B → 2B).
|
||||||
|
|
||||||
|
Wyszukuje po tokenie planety zdarzenia i zawęża do wszystkich tokenów
|
||||||
|
(aspekt/druga planeta lub znak), z odsiewaniem szumu i duplikatów.
|
||||||
|
"""
|
||||||
|
for ev in events:
|
||||||
|
tokens = _event_tokens(ev)
|
||||||
|
if not tokens:
|
||||||
|
continue
|
||||||
|
raw = data.search(
|
||||||
|
key="significator", value=tokens[0], exact=False, limit=per_object_limit,
|
||||||
|
fields=["significator", "actioneffect", "topicresult", "bodypart"],
|
||||||
|
)
|
||||||
|
samples = _facet_samples(raw.get("rows", []), tokens)
|
||||||
|
ev["interpretations"] = samples[:limit]
|
||||||
|
ev["interpretations_count"] = len(samples)
|
||||||
|
return events
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
@@ -2,3 +2,7 @@ fastapi>=0.115
|
|||||||
uvicorn[standard]>=0.34
|
uvicorn[standard]>=0.34
|
||||||
httpx>=0.28
|
httpx>=0.28
|
||||||
pydantic>=2.10
|
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
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Wspólne fixture'y testów silnika.
|
||||||
|
|
||||||
|
Horoskop referencyjny = przykład z notatek projektu (notes3): 30.04.1984,
|
||||||
|
09:35 CEST = 07:35 UTC, Warszawa. Pozycje znane z astro.com/Swiss Ephemeris
|
||||||
|
służą jako wyrocznia (LOG-25).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
REFERENCE_UTC = dt.datetime(1984, 4, 30, 7, 35, 0, tzinfo=dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def reference_moment() -> ChartMoment:
|
||||||
|
return ChartMoment(when_utc=REFERENCE_UTC, lat=52.2333, lon=21.0167)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def own_engine():
|
||||||
|
pytest.importorskip("skyfield")
|
||||||
|
from app.engine.skyfield_engine import SkyfieldEngine
|
||||||
|
|
||||||
|
try:
|
||||||
|
return SkyfieldEngine()
|
||||||
|
except Exception as e: # brak efemeryd / brak sieci
|
||||||
|
# Lokalnie pomijamy (dev bez pobranego jądra), ale w CI to musi być błąd —
|
||||||
|
# inaczej testy referencyjne (walidacja względem astro.com) cicho znikają.
|
||||||
|
if os.getenv("CI"):
|
||||||
|
pytest.fail(f"CI: silnik efemeryd niedostępny — testy referencyjne muszą działać: {e}")
|
||||||
|
pytest.skip(f"Nie można wczytać efemeryd: {e}")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Testy rozwijania skrótów sygnifikatorów."""
|
||||||
|
from app.abbreviations import expand
|
||||||
|
|
||||||
|
|
||||||
|
def test_planet_in_sign():
|
||||||
|
assert expand("[Su in [Tau") == "Sun in Taurus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_aspect_and_house():
|
||||||
|
assert expand("[Sa [conj [Su in 6th H.") == "Saturn conjunction Sun in 6th house"
|
||||||
|
|
||||||
|
|
||||||
|
def test_affliction_word_and_house():
|
||||||
|
assert expand("[Ne affl. in the 7th H.") == "Neptune afflicted in the 7th house"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_token_kept_without_bracket():
|
||||||
|
assert expand("[Xyz rising") == "Xyz rising"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_passthrough():
|
||||||
|
assert expand("") == ""
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
||||||
|
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
|
||||||
|
|
||||||
|
|
||||||
|
def test_separation_wraparound():
|
||||||
|
assert separation(10, 350) == 20
|
||||||
|
assert separation(0, 180) == 180
|
||||||
|
assert separation(0, 90) == 90
|
||||||
|
|
||||||
|
|
||||||
|
def test_conjunction_and_opposition():
|
||||||
|
pos = [
|
||||||
|
{"name": "Sun", "decimal": 10.0},
|
||||||
|
{"name": "Moon", "decimal": 12.0}, # 2° od Słońca -> koniunkcja
|
||||||
|
{"name": "Mars", "decimal": 190.0}, # 180° od Słońca -> opozycja
|
||||||
|
]
|
||||||
|
pairs = {(a["obj1"], a["obj2"], a["aspect"]) for a in find_aspects(pos)}
|
||||||
|
assert ("Sun", "Moon", "conjunction") in pairs
|
||||||
|
assert ("Sun", "Mars", "opposition") in pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_orb_limit_excludes_wide():
|
||||||
|
pos = [{"name": "Mercury", "decimal": 0.0}, {"name": "Venus", "decimal": 100.0}]
|
||||||
|
assert find_aspects(pos, orb=8.0, luminary_bonus=0.0) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_luminary_bonus_widens_orb():
|
||||||
|
# 99.5° -> 9.5° od kwadratury; z bonusem luminarza (8+2) mieści się
|
||||||
|
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Saturn", "decimal": 99.5}]
|
||||||
|
assert any(a["aspect"] == "square" for a in find_aspects(pos))
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_aspect_per_pair():
|
||||||
|
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||||
|
assert len(find_aspects(pos)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_applying_when_faster_body_catches_up():
|
||||||
|
# Księżyc 5° za Słońcem, szybszy -> koniunkcja aplikacyjna
|
||||||
|
pos = [
|
||||||
|
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
|
||||||
|
{"name": "Moon", "decimal": 35.0, "speed": 13.0},
|
||||||
|
]
|
||||||
|
a = find_aspects(pos)[0]
|
||||||
|
assert a["applying"] is True and a["as"] == "A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_separating_when_moving_apart():
|
||||||
|
# Księżyc 5° przed Słońcem i szybszy -> koniunkcja separacyjna
|
||||||
|
pos = [
|
||||||
|
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
|
||||||
|
{"name": "Moon", "decimal": 45.0, "speed": 13.0},
|
||||||
|
]
|
||||||
|
a = find_aspects(pos)[0]
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
# Referencja A/S z notes3 (astro-seek) dla horoskopu 30.04.1984 07:35 UT, Warszawa.
|
||||||
|
REFERENCE_AS = {
|
||||||
|
("Sun", "Moon", "conjunction"): "A",
|
||||||
|
("Sun", "Jupiter", "trine"): "A",
|
||||||
|
("Sun", "Saturn", "opposition"): "A",
|
||||||
|
("Sun", "Neptune", "trine"): "S",
|
||||||
|
("Sun", "Pluto", "opposition"): "S",
|
||||||
|
("Moon", "Mercury", "conjunction"): "S",
|
||||||
|
("Moon", "Venus", "conjunction"): "S",
|
||||||
|
("Moon", "Neptune", "trine"): "A",
|
||||||
|
("Moon", "Pluto", "opposition"): "S",
|
||||||
|
("Mercury", "Venus", "conjunction"): "S",
|
||||||
|
("Mercury", "Neptune", "trine"): "S",
|
||||||
|
("Mercury", "Pluto", "opposition"): "S",
|
||||||
|
("Venus", "Neptune", "trine"): "A",
|
||||||
|
("Venus", "Pluto", "opposition"): "A",
|
||||||
|
("Jupiter", "Saturn", "sextile"): "A",
|
||||||
|
("Neptune", "Pluto", "sextile"): "S",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_applying_separating_matches_astroseek_reference(own_engine, reference_moment):
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
|
||||||
|
chart = build_chart(own_engine, reference_moment)
|
||||||
|
got = {(a["obj1"], a["obj2"], a["aspect"]): a.get("as") for a in chart["aspects"]}
|
||||||
|
mismatches = {k: (got.get(k), v) for k, v in REFERENCE_AS.items() if got.get(k) != v}
|
||||||
|
assert not mismatches, f"rozbieżności A/S vs astro-seek: {mismatches}"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Integracja: pełny horoskop (pozycje + osie + domy) przez silnik (LOG-01+LOG-05).
|
||||||
|
|
||||||
|
Waliduje względem astro.com dla horoskopu referencyjnego.
|
||||||
|
"""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_angles_match_reference(own_engine, reference_moment):
|
||||||
|
chart = build_chart(own_engine, reference_moment, "whole_sign")
|
||||||
|
assert chart["angles"]["Asc"]["sign"] == "Cancer"
|
||||||
|
assert chart["angles"]["MC"]["sign"] == "Pisces"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_house_assignments_match_reference(own_engine, reference_moment):
|
||||||
|
chart = build_chart(own_engine, reference_moment, "whole_sign")
|
||||||
|
by = {p["name"]: p for p in chart["positions"]}
|
||||||
|
expected = {"Sun": 11, "Moon": 11, "Mercury": 10, "Venus": 10, "Mars": 5,
|
||||||
|
"Jupiter": 7, "Saturn": 5, "Uranus": 6, "Neptune": 7, "Pluto": 5}
|
||||||
|
for name, house in expected.items():
|
||||||
|
assert by[name]["house"] == house, f"{name}: dom {by[name]['house']} != {house}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_house_systems_available(own_engine, reference_moment):
|
||||||
|
for system in ("whole_sign", "equal", "porphyry"):
|
||||||
|
chart = build_chart(own_engine, reference_moment, system)
|
||||||
|
assert chart["house_system"] == system
|
||||||
|
assert len(chart["cusps"]) == 12
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Testy harnessu porównawczego (LOG-25) — bez efemeryd."""
|
||||||
|
from app.engine.compare import compare_positions
|
||||||
|
from app.engine.models import ObjectPosition
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_positions_have_zero_diff():
|
||||||
|
a = [ObjectPosition("Sun", 40.0, 0.0, 0.95, False)]
|
||||||
|
report = compare_positions(a, a, lon_tol_arcsec=1.0)
|
||||||
|
assert report.ok
|
||||||
|
assert report.max_arcsec == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_beyond_tolerance_is_flagged():
|
||||||
|
a = [ObjectPosition("Sun", 40.0, 0.0, 0.95, False)]
|
||||||
|
b = [ObjectPosition("Sun", 40.1, 0.0, 0.95, False)] # 0.1° = 360"
|
||||||
|
report = compare_positions(a, b, lon_tol_arcsec=120.0)
|
||||||
|
assert not report.ok
|
||||||
|
assert report.diffs[0].over_tolerance
|
||||||
|
assert abs(abs(report.diffs[0].delta_arcsec) - 360.0) < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrograde_mismatch_is_flagged():
|
||||||
|
a = [ObjectPosition("Mars", 234.0, 0.0, -0.2, True)]
|
||||||
|
b = [ObjectPosition("Mars", 234.0, 0.0, 0.2, False)]
|
||||||
|
report = compare_positions(a, b, lon_tol_arcsec=120.0)
|
||||||
|
assert not report.ok
|
||||||
|
assert report.diffs[0].speed_sign_mismatch
|
||||||
|
|
||||||
|
|
||||||
|
def test_wraparound_delta_is_small():
|
||||||
|
a = [ObjectPosition("Sun", 359.99, 0.0, 1.0, False)]
|
||||||
|
b = [ObjectPosition("Sun", 0.01, 0.0, 1.0, False)]
|
||||||
|
report = compare_positions(a, b, lon_tol_arcsec=120.0)
|
||||||
|
assert report.ok # 0.02° ≈ 72" < 120"
|
||||||
|
assert abs(report.diffs[0].delta_arcsec) < 80.0
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Kontrakt parzystości silników (LOG-28).
|
||||||
|
|
||||||
|
Ten sam test musi przejść dla KAŻDEGO silnika. Dziś uruchamiamy go dla silnika
|
||||||
|
własnego; gdy skonfigurowany jest ENGINE_SWISSEPH_URL, ten sam kontrakt sprawdza
|
||||||
|
też silnik B.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.compare import check_engine_contract
|
||||||
|
|
||||||
|
|
||||||
|
def test_own_engine_contract(own_engine, reference_moment):
|
||||||
|
check_engine_contract(own_engine, reference_moment)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not os.getenv("ENGINE_SWISSEPH_URL"),
|
||||||
|
reason="silnik B (swisseph) nieskonfigurowany — pomijam",
|
||||||
|
)
|
||||||
|
def test_swisseph_engine_contract(reference_moment):
|
||||||
|
from app.engine.remote_engine import RemoteEngine
|
||||||
|
|
||||||
|
check_engine_contract(RemoteEngine(), reference_moment)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Firdaria (LOG-11) — sekta, kolejność, długości okresów, podokresy."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.engine.firdaria import DAY_ORDER, NIGHT_ORDER, YEARS, firdaria, is_day_birth
|
||||||
|
from app.significators import _event_tokens
|
||||||
|
|
||||||
|
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||||
|
# horoskop referencyjny: Asc 112.18, MC 352.59, Sun 40.14 -> Day birth (notes3)
|
||||||
|
ASC, MC, SUN = 112.18, 352.59, 40.14
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_is_day_birth():
|
||||||
|
assert is_day_birth(SUN, ASC, MC) is True # notes3: "Day birth (Diurnal)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_birth_uses_night_order():
|
||||||
|
# Słońce po stronie IC (przeciwna półkula) -> noc
|
||||||
|
assert is_day_birth((SUN + 180) % 360, ASC, MC) is False
|
||||||
|
fd = firdaria(BIRTH, (SUN + 180) % 360, ASC, MC)
|
||||||
|
assert fd["sect"] == "night" and fd["order"] == NIGHT_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_diurnal_sequence_and_totals():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
assert fd["sect"] == "day" and fd["order"] == DAY_ORDER
|
||||||
|
lords = [p["lord"] for p in fd["periods"]]
|
||||||
|
assert lords == DAY_ORDER + ["North Node", "South Node"]
|
||||||
|
assert sum(p["years"] for p in fd["periods"]) == 75 # 70 + 3 + 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_planet_majors_have_seven_subperiods_summing_to_period():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
for p in fd["periods"]:
|
||||||
|
if p["lord"] in YEARS:
|
||||||
|
assert len(p["sub"]) == 7
|
||||||
|
assert p["sub"][0]["lord"] == p["lord"] # sub zaczyna się od władcy okresu
|
||||||
|
assert p["sub"][0]["start"] == p["start"]
|
||||||
|
assert p["sub"][-1]["end"] == p["end"]
|
||||||
|
else:
|
||||||
|
assert "sub" not in p # węzły bez podokresów
|
||||||
|
|
||||||
|
|
||||||
|
def test_periods_are_contiguous():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
for a, b in zip(fd["periods"], fd["periods"][1:]):
|
||||||
|
assert a["end"] == b["start"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_42_is_saturn_major():
|
||||||
|
# kumulatywnie: Su10 Ve8 Me13 Mo9 -> 40; Saturn 40-51 -> wiek 42 w Saturnie
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
saturn = next(p for p in fd["periods"] if p["lord"] == "Saturn")
|
||||||
|
assert saturn["start"].startswith("2024") and saturn["end"].startswith("2035")
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_firdaria():
|
||||||
|
ev = {"technique": "firdaria", "fd_major": "Saturn", "fd_sub": "Jupiter"}
|
||||||
|
assert _event_tokens(ev) == ["[Sa", "[Ju"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Testy formatowania (czyste, bez efemeryd)."""
|
||||||
|
from app.engine import formats
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_index():
|
||||||
|
assert formats.sign_index(0) == 0 # Aries
|
||||||
|
assert formats.sign_index(35) == 1 # Taurus
|
||||||
|
assert formats.sign_index(359.9) == 11 # Pisces
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_sign_basic():
|
||||||
|
# 40°08'21" absolutnie = Taurus 10°08'21"
|
||||||
|
assert formats.in_sign(40.139166) == "Tau 10°08'21\""
|
||||||
|
|
||||||
|
|
||||||
|
def test_absolute():
|
||||||
|
assert formats.absolute(40.139166) == "40°08'21\""
|
||||||
|
|
||||||
|
|
||||||
|
def test_wraparound_and_norm():
|
||||||
|
assert formats.norm360(370.0) == 10.0
|
||||||
|
assert formats.norm360(-1.0) == 359.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_rounding_carry_into_next_sign():
|
||||||
|
# tuż przy granicy znaku zaokrąglenie nie może dać "30°"
|
||||||
|
s = formats.in_sign(59.99999)
|
||||||
|
assert s.startswith("Gem 0°") or s.startswith("Tau 29°59")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
|
||||||
|
from app.engine import houses as H
|
||||||
|
|
||||||
|
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
||||||
|
RAMC, EPS, LAT = 353.1968, 23.44133, 52.2333
|
||||||
|
|
||||||
|
|
||||||
|
def _near(a, b, tol=0.05):
|
||||||
|
return abs(((a - b + 180) % 360) - 180) < tol
|
||||||
|
|
||||||
|
|
||||||
|
def test_asc_mc_match_reference():
|
||||||
|
asc = H.compute_asc(RAMC, EPS, LAT)
|
||||||
|
mc = H.compute_mc(RAMC, EPS)
|
||||||
|
assert _near(asc, 112.18) # Cancer 22°10' (astro.com)
|
||||||
|
assert _near(mc, 352.59) # Pisces 22°35'
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_sign_starts_on_sign_boundary():
|
||||||
|
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN)
|
||||||
|
assert cusps[0] == 90.0 # dom 1 = 0° Raka
|
||||||
|
assert cusps[1] == 120.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_equal_cusps_are_30_apart_from_asc():
|
||||||
|
cusps = H.cusps(112.18, 352.59, H.EQUAL)
|
||||||
|
assert abs(cusps[0] - 112.18) < 1e-9
|
||||||
|
assert abs(cusps[1] - 142.18) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_porphyry_angles_on_cusps():
|
||||||
|
cusps = H.cusps(112.18, 352.59, H.PORPHYRY)
|
||||||
|
assert abs(cusps[0] - 112.18) < 1e-9 # Asc = dom 1
|
||||||
|
assert abs(cusps[9] - 352.59) < 1e-9 # MC = dom 10
|
||||||
|
assert abs(cusps[6] - (112.18 + 180) % 360) < 1e-9 # Dsc = dom 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_house_whole_sign():
|
||||||
|
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°)
|
||||||
|
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
||||||
|
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Okna kontekstu i planowanie budżetu tokenów (LOG-30/31).
|
||||||
|
|
||||||
|
Naczelna zasada, której pilnują te testy: **zawsze zostaje miejsce na odpowiedź**.
|
||||||
|
Prompt nigdy nie może wypełnić całego okna kontekstu, bo wtedy model kończy na
|
||||||
|
`max_tokens` z pustą albo uciętą treścią — to był zgłoszony błąd.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.llm import limits
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_models_have_documented_limits():
|
||||||
|
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
|
||||||
|
assert ctx == 1_000_000 and out == 128_000
|
||||||
|
ctx, out = limits.limits_for("anthropic", "claude-haiku-4-5")
|
||||||
|
assert ctx == 200_000 and out == 64_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_model_tag_is_matched_by_prefix():
|
||||||
|
"""Modele lokalne niosą tag (`llama3.1:8b`) — dopasowanie musi to znieść."""
|
||||||
|
assert limits.limits_for("local", "llama3.1:8b") == limits.limits_for("local", "llama3.1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_model_falls_back_conservatively():
|
||||||
|
ctx, out = limits.limits_for("local", "jakis-egzotyczny-model")
|
||||||
|
assert ctx == 8_192 and out == 4_096
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_overrides_win(monkeypatch):
|
||||||
|
"""Modele wychodzą szybciej, niż aktualizuje się tabela."""
|
||||||
|
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "131072")
|
||||||
|
monkeypatch.setenv("LOCAL_MAX_OUTPUT", "8192")
|
||||||
|
assert limits.limits_for("local", "llama3.1:8b") == (131_072, 8_192)
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_override_ignores_garbage(monkeypatch):
|
||||||
|
monkeypatch.setenv("LOCAL_CONTEXT_WINDOW", "nie-liczba")
|
||||||
|
assert limits.limits_for("local", "llama3.1:8b")[0] == 8_192
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- rezerwa miejsca na odpowiedź
|
||||||
|
|
||||||
|
def test_prompt_budget_always_reserves_room_for_answer():
|
||||||
|
ctx, out = limits.limits_for("anthropic", "claude-opus-4-8")
|
||||||
|
budget = limits.prompt_token_budget("anthropic", "claude-opus-4-8")
|
||||||
|
assert budget + out + limits.SAFETY_MARGIN <= ctx
|
||||||
|
assert budget > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_small_context_model_still_leaves_room():
|
||||||
|
budget = limits.prompt_token_budget("local", "llama3.1:8b")
|
||||||
|
ctx, out = limits.limits_for("local", "llama3.1:8b")
|
||||||
|
assert budget + out + limits.SAFETY_MARGIN <= ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_shrinks_output_when_prompt_is_huge():
|
||||||
|
"""Duży prompt nie może dostać pełnego okna wyjścia — musi się zmieścić."""
|
||||||
|
ctx, model_out = limits.limits_for("local", "llama3.1:8b")
|
||||||
|
p = limits.plan("local", "llama3.1:8b", prompt_tokens=6_000)
|
||||||
|
assert p["max_output"] <= ctx - 6_000 - limits.SAFETY_MARGIN
|
||||||
|
assert p["max_output"] < model_out
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_reports_not_fitting_instead_of_failing_silently():
|
||||||
|
p = limits.plan("local", "llama3.1:8b", prompt_tokens=8_000)
|
||||||
|
assert p["fits"] is False
|
||||||
|
assert any("odpowied" in w for w in p["warnings"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_warns_above_90k_but_still_fits():
|
||||||
|
"""Powyżej 90 tys. tokenów ostrzegamy — ale wysłanie MA być nadal możliwe."""
|
||||||
|
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=120_000)
|
||||||
|
assert p["fits"] is True, "duży prompt nadal musi dać się wysłać"
|
||||||
|
assert p["max_output"] >= limits.MIN_OUTPUT
|
||||||
|
assert any("dużo" in w for w in p["warnings"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_warning_below_threshold():
|
||||||
|
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=10_000)
|
||||||
|
assert p["warnings"] == [] and p["fits"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_requested_output_is_capped_by_model_maximum():
|
||||||
|
p = limits.plan("anthropic", "claude-haiku-4-5", prompt_tokens=1_000, want_output=999_999)
|
||||||
|
assert p["max_output"] == 64_000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tokens", [0, 1_000, 50_000, 200_000, 900_000])
|
||||||
|
def test_plan_never_returns_negative_output(tokens):
|
||||||
|
p = limits.plan("anthropic", "claude-opus-4-8", prompt_tokens=tokens)
|
||||||
|
assert p["max_output"] >= 0
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""Szyfrowanie łącza między warstwami (PRE-16).
|
||||||
|
|
||||||
|
Sedno: przez to łącze płyną surowe wiersze oryginalnych baz interpretacyjnych.
|
||||||
|
Testy nie sprawdzają więc tylko, czy „coś się zaszyfrowało i odszyfrowało" —
|
||||||
|
sprawdzają, czy tajnego tekstu FAKTYCZNIE NIE MA w bajtach lecących po sieci
|
||||||
|
oraz czy każda znana droga na skróty (brak szyfrowania, obcy klucz, podmieniony
|
||||||
|
bajt, przeklejenie na inny endpoint, odtworzenie po czasie) kończy się odmową.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
|
from app.link_crypto import ENV_LOGIC_DATA, ENV_PRESENTATION_LOGIC, Link, LinkError
|
||||||
|
|
||||||
|
SECRET = "Saturn w VII domu — opis z oryginalnej bazy interpretacyjnej"
|
||||||
|
KEY_A = "11" * 32 # hex, 32 bajty
|
||||||
|
KEY_B = "22" * 32
|
||||||
|
|
||||||
|
|
||||||
|
class Payload(BaseModel):
|
||||||
|
question: str
|
||||||
|
|
||||||
|
|
||||||
|
def _app(link: Link | None) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
if link is not None:
|
||||||
|
app.add_middleware(link_crypto.LinkCryptoMiddleware, link=link, layer="testowa")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.post("/search")
|
||||||
|
def search(payload: Payload):
|
||||||
|
return {"echo": payload.question, "interpretation": SECRET}
|
||||||
|
|
||||||
|
@app.get("/catalog")
|
||||||
|
def catalog():
|
||||||
|
return {"models": ["a", "b"]}
|
||||||
|
|
||||||
|
@app.post("/stream")
|
||||||
|
def stream():
|
||||||
|
def lines():
|
||||||
|
for i in range(4):
|
||||||
|
yield json.dumps({"step": i, "note": SECRET}).encode() + b"\n"
|
||||||
|
|
||||||
|
return StreamingResponse(lines(), media_type="application/x-ndjson")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def link():
|
||||||
|
return Link(link_crypto.parse_key(KEY_A))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- klucze
|
||||||
|
|
||||||
|
def test_key_accepts_hex_and_base64():
|
||||||
|
import base64
|
||||||
|
|
||||||
|
raw = bytes(range(32))
|
||||||
|
assert link_crypto.parse_key(raw.hex()) == raw
|
||||||
|
assert link_crypto.parse_key(base64.b64encode(raw).decode()) == raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_of_wrong_length_is_rejected_loudly():
|
||||||
|
"""Krótki klucz to nie „słabsze szyfrowanie", tylko błąd konfiguracji."""
|
||||||
|
with pytest.raises(LinkError, match="32"):
|
||||||
|
link_crypto.parse_key("aabb")
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_that_is_neither_hex_nor_base64_is_rejected():
|
||||||
|
with pytest.raises(LinkError, match="hex"):
|
||||||
|
link_crypto.parse_key("!!! to nie jest klucz !!!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_from_env_is_lazy(monkeypatch):
|
||||||
|
monkeypatch.delenv(ENV_PRESENTATION_LOGIC, raising=False)
|
||||||
|
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) is None
|
||||||
|
monkeypatch.setenv(ENV_PRESENTATION_LOGIC, KEY_A)
|
||||||
|
assert link_crypto.key_from_env(ENV_PRESENTATION_LOGIC) == bytes.fromhex(KEY_A)
|
||||||
|
|
||||||
|
|
||||||
|
def test_directions_use_different_subkeys(link):
|
||||||
|
"""Żądanie i odpowiedź nie dzielą klucza — powtórzenie jednorazówki w jedną
|
||||||
|
stronę nie osłabia drugiej."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
sealed = link.seal(link_crypto.REQUEST, "/search", stamp, 0, b"tajne")
|
||||||
|
with pytest.raises(LinkError):
|
||||||
|
link.open(link_crypto.RESPONSE, "/search", stamp, 0, sealed)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- podstawowy obieg
|
||||||
|
|
||||||
|
def test_round_trip_delivers_plaintext_to_the_app(link):
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||||
|
payload={"question": "Saturn"}, link=link)
|
||||||
|
assert got["echo"] == "Saturn"
|
||||||
|
assert got["interpretation"] == SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_without_body_also_works(link):
|
||||||
|
"""GET nie ma ciała, ale i tak pieczętujemy pustą kopertę — to ona dowodzi,
|
||||||
|
że pytający ma klucz, i ona wymusza zaszyfrowanie odpowiedzi."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
got = link_crypto.call_json(client, "GET", "http://testserver/catalog", link=link)
|
||||||
|
assert got == {"models": ["a", "b"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_key_traffic_stays_plaintext(link):
|
||||||
|
"""Dev bez sekretów ma działać jak dotąd — inaczej nikt nie odpali projektu lokalnie."""
|
||||||
|
with TestClient(_app(None)) as client:
|
||||||
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||||
|
payload={"question": "Saturn"}, link=None)
|
||||||
|
assert got["interpretation"] == SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_stays_open_for_kubernetes_probes(link):
|
||||||
|
"""Sondy k8s klucza nie mają. Gdyby /health wymagał szyfrowania, literówka
|
||||||
|
w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
assert client.get("http://testserver/health").json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- czy na kablu naprawdę nic nie widać
|
||||||
|
|
||||||
|
def _raw_exchange(client, link, payload):
|
||||||
|
"""Wysyła zapieczętowane żądanie i zwraca SUROWE bajty obu stron."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
body = link_crypto.frame_out(
|
||||||
|
link.seal(link_crypto.REQUEST, "/search", stamp, 0, json.dumps(payload).encode()))
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
return body, response
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_bytes_do_not_contain_the_question(link):
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
body, _ = _raw_exchange(client, link, {"question": "Saturn w VII"})
|
||||||
|
assert b"Saturn" not in body
|
||||||
|
assert b"question" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_bytes_do_not_contain_the_interpretation(link):
|
||||||
|
"""To jest właściwy powód istnienia całego modułu."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
_, response = _raw_exchange(client, link, {"question": "Saturn"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
assert b"interpretation" not in response.content
|
||||||
|
assert response.headers[link_crypto.HEADER_ENC] == link_crypto.VERSION
|
||||||
|
assert response.headers["content-type"] == link_crypto.CONTENT_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- drogi na skróty i ataki
|
||||||
|
|
||||||
|
def test_plaintext_request_is_refused_when_key_is_set(link):
|
||||||
|
"""Fail-closed: regresja po stronie klienta nie może oznaczać cichego
|
||||||
|
powrotu do jawnego ruchu."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
response = client.post("http://testserver/search", json={"question": "Saturn"})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_foreign_key_cannot_read_the_link(link):
|
||||||
|
"""Klucze są osobne dla każdej pary warstw — przejęcie jednego nie otwiera drugiej."""
|
||||||
|
intruder = Link(link_crypto.parse_key(KEY_B))
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
body = link_crypto.frame_out(
|
||||||
|
intruder.seal(link_crypto.REQUEST, "/search", stamp, 0, b'{"question":"x"}'))
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_flipped_bit_is_rejected(link):
|
||||||
|
"""GCM uwierzytelnia, więc nie ma wariantu „odszyfrowało się, ale zmienione"."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
sealed = bytearray(link.seal(link_crypto.REQUEST, "/search", stamp, 0,
|
||||||
|
b'{"question":"x"}'))
|
||||||
|
sealed[-1] ^= 0x01
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search",
|
||||||
|
content=link_crypto.frame_out(bytes(sealed)),
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_cannot_be_replayed_against_another_endpoint(link):
|
||||||
|
"""Ścieżka wchodzi do materiału uwierzytelnianego, więc podsłuchanej koperty
|
||||||
|
nie da się przekleić tam, gdzie odpowiedź byłaby ciekawsza."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
sealed = link.seal(link_crypto.REQUEST, "/catalog", stamp, 0, b"")
|
||||||
|
with pytest.raises(LinkError):
|
||||||
|
link.open(link_crypto.REQUEST, "/search", stamp, 0, sealed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_frames_cannot_be_reordered(link):
|
||||||
|
"""Numer ramki jest uwierzytelniony — przestawienie kolejności w strumieniu
|
||||||
|
to błąd, a nie po cichu pomieszany horoskop."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
second = link.seal(link_crypto.RESPONSE, "/stream", stamp, 1, b"druga")
|
||||||
|
with pytest.raises(LinkError):
|
||||||
|
link.open(link_crypto.RESPONSE, "/stream", stamp, 0, second)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_frame_is_refused(link, monkeypatch):
|
||||||
|
"""Bez okna czasowego podsłuchane żądanie dałoby się odtworzyć kiedykolwiek."""
|
||||||
|
old = f"{time.time() - link_crypto.MAX_SKEW_SECONDS - 60:.3f}"
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
body = link_crypto.frame_out(
|
||||||
|
link.seal(link_crypto.REQUEST, "/search", old, 0, b'{"question":"x"}'))
|
||||||
|
response = client.request(
|
||||||
|
"POST", "http://testserver/search", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: old,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncated_stream_is_an_error_not_silent_loss(link):
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
full = link_crypto.frame_out(link.seal(link_crypto.RESPONSE, "/x", stamp, 0, b"abc"))
|
||||||
|
with pytest.raises(LinkError, match="urwana"):
|
||||||
|
link.open_all(link_crypto.RESPONSE, "/x", stamp, full[:-2])
|
||||||
|
|
||||||
|
|
||||||
|
def test_absurd_frame_length_does_not_allocate(link):
|
||||||
|
"""Zadeklarowana długość pochodzi z sieci — nie wolno jej wierzyć na słowo."""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
with pytest.raises(LinkError, match="rozmiar"):
|
||||||
|
list(link_crypto.frames_in(struct.pack(">I", 2 ** 31) + b"nic"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- odpowiedź strumieniowa
|
||||||
|
|
||||||
|
def test_streaming_response_survives_encryption(link):
|
||||||
|
"""Okno postępu dostaje kolejne linie na żywo — muszą dojść po kolei
|
||||||
|
i w komplecie, mimo że każda jedzie w osobnej kopercie."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
body = link_crypto.frame_out(link.seal(link_crypto.REQUEST, "/stream", stamp, 0, b""))
|
||||||
|
with client.stream("POST", "http://testserver/stream", content=body,
|
||||||
|
headers={link_crypto.HEADER_ENC: link_crypto.VERSION,
|
||||||
|
link_crypto.HEADER_TS: stamp,
|
||||||
|
"Content-Type": link_crypto.CONTENT_TYPE}) as response:
|
||||||
|
chunks = list(link_crypto.open_response_stream(response, link))
|
||||||
|
|
||||||
|
steps = [json.loads(line) for line in b"".join(chunks).splitlines()]
|
||||||
|
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
||||||
|
assert all(s["note"] == SECRET for s in steps)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_lines_helper_round_trips_ndjson(link):
|
||||||
|
"""Wyższa warstwa (`stream_lines`) — dokładnie tej używa klient okna postępu.
|
||||||
|
|
||||||
|
Regresja, którą pilnuje ten test: strumień horoskopu powstał na gałęzi, która
|
||||||
|
nie widziała szyfrowania, więc szedł surowym httpx. Po scaleniu z PRE-16 przy
|
||||||
|
włączonym łączu żądanie było odrzucane (400) albo odpowiedź wracała jako
|
||||||
|
nieczytelne ramki. Tu sprawdzamy, że helper zwraca CZYSTE linie NDJSON i że
|
||||||
|
tajny opis nie przecieka po drodze."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
lines = list(link_crypto.stream_lines(
|
||||||
|
client, "http://testserver/stream", payload=None, link=link))
|
||||||
|
steps = [json.loads(line) for line in lines]
|
||||||
|
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
||||||
|
assert all(s["note"] == SECRET for s in steps)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_lines_plaintext_path_still_works(link):
|
||||||
|
"""Bez klucza (dev) strumień ma działać jak dotąd — surowy, bez szyfrowania."""
|
||||||
|
with TestClient(_app(None)) as client:
|
||||||
|
lines = list(link_crypto.stream_lines(
|
||||||
|
client, "http://testserver/stream", payload=None, link=None))
|
||||||
|
assert [json.loads(line)["step"] for line in lines] == [0, 1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_request_without_key_is_refused_when_required(link):
|
||||||
|
"""Fail-closed obejmuje też strumień: nieszyfrowane żądanie na łączu z kluczem
|
||||||
|
dostaje odmowę, a nie cichy jawny przelot tajnych danych."""
|
||||||
|
with TestClient(_app(link)) as client:
|
||||||
|
response = client.post("http://testserver/stream", json={})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert SECRET.encode() not in response.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_lines_client_is_fail_closed_when_required(monkeypatch, link):
|
||||||
|
"""Klient strumienia też nie wypuszcza jawnego żądania — jak `call`. Bez tego
|
||||||
|
ciało (dane urodzenia) poszłoby w eter, zanim serwer zdążyłby odmówić."""
|
||||||
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
class Tripwire:
|
||||||
|
def stream(self, *args, **kwargs):
|
||||||
|
sent.append(args)
|
||||||
|
raise AssertionError("strumień NIE powinien opuścić procesu")
|
||||||
|
|
||||||
|
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
||||||
|
list(link_crypto.stream_lines(Tripwire(), "http://logic/chart/horoscope/stream",
|
||||||
|
payload={"lat": 50.0}, link=None))
|
||||||
|
assert not sent, "żądanie strumienia wyszłoby jawnym tekstem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_incremental_unframing_handles_split_frames(link):
|
||||||
|
"""Ramka potrafi rozjechać się między dwa odczyty z gniazda — składamy ją
|
||||||
|
w buforze, zamiast zakładać, że każdy kawałek to komplet."""
|
||||||
|
stamp = link_crypto.stamp_now()
|
||||||
|
stream = b"".join(link.seal_stream(link_crypto.RESPONSE, "/x", stamp,
|
||||||
|
[b"raz", b"dwa", b"trzy"]))
|
||||||
|
buffer = bytearray()
|
||||||
|
opened, seq = [], 0
|
||||||
|
for i in range(0, len(stream), 5): # ciachamy w poprzek ramek
|
||||||
|
buffer += stream[i:i + 5]
|
||||||
|
for frame in link_crypto.unframe_incremental(buffer):
|
||||||
|
opened.append(link.open(link_crypto.RESPONSE, "/x", stamp, seq, frame))
|
||||||
|
seq += 1
|
||||||
|
assert opened == [b"raz", b"dwa", b"trzy"]
|
||||||
|
assert not buffer, "bufor musi zostać pusty — inaczej gdzieś zgubiliśmy ramkę"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------- trzy kopie muszą być identyczne
|
||||||
|
|
||||||
|
def test_all_three_services_share_the_same_module():
|
||||||
|
"""Moduł jest skopiowany do trzech niezależnych usług (nie mają wspólnej
|
||||||
|
biblioteki). Rozjazd między kopiami objawiłby się dopiero na produkcji jako
|
||||||
|
„nie da się odszyfrować" — więc pilnujemy tego testem."""
|
||||||
|
root = pathlib.Path(__file__).resolve().parents[3]
|
||||||
|
copies = {svc: (root / "services" / svc / "app" / "link_crypto.py")
|
||||||
|
for svc in ("presentation", "logic", "data")}
|
||||||
|
missing = [svc for svc, path in copies.items() if not path.is_file()]
|
||||||
|
assert not missing, f"brak modułu w warstwach: {missing}"
|
||||||
|
contents = {svc: path.read_bytes() for svc, path in copies.items()}
|
||||||
|
assert len(set(contents.values())) == 1, (
|
||||||
|
"kopie link_crypto.py rozjechały się między warstwami: "
|
||||||
|
+ ", ".join(f"{svc}={len(body)}B" for svc, body in contents.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_names_are_two_distinct_keys():
|
||||||
|
"""Wymóg wprost: osobny klucz dla pary prezentacja-logika i logika-dane."""
|
||||||
|
assert ENV_PRESENTATION_LOGIC != ENV_LOGIC_DATA
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- klient też musi być fail-closed
|
||||||
|
#
|
||||||
|
# To wyszło dopiero z podsłuchu prawdziwego gniazda, nie z testów: przy kliencie
|
||||||
|
# BEZ klucza serwer owszem odmawiał, ale pytanie leciało po drodze otwartym
|
||||||
|
# tekstem. Odpowiedź była chroniona — zapytanie już nie.
|
||||||
|
|
||||||
|
def test_client_without_key_sends_nothing_when_encryption_required(monkeypatch):
|
||||||
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
class Tripwire:
|
||||||
|
def request(self, *args, **kwargs):
|
||||||
|
sent.append(args)
|
||||||
|
raise AssertionError("żądanie NIE powinno opuścić procesu")
|
||||||
|
|
||||||
|
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
||||||
|
link_crypto.call(Tripwire(), "POST", "http://logic/search",
|
||||||
|
payload={"value": "[Sat"}, link=None)
|
||||||
|
assert not sent, "treść zapytania wyszłaby jawnym tekstem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plaintext_still_allowed_in_dev(monkeypatch, link):
|
||||||
|
"""Bez tej flagi lokalne uruchomienie bez sekretów ma dalej działać."""
|
||||||
|
monkeypatch.delenv(link_crypto.ENV_REQUIRED, raising=False)
|
||||||
|
with TestClient(_app(None)) as client:
|
||||||
|
got = link_crypto.call_json(client, "POST", "http://testserver/search",
|
||||||
|
payload={"question": "Saturn"}, link=None)
|
||||||
|
assert got["interpretation"] == SECRET
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_refuses_to_start_without_key_when_required(monkeypatch):
|
||||||
|
"""Pod w CrashLoop widać od razu; usługę, która wstała i nie szyfruje — nie."""
|
||||||
|
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||||
|
monkeypatch.delenv(ENV_LOGIC_DATA, raising=False)
|
||||||
|
with pytest.raises(LinkError, match=ENV_LOGIC_DATA):
|
||||||
|
link_crypto.install(FastAPI(), ENV_LOGIC_DATA, "danych")
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""Dostawcy LLM (LOG-31) — bez wołania jakiegokolwiek prawdziwego modelu.
|
||||||
|
|
||||||
|
Transport podstawiamy przez httpx.MockTransport, więc testy są szybkie,
|
||||||
|
deterministyczne i nic nie wychodzi na zewnątrz.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.llm import factory
|
||||||
|
from app.llm.base import Completion, LLMError
|
||||||
|
from app.llm.providers import AnthropicProvider, ChatCompletionsProvider
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_client(handler):
|
||||||
|
"""Podmienia httpx.Client na wersję z transportem testowym."""
|
||||||
|
class _C(httpx.Client):
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
kw["transport"] = httpx.MockTransport(handler)
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
return _C
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chat_ok(monkeypatch):
|
||||||
|
def handler(request):
|
||||||
|
assert request.url.path.endswith("/chat/completions")
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"model": "test-model",
|
||||||
|
"choices": [{"message": {"content": "Horoskop testowy."}}],
|
||||||
|
"usage": {"prompt_tokens": 100, "completion_tokens": 50},
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- protokół chat/completions
|
||||||
|
|
||||||
|
def test_local_provider_generates(chat_ok):
|
||||||
|
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
|
||||||
|
out = p.generate("prompt", 500)
|
||||||
|
assert isinstance(out, Completion)
|
||||||
|
assert out.text == "Horoskop testowy."
|
||||||
|
assert out.usage["completion_tokens"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_provider_does_not_leave_lan(chat_ok):
|
||||||
|
p = ChatCompletionsProvider("local", "http://localhost:11434/v1", "m", leaves_lan=False)
|
||||||
|
assert p.generate("prompt", 100).leaves_lan is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_provider_marks_leaving_lan(chat_ok):
|
||||||
|
p = ChatCompletionsProvider("openai", "https://api.openai.com/v1", "m", "klucz",
|
||||||
|
leaves_lan=True)
|
||||||
|
assert p.generate("prompt", 100).leaves_lan is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_key_sent_only_when_set(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen["auth"] = request.headers.get("authorization")
|
||||||
|
return httpx.Response(200, json={"choices": [{"message": {"content": "x"}}]})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||||
|
assert seen["auth"] is None
|
||||||
|
ChatCompletionsProvider("openai", "http://x/v1", "m", "tajny").generate("p", 10)
|
||||||
|
assert seen["auth"] == "Bearer tajny"
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_error_becomes_readable_message(monkeypatch):
|
||||||
|
monkeypatch.setattr(httpx, "Client",
|
||||||
|
_mock_client(lambda r: httpx.Response(400, text="zly model")))
|
||||||
|
with pytest.raises(LLMError, match="400"):
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_response_reported(monkeypatch):
|
||||||
|
monkeypatch.setattr(httpx, "Client",
|
||||||
|
_mock_client(lambda r: httpx.Response(200, json={"nonsens": 1})))
|
||||||
|
with pytest.raises(LLMError, match="kształt"):
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retries_then_succeeds(monkeypatch):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] < 3:
|
||||||
|
return httpx.Response(429, text="za duzo")
|
||||||
|
return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
monkeypatch.setattr("app.llm.providers.time.sleep", lambda s: None) # bez czekania
|
||||||
|
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 10).text == "ok"
|
||||||
|
assert calls["n"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Anthropic
|
||||||
|
|
||||||
|
def test_anthropic_generates(monkeypatch):
|
||||||
|
def handler(request):
|
||||||
|
assert request.url.path.endswith("/v1/messages")
|
||||||
|
assert request.headers.get("x-api-key") == "klucz"
|
||||||
|
assert request.headers.get("anthropic-version")
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"model": "claude-x",
|
||||||
|
"content": [{"type": "text", "text": "Prognoza."}],
|
||||||
|
"usage": {"input_tokens": 10, "output_tokens": 5},
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
out = AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
|
||||||
|
assert out.text == "Prognoza." and out.leaves_lan is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_requires_key():
|
||||||
|
with pytest.raises(LLMError, match="ANTHROPIC_API_KEY"):
|
||||||
|
AnthropicProvider("https://api.anthropic.com", "m", "").generate("p", 10)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- fabryka
|
||||||
|
|
||||||
|
def test_default_provider_is_local(monkeypatch):
|
||||||
|
monkeypatch.delenv("LLM_PROVIDER", raising=False)
|
||||||
|
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||||
|
monkeypatch.delenv("LLM_BASE_URL", raising=False)
|
||||||
|
p = factory.build_provider()
|
||||||
|
assert p.name == "local"
|
||||||
|
# domyślnie NIC nie opuszcza sieci — prompt niesie opisy z baz (LOG-32)
|
||||||
|
assert p.leaves_lan is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_requires_key(monkeypatch):
|
||||||
|
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
|
# komunikat wskazuje ZMIENNĄ DO USTAWIENIA dla tego dostawcy, nie ogólne LLM_API_KEY
|
||||||
|
with pytest.raises(LLMError, match="OPENAI_API_KEY"):
|
||||||
|
factory.build_provider("openai")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_provider_rejected():
|
||||||
|
with pytest.raises(LLMError, match="Nieznany dostawca"):
|
||||||
|
factory.build_provider("bzdura")
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_overrides_model_and_url(monkeypatch):
|
||||||
|
monkeypatch.setenv("LLM_MODEL", "moj-model")
|
||||||
|
monkeypatch.setenv("LLM_BASE_URL", "http://serwer:8000/v1")
|
||||||
|
p = factory.build_provider("local")
|
||||||
|
assert p.model == "moj-model" and p.base_url == "http://serwer:8000/v1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------- konfiguracja per dostawca (regresja LOG-31)
|
||||||
|
# UI pozwala przelaczac dostawce przy kazdym zadaniu, wiec ustawienia JEDNEGO nie moga
|
||||||
|
# przeciekac na pozostalych. Wczesniej wspolne LLM_BASE_URL/LLM_MODEL kierowaly zadania
|
||||||
|
# do OpenAI na adres lokalnej Ollamy i prosily Anthropic o model llama.
|
||||||
|
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
for v in ("LLM_PROVIDER", "LLM_MODEL", "LLM_BASE_URL", "LLM_API_KEY",
|
||||||
|
"LOCAL_MODEL", "LOCAL_BASE_URL", "LOCAL_API_KEY",
|
||||||
|
"OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY",
|
||||||
|
"ANTHROPIC_MODEL", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
|
||||||
|
monkeypatch.delenv(v, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_config_does_not_leak_to_cloud(monkeypatch):
|
||||||
|
"""Sedno bledu: skonfigurowany model lokalny przejmowal zadania do chmury."""
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||||
|
monkeypatch.setenv("LLM_BASE_URL", "http://ollama:11434/v1") # konfiguracja lokalnego
|
||||||
|
monkeypatch.setenv("LLM_MODEL", "llama3.1:8b")
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||||
|
|
||||||
|
local = factory.build_provider("local")
|
||||||
|
assert local.base_url == "http://ollama:11434/v1" and local.model == "llama3.1:8b"
|
||||||
|
|
||||||
|
openai = factory.build_provider("openai")
|
||||||
|
assert openai.base_url == "https://api.openai.com/v1", "zadanie do OpenAI poszloby do Ollamy"
|
||||||
|
assert openai.model == "gpt-4o-mini", "OpenAI dostalby nazwe modelu llama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_specific_settings_win(monkeypatch):
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
|
||||||
|
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
|
||||||
|
p = factory.build_provider("anthropic")
|
||||||
|
assert p.model == "claude-opus-4-8" and p.api_key == "sk-ant"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_vars_apply_only_to_default_provider(monkeypatch):
|
||||||
|
"""Zgodnosc wstecz: wspolne LLM_* konfiguruja dostawce domyslnego i tylko jego."""
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "openai")
|
||||||
|
monkeypatch.setenv("LLM_API_KEY", "sk-generic")
|
||||||
|
monkeypatch.setenv("LLM_MODEL", "gpt-4o")
|
||||||
|
assert factory.build_provider("openai").model == "gpt-4o"
|
||||||
|
assert factory.build_provider("local").model == "llama3.1:8b" # nie dziedziczy
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_without_key_is_rejected_clearly(monkeypatch):
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("LLM_PROVIDER", "local")
|
||||||
|
for name in ("openai", "anthropic"):
|
||||||
|
with pytest.raises(LLMError, match=f"{name.upper()}_API_KEY"):
|
||||||
|
factory.build_provider(name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_needs_no_key(monkeypatch):
|
||||||
|
_clear(monkeypatch)
|
||||||
|
assert factory.build_provider("local").api_key == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- pusta odpowiedz modelu (cicha awaria)
|
||||||
|
# Regresja: model potrafi oddac pusta tresc (prompt zjadl caly kontekst ->
|
||||||
|
# finish_reason=length, completion_tokens=0). Wczesniej generate() zwracalo pusty
|
||||||
|
# tekst BEZ bledu, widok nic nie renderowal i uzytkownik dostawal pusta strone
|
||||||
|
# bez zadnego wyjasnienia. Pusta odpowiedz MUSI byc bledem.
|
||||||
|
|
||||||
|
def test_empty_completion_raises_instead_of_silent_blank(monkeypatch):
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
||||||
|
"model": "llama3.1:8b",
|
||||||
|
"choices": [{"message": {"content": ""}, "finish_reason": "length"}],
|
||||||
|
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
|
||||||
|
})))
|
||||||
|
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_completion_explains_context_window(monkeypatch):
|
||||||
|
"""Komunikat ma prowadzic do przyczyny, a nie tylko stwierdzac fakt."""
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": " "}, "finish_reason": "length"}],
|
||||||
|
"usage": {"prompt_tokens": 8000, "completion_tokens": 0},
|
||||||
|
})))
|
||||||
|
with pytest.raises(LLMError) as ei:
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 2000)
|
||||||
|
msg = str(ei.value)
|
||||||
|
assert "kontekstu" in msg and "budżet" in msg
|
||||||
|
assert "powód zakończenia: length" in msg # diagnostyka w tresci bledu
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitespace_only_is_treated_as_empty(monkeypatch):
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": "\n\n \t "}}],
|
||||||
|
})))
|
||||||
|
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_empty_completion_raises(monkeypatch):
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
||||||
|
"model": "claude-x", "content": [], "stop_reason": "max_tokens",
|
||||||
|
"usage": {"input_tokens": 9000, "output_tokens": 0},
|
||||||
|
})))
|
||||||
|
with pytest.raises(LLMError, match="nie zwrócił żadnej treści"):
|
||||||
|
AnthropicProvider("https://api.anthropic.com", "claude-x", "klucz").generate("p", 100)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normal_response_still_passes(monkeypatch):
|
||||||
|
"""Straznik nie moze psuc poprawnej odpowiedzi."""
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(lambda r: httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": "Horoskop."}, "finish_reason": "stop"}],
|
||||||
|
"usage": {"prompt_tokens": 100, "completion_tokens": 20},
|
||||||
|
})))
|
||||||
|
assert ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 100).text == "Horoskop."
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------- kontynuacja: horoskop MA powstac zawsze
|
||||||
|
# Sedno wymagania: niezaleznie od objetosci promptu i limitu wyjscia, pelna tresc
|
||||||
|
# ma wrocic do uzytkownika. Model urwany na max_tokens jest proszony o dokonczenie
|
||||||
|
# w ramach tej samej rozmowy, a kawalki sa sklejane.
|
||||||
|
|
||||||
|
def _scripted(responses):
|
||||||
|
"""Transport oddajacy kolejne odpowiedzi z listy (po jednej na ture)."""
|
||||||
|
seq = list(responses)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen.append(request)
|
||||||
|
return httpx.Response(200, json=seq.pop(0) if seq else seq_last)
|
||||||
|
|
||||||
|
seq_last = responses[-1]
|
||||||
|
return handler, seen
|
||||||
|
|
||||||
|
|
||||||
|
def _chat(text, finish):
|
||||||
|
return {"choices": [{"message": {"content": text}, "finish_reason": finish}],
|
||||||
|
"usage": {"completion_tokens": 10}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncated_answer_is_continued_and_joined(monkeypatch):
|
||||||
|
handler, seen = _scripted([
|
||||||
|
_chat("Czesc pierwsza.", "length"),
|
||||||
|
_chat("Czesc druga. KONIEC", "stop"),
|
||||||
|
])
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
|
||||||
|
assert "Czesc pierwsza." in out.text and "Czesc druga." in out.text
|
||||||
|
assert "KONIEC" not in out.text # znacznik nie trafia do horoskopu
|
||||||
|
assert out.usage["turns"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_continuation_asks_in_same_conversation(monkeypatch):
|
||||||
|
"""Kontynuacja musi isc jako kolejna tura rozmowy, a ostatnia wiadomosc MUSI
|
||||||
|
byc od uzytkownika — Claude odrzuca prefill w turze asystenta (400)."""
|
||||||
|
handler, seen = _scripted([_chat("Poczatek", "length"), _chat("Reszta", "stop")])
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate("prompt", 40000)
|
||||||
|
msgs = json.loads(seen[1].content)["messages"]
|
||||||
|
assert msgs[-1]["role"] == "user", "ostatnia wiadomosc nie moze byc prefillem asystenta"
|
||||||
|
assert msgs[1]["role"] == "assistant" and "Poczatek" in msgs[1]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_thinking_only_turn_is_continued(monkeypatch):
|
||||||
|
"""DOKLADNIE zgloszony objaw: cala tura poszla na myslenie, tekst pusty.
|
||||||
|
Wczesniej konczylo sie to pusta strona; teraz pytamy o tresc dalej."""
|
||||||
|
seq = [
|
||||||
|
{"content": [{"type": "thinking", "thinking": ""}], "stop_reason": "max_tokens",
|
||||||
|
"usage": {"output_tokens": 2000}},
|
||||||
|
{"content": [{"type": "text", "text": "Horoskop urodzeniowy..."}],
|
||||||
|
"stop_reason": "end_turn", "usage": {"output_tokens": 500}},
|
||||||
|
]
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
out = AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 40000)
|
||||||
|
assert out.text == "Horoskop urodzeniowy..."
|
||||||
|
assert out.usage["turns"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_sends_thinking_config(monkeypatch):
|
||||||
|
"""Bez jawnego `thinking` Sonnet 5 wlacza myslenie sam — konfigurujemy to wprost."""
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen.append(json.loads(request.content))
|
||||||
|
return httpx.Response(200, json={"content": [{"type": "text", "text": "ok"}],
|
||||||
|
"stop_reason": "end_turn"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
monkeypatch.delenv("ANTHROPIC_THINKING", raising=False)
|
||||||
|
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
|
||||||
|
assert seen[0]["thinking"] == {"type": "adaptive"}
|
||||||
|
|
||||||
|
seen.clear()
|
||||||
|
monkeypatch.setenv("ANTHROPIC_THINKING", "off")
|
||||||
|
AnthropicProvider("https://api.anthropic.com", "claude-opus-4-8", "k").generate("p", 5000)
|
||||||
|
assert seen[0]["thinking"] == {"type": "disabled"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_answer_does_not_loop(monkeypatch):
|
||||||
|
"""Straznik nie moze mnozyc zapytan, gdy model skonczyl normalnie."""
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
calls["n"] += 1
|
||||||
|
return httpx.Response(200, json=_chat("Gotowe.", "stop"))
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 40000)
|
||||||
|
assert out.text == "Gotowe." and calls["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- wybor modelu przez uzytkownika (UI)
|
||||||
|
|
||||||
|
def test_model_from_request_wins_over_config(monkeypatch):
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-opus-4-8")
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
|
||||||
|
p = factory.build_provider("anthropic", "claude-fable-5")
|
||||||
|
assert p.model == "claude-fable-5", "wybor z UI musi wygrac nad konfiguracja"
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_model_falls_back_to_configured_default(monkeypatch):
|
||||||
|
_clear(monkeypatch)
|
||||||
|
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-sonnet-5")
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
|
||||||
|
assert factory.build_provider("anthropic", " ").model == "claude-sonnet-5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_model_needs_no_api_key(monkeypatch):
|
||||||
|
"""Budzet promptu zalezy od okna kontekstu modelu — nie moze wymagac klucza.
|
||||||
|
|
||||||
|
Wczesniej liczenie budzetu szlo przez build_provider(), ktory bez klucza
|
||||||
|
rzuca bledem, wiec „maksymalny kontekst" cicho spadal do wartosci zapasowej.
|
||||||
|
"""
|
||||||
|
_clear(monkeypatch)
|
||||||
|
provider, model = factory.resolve_model("anthropic", "claude-haiku-4-5")
|
||||||
|
assert (provider, model) == ("anthropic", "claude-haiku-4-5")
|
||||||
|
with pytest.raises(LLMError): # samo zbudowanie nadal wymaga klucza
|
||||||
|
factory.build_provider("anthropic", "claude-haiku-4-5")
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_budget_differs_between_models(monkeypatch):
|
||||||
|
"""Sedno funkcji: wieksze okno = wiekszy budzet promptu."""
|
||||||
|
from app.llm.limits import prompt_token_budget
|
||||||
|
_clear(monkeypatch)
|
||||||
|
opus = prompt_token_budget(*factory.resolve_model("anthropic", "claude-opus-4-8"))
|
||||||
|
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
|
||||||
|
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
|
||||||
|
assert opus > haiku > local > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_turn_budget_counts_produced_not_requested(monkeypatch):
|
||||||
|
"""Regresja: odejmowanie ZAMOWIONEGO limitu tury zamiast wyprodukowanych
|
||||||
|
tokenow konczylo petle po jednej turze — urwany fragment wracal jako calosc."""
|
||||||
|
seq = [_chat("Fragment 1. ", "length"), _chat("Fragment 2. ", "length"),
|
||||||
|
_chat("Zakonczenie. KONIEC", "stop")]
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||||
|
# budzet 8000 < TURN_TOKENS_CAP: przy starej logice byla dokladnie jedna tura
|
||||||
|
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 8000)
|
||||||
|
assert out.usage["turns"] == 3, "urwana odpowiedz musi byc kontynuowana"
|
||||||
|
assert "Fragment 1." in out.text and "Zakonczenie." in out.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_reports_progress_events(monkeypatch):
|
||||||
|
"""Log w UI ma pokazywac RZECZYWISTE tury, nie udawany pasek postepu."""
|
||||||
|
seq = [_chat("Czesc. ", "length"), _chat("Reszta. KONIEC", "stop")]
|
||||||
|
monkeypatch.setattr(httpx, "Client", _mock_client(
|
||||||
|
lambda r: httpx.Response(200, json=seq.pop(0) if seq else seq[-1])))
|
||||||
|
events = []
|
||||||
|
ChatCompletionsProvider("local", "http://x/v1", "m").generate(
|
||||||
|
"p", 8000, on_event=events.append)
|
||||||
|
kinds = [e["type"] for e in events]
|
||||||
|
assert kinds.count("turn_start") == 2 and kinds.count("turn_end") == 2
|
||||||
|
assert kinds[-1] == "generated"
|
||||||
|
assert any("urwana" in e["message"] for e in events if e["type"] == "turn_end")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Lots / punkty arabskie (LOG-08).
|
||||||
|
|
||||||
|
Wyrocznie z notes3 (astro-seek, horoskop referencyjny 30.04.1984, urodzenie DZIENNE):
|
||||||
|
- Fortuna wprost w tabeli obiektów: Cancer 12°35'24" = 102.5900°
|
||||||
|
- Ducha (Spirit) potwierdza jego antyscja: Taurus 28°14' -> Spirit = 180 − 58.2333
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.formats import sign_index
|
||||||
|
from app.engine.lots import LOT_DEFS, compute_lots
|
||||||
|
|
||||||
|
# natalne długości horoskopu referencyjnego
|
||||||
|
NATAL = {"Asc": 112.1786, "Sun": 40.1392, "Moon": 30.5514, "Mercury": 27.3839,
|
||||||
|
"Venus": 27.6769, "Mars": 234.5411, "Jupiter": 282.9617, "Saturn": 223.3108}
|
||||||
|
|
||||||
|
FORTUNE_REF = 102.5900 # Cancer 12°35'24"
|
||||||
|
SPIRIT_REF = 180.0 - 58.2333 # z antyscji Taurus 28°14'
|
||||||
|
|
||||||
|
|
||||||
|
def _by_name(lots):
|
||||||
|
return {lot["name"]: lot["longitude"] for lot in lots}
|
||||||
|
|
||||||
|
|
||||||
|
def _arcmin(a, b):
|
||||||
|
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_matches_astroseek():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
assert _arcmin(lots["Fortune"], FORTUNE_REF) < 2.0
|
||||||
|
assert sign_index(lots["Fortune"]) == 3 # Cancer
|
||||||
|
|
||||||
|
|
||||||
|
def test_spirit_matches_antiscia_reference():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
assert _arcmin(lots["Spirit"], SPIRIT_REF) < 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_and_spirit_reverse_at_night():
|
||||||
|
day = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
night = _by_name(compute_lots(NATAL, is_day=False))
|
||||||
|
# nocą formuła się odwraca: Fortuna nocna = Duch dzienny (i odwrotnie)
|
||||||
|
assert _arcmin(night["Fortune"], day["Spirit"]) < 0.01
|
||||||
|
assert _arcmin(night["Spirit"], day["Fortune"]) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_seven_hermetic_lots_present():
|
||||||
|
lots = compute_lots(NATAL, is_day=True)
|
||||||
|
assert [lot["name"] for lot in lots] == [d[0] for d in LOT_DEFS]
|
||||||
|
assert len(lots) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_derived_lots_use_fortune_and_spirit():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
# Necessity = Asc + Fortune − Mercury
|
||||||
|
expected = (NATAL["Asc"] + lots["Fortune"] - NATAL["Mercury"]) % 360.0
|
||||||
|
assert _arcmin(lots["Necessity"], expected) < 0.01
|
||||||
|
# Eros = Asc + Venus − Spirit
|
||||||
|
expected_eros = (NATAL["Asc"] + NATAL["Venus"] - lots["Spirit"]) % 360.0
|
||||||
|
assert _arcmin(lots["Eros"], expected_eros) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_sign_method_lands_on_sign_start():
|
||||||
|
lots = compute_lots(NATAL, is_day=True, method="sign")
|
||||||
|
for lot in lots:
|
||||||
|
assert lot["longitude"] % 30.0 == 0.0 # 0° wyliczonego znaku
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_method_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
compute_lots(NATAL, is_day=True, method="bzdura")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Testy punktów wirtualnych (LOG-02): mean Node, mean Lilith.
|
||||||
|
|
||||||
|
Referencje dla 30.04.1984 07:35 UT:
|
||||||
|
- astro-seek (notes3): North Node (M) = Gem 8°09'24" = 68.1567°
|
||||||
|
- wyrocznia swisseph (MEAN_NODE / MEAN_APOG, tryb Moshiera):
|
||||||
|
NN = 68.1569°, Lilith = 345.6840°
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_arcmin(a: float, b: float) -> float:
|
||||||
|
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def by_name(own_engine, reference_moment):
|
||||||
|
return {p.name: p for p in own_engine.positions(reference_moment)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mean_node_matches_references(by_name):
|
||||||
|
nn = by_name["North Node"]
|
||||||
|
assert _delta_arcmin(nn.longitude, 68.1567) < 2.0 # astro-seek
|
||||||
|
assert _delta_arcmin(nn.longitude, 68.1569) < 2.0 # swisseph
|
||||||
|
assert nn.sign == "Gemini"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nodes_always_retrograde_and_opposed(by_name):
|
||||||
|
nn, sn = by_name["North Node"], by_name["South Node"]
|
||||||
|
assert nn.retrograde and sn.retrograde # mean node zawsze Rx
|
||||||
|
assert _delta_arcmin(sn.longitude, norm360(nn.longitude + 180.0)) < 0.01
|
||||||
|
assert abs(nn.speed - sn.speed) < 1e-9 # ta sama prędkość
|
||||||
|
|
||||||
|
|
||||||
|
def test_mean_lilith_matches_swisseph(by_name):
|
||||||
|
li = by_name["Lilith"]
|
||||||
|
assert _delta_arcmin(li.longitude, 345.6840) < 3.0 # wyrocznia swisseph
|
||||||
|
assert li.sign == "Pisces"
|
||||||
|
assert li.speed > 0 and not li.retrograde # mean Lilith zawsze direct
|
||||||
|
|
||||||
|
|
||||||
|
def test_points_join_houses_and_chart(own_engine, reference_moment):
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
|
||||||
|
chart = build_chart(own_engine, reference_moment)
|
||||||
|
by = {p["name"]: p for p in chart["positions"]}
|
||||||
|
# NN w Gem -> 12. dom Whole Sign (Asc w Raku); zgodnie z tabelą astro-seek w notes3
|
||||||
|
assert by["North Node"]["house"] == 12
|
||||||
|
assert by["Lilith"]["house"] == 9 # Pis -> 9. dom
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Profekcje (LOG-10) — walidacja względem tabeli astro-seek z notes3."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.engine.profections import DOMICILE_RULERS, age_at, profected_sign, profection_rows
|
||||||
|
|
||||||
|
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||||
|
NATAL = {"Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55}
|
||||||
|
|
||||||
|
# wiek -> (profektowany Asc, Władca Roku) — tabela referencyjna notes3
|
||||||
|
REFERENCE = {
|
||||||
|
0: ("Cancer", "Moon"), 1: ("Leo", "Sun"), 2: ("Virgo", "Mercury"),
|
||||||
|
3: ("Libra", "Venus"), 4: ("Scorpio", "Mars"), 5: ("Sagittarius", "Jupiter"),
|
||||||
|
6: ("Capricorn", "Saturn"), 7: ("Aquarius", "Saturn"), 8: ("Pisces", "Jupiter"),
|
||||||
|
9: ("Aries", "Mars"), 10: ("Taurus", "Venus"), 11: ("Gemini", "Mercury"),
|
||||||
|
12: ("Cancer", "Moon"), 40: ("Scorpio", "Mars"), 41: ("Sagittarius", "Jupiter"),
|
||||||
|
42: ("Capricorn", "Saturn"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_profections_match_astroseek_table():
|
||||||
|
rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 43)}
|
||||||
|
for age, (asc, lord) in REFERENCE.items():
|
||||||
|
assert rows[age]["profected_asc"] == asc, f"wiek {age}"
|
||||||
|
assert rows[age]["lord_of_year"] == lord, f"wiek {age}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_profected_secondary_points_match_reference():
|
||||||
|
rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 3)}
|
||||||
|
# notes3: wiek 0 -> MC Pis, Sun Tau, Moon Tau; wiek 1 -> MC Ari, Sun Gem
|
||||||
|
assert rows[0]["MC"] == "Pisces" and rows[0]["Sun"] == "Taurus"
|
||||||
|
assert rows[1]["MC"] == "Aries" and rows[1]["Sun"] == "Gemini"
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dates_are_birthdays():
|
||||||
|
rows = profection_rows(NATAL, BIRTH, 40, 3)
|
||||||
|
assert [r["from"] for r in rows] == ["2024-04-30", "2025-04-30", "2026-04-30"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_at_boundaries():
|
||||||
|
assert age_at(BIRTH, dt.datetime(2026, 4, 29, tzinfo=dt.timezone.utc)) == 41
|
||||||
|
assert age_at(BIRTH, dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc)) == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_rulers_cover_all_signs():
|
||||||
|
assert len(DOMICILE_RULERS) == 12
|
||||||
|
assert profected_sign(112.18, 12) == "Cancer" # pełny cykl wraca
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""Generator promptów (LOG-29) i budżetowanie (LOG-30).
|
||||||
|
|
||||||
|
Testy nie wołają żadnego modelu — sprawdzają skład promptu i niezmienniki redukcji.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.prompt import (
|
||||||
|
BUDGETS,
|
||||||
|
build_natal_prompt,
|
||||||
|
build_period_prompt,
|
||||||
|
natal_indications,
|
||||||
|
period_indications,
|
||||||
|
reduce_indications,
|
||||||
|
)
|
||||||
|
|
||||||
|
CHART = {
|
||||||
|
"zodiac": "tropical",
|
||||||
|
"house_system": "whole_sign",
|
||||||
|
"sect": "day",
|
||||||
|
"positions": [
|
||||||
|
{"name": "Sun", "sign": "Taurus", "in_sign": "Tau 10°12'37\"", "house": 11, "direction": "D"},
|
||||||
|
{"name": "Moon", "sign": "Aries", "in_sign": "Ari 7°48'40\"", "house": 10, "direction": "D"},
|
||||||
|
],
|
||||||
|
"angles": {"Asc": {"name": "Asc", "in_sign": "Can 16°00'54\""},
|
||||||
|
"MC": {"name": "MC", "in_sign": "Pis 25°50'50\""}},
|
||||||
|
"lots": [{"name": "Fortune", "in_sign": "Can 7°15'14\"", "house": 1,
|
||||||
|
"formula": "Asc + Moon − Sun"}],
|
||||||
|
"aspects": [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.34, "as": "A"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _report(n_samples=3, score=2.0):
|
||||||
|
return {"objects": [{
|
||||||
|
"object": "Sun", "sign": "Taurus", "house": 11,
|
||||||
|
"facets": [{
|
||||||
|
"type": "sign", "label": "w znaku Taurus", "score": score,
|
||||||
|
"samples": [{"significator": f"[Su in [Tau {i}", "expanded": f"Sun in Taurus {i}",
|
||||||
|
"effect": f"efekt numer {i}"} for i in range(n_samples)],
|
||||||
|
}],
|
||||||
|
}]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- budżetowanie
|
||||||
|
|
||||||
|
def test_budget_limits_prompt_size():
|
||||||
|
# opisy MUSZĄ być różne — identyczne zlałoby grupowanie w jedną pozycję
|
||||||
|
big = {"objects": [{
|
||||||
|
"object": "Sun", "sign": "Taurus", "house": 11,
|
||||||
|
"facets": [{"type": "sign", "label": "w znaku Taurus", "score": 1.0,
|
||||||
|
"samples": [{"significator": f"[Su {i}", "expanded": f"Sun {i}",
|
||||||
|
"effect": f"opis {i} " + "x" * 200} for i in range(500)]}],
|
||||||
|
}]}
|
||||||
|
out = build_natal_prompt(CHART, big, budget="concise")
|
||||||
|
assert out["stats"]["chars"] <= BUDGETS["concise"] * 1.1 # z marginesem na sekcje stałe
|
||||||
|
assert out["stats"]["omitted"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_bigger_budget_includes_more():
|
||||||
|
big = _report(n_samples=300, score=1.0)
|
||||||
|
small = build_natal_prompt(CHART, big, budget="concise")["stats"]
|
||||||
|
large = build_natal_prompt(CHART, big, budget="extensive")["stats"]
|
||||||
|
assert large["included"] > small["included"]
|
||||||
|
assert large["omitted"] < small["omitted"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouping_merges_identical_effects():
|
||||||
|
items = [
|
||||||
|
{"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0,
|
||||||
|
"significator": "Sun in Taurus", "effect": "ten sam opis"},
|
||||||
|
{"context": "Sun — w znaku", "sort_key": ("Sun", "a"), "score": 1.0,
|
||||||
|
"significator": "Sun in Taurus (inny zapis)", "effect": "Ten Sam Opis "},
|
||||||
|
]
|
||||||
|
chosen, stats = reduce_indications(items, 10_000)
|
||||||
|
assert len(chosen) == 1 # zlane w jedno
|
||||||
|
assert chosen[0]["count"] == 2 # z licznikiem wystąpień
|
||||||
|
assert stats["after_grouping"] == 1
|
||||||
|
assert stats["deduplicated"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorted_by_score_desc():
|
||||||
|
items = [
|
||||||
|
{"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i),
|
||||||
|
"significator": f"s{i}", "effect": f"e{i}"} for i in range(5)
|
||||||
|
]
|
||||||
|
chosen, _ = reduce_indications(items, 10_000)
|
||||||
|
scores = [c["score"] for c in chosen]
|
||||||
|
assert scores == sorted(scores, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weakest_are_dropped_first():
|
||||||
|
items = [
|
||||||
|
{"context": f"c{i}", "sort_key": (f"c{i}", ""), "score": float(i),
|
||||||
|
"significator": f"s{i}", "effect": "opis " * 20} for i in range(20)
|
||||||
|
]
|
||||||
|
chosen, stats = reduce_indications(items, 400)
|
||||||
|
assert stats["omitted"] > 0
|
||||||
|
# to, co weszło, ma wagę nie niższą niż to, co odpadło
|
||||||
|
assert stats["min_score_included"] >= stats["max_score_omitted"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_effects_are_shortened():
|
||||||
|
items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0,
|
||||||
|
"significator": "s", "effect": "x" * 1000}]
|
||||||
|
chosen, stats = reduce_indications(items, 10_000)
|
||||||
|
assert stats["shortened"] == 1
|
||||||
|
assert "skrócono" in chosen[0]["effect"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_truncates_mid_indication():
|
||||||
|
items = [{"context": "c", "sort_key": ("c", ""), "score": 1.0,
|
||||||
|
"significator": "s", "effect": "opis " * 50} for _ in range(10)]
|
||||||
|
chosen, _ = reduce_indications(items, 200)
|
||||||
|
assert chosen # zawsze co najmniej jedno całe
|
||||||
|
for c in chosen:
|
||||||
|
assert c["effect"].endswith(("opis", "[…skrócono]")) # nie urwane w pół słowa
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_budget_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
build_natal_prompt(CHART, _report(), budget="gigantyczny")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- skład promptu
|
||||||
|
|
||||||
|
def test_natal_prompt_contains_required_sections():
|
||||||
|
p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"]
|
||||||
|
for section in ["# ZADANIE", "# DANE HOROSKOPU", "# WSKAZANIA Z BAZ",
|
||||||
|
"# JAK MA WYGLĄDAĆ ODPOWIEDŹ", "# ZASTRZEŻENIE"]:
|
||||||
|
assert section in p
|
||||||
|
assert "HOROSKOPU\nURODZENIOWEGO" in p or "URODZENIOWEGO" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_natal_prompt_carries_chart_data():
|
||||||
|
p = build_natal_prompt(CHART, _report(), moment_label="1984-04-30 09:20 UTC")["prompt"]
|
||||||
|
assert "Tau 10°12'37\"" in p and "dom 11" in p # pozycja + dom
|
||||||
|
assert "Can 16°00'54\"" in p # Asc
|
||||||
|
assert "Fortune" in p # Lots
|
||||||
|
assert "Sun conjunction Moon" in p or "conjunction" in p
|
||||||
|
assert "sekta: dzienna" in p
|
||||||
|
assert "1984-04-30 09:20 UTC" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_demands_citing_significators():
|
||||||
|
p = build_natal_prompt(CHART, _report())["prompt"]
|
||||||
|
assert "Teza bez wskazania jest niedopuszczalna" in p
|
||||||
|
assert "Nie zmyślaj" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_has_disclaimer():
|
||||||
|
p = build_natal_prompt(CHART, _report())["prompt"]
|
||||||
|
assert "nie stanowi porady" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_is_deterministic():
|
||||||
|
a = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"]
|
||||||
|
b = build_natal_prompt(CHART, _report(n_samples=50), budget="medium")["prompt"]
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_omission_is_reported_in_prompt():
|
||||||
|
out = build_natal_prompt(CHART, _report(n_samples=400, score=1.0), budget="concise")
|
||||||
|
assert out["stats"]["omitted"] > 0
|
||||||
|
assert "pominięto" in out["prompt"] # użytkownik/model wie, że coś odpadło
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_report_still_builds_prompt():
|
||||||
|
out = build_natal_prompt(CHART, {"objects": []})
|
||||||
|
assert out["stats"]["included"] == 0
|
||||||
|
assert "brak trafień" in out["prompt"]
|
||||||
|
assert "# DANE HOROSKOPU" in out["prompt"]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- okresowy
|
||||||
|
|
||||||
|
EVENTS = [
|
||||||
|
{"technique": "profection", "significator": "Lord of Year: Mars", "start": "2026-04-30",
|
||||||
|
"exact": "2026-04-30", "end": "2027-04-30", "interpretations_count": 2,
|
||||||
|
"interpretations": [{"significator": "[Ma", "expanded": "Mars", "effect": "opis marsowy"}]},
|
||||||
|
{"technique": "solar_arc", "significator": "Sun conj Saturn", "start": "2026-01-01",
|
||||||
|
"exact": "2026-06-15", "end": "2026-12-31", "interpretations_count": 1,
|
||||||
|
"interpretations": [{"significator": "[Su [conj [Sa", "expanded": "Sun conjunction Saturn",
|
||||||
|
"effect": "opis saturniczny"}]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_prompt_has_dates_and_timeline():
|
||||||
|
out = build_period_prompt(CHART, EVENTS, "2026-01-01", "2027-01-01")
|
||||||
|
p = out["prompt"]
|
||||||
|
assert "2026-01-01 — 2027-01-01" in p
|
||||||
|
assert "# OŚ CZASU" in p
|
||||||
|
assert "profection" in p and "solar_arc" in p
|
||||||
|
assert "2026-06-15" in p # data dokładna zdarzenia
|
||||||
|
assert "CHRONOLOGICZNIE" in p
|
||||||
|
assert out["stats"]["events"] == 2
|
||||||
|
assert out["profile"] == "period"
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_prompt_keeps_timeline_without_interpretations():
|
||||||
|
"""Padnięta warstwa danych zabiera wskazania, ale NIE oś czasu — ta jest czysto
|
||||||
|
obliczeniowa i bez niej prognoza okresowa jest bezużyteczna."""
|
||||||
|
bare = [{k: v for k, v in ev.items()
|
||||||
|
if k not in ("interpretations", "interpretations_count")} for ev in EVENTS]
|
||||||
|
out = build_period_prompt(CHART, bare, "2026-01-01", "2027-01-01")
|
||||||
|
assert out["stats"]["events"] == 2
|
||||||
|
assert "profection" in out["prompt"] and "2026-06-15" in out["prompt"]
|
||||||
|
assert out["stats"]["included"] == 0 # brak wskazań, ale oś czasu jest
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_indications_weight_by_hit_count():
|
||||||
|
items = period_indications(EVENTS)
|
||||||
|
assert [i["score"] for i in items] == [2.0, 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_natal_indications_flatten_facets():
|
||||||
|
items = natal_indications(_report(n_samples=4))
|
||||||
|
assert len(items) == 4
|
||||||
|
assert all(i["context"].startswith("Sun —") for i in items)
|
||||||
|
assert all(i["score"] == 2.0 for i in items)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Solar / Lunar Return (LOG-12) — samospójność i sensowność dat."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.returns import find_return
|
||||||
|
|
||||||
|
|
||||||
|
def _lon(engine, body, when):
|
||||||
|
return engine.positions(ChartMoment(when_utc=when), [body])[0].longitude
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_arcmin(a, b):
|
||||||
|
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_return_hits_natal_sun(own_engine, reference_moment):
|
||||||
|
natal_sun = _lon(own_engine, "Sun", reference_moment.when_utc)
|
||||||
|
# solariusz na 42. urodziny (2026)
|
||||||
|
hit = find_return(own_engine, "solar", reference_moment,
|
||||||
|
dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc))
|
||||||
|
assert hit is not None
|
||||||
|
assert _delta_arcmin(_lon(own_engine, "Sun", hit), natal_sun) < 0.5
|
||||||
|
assert hit.month == 4 and hit.year == 2026 # w okolicy urodzin
|
||||||
|
|
||||||
|
|
||||||
|
def test_lunar_return_hits_natal_moon(own_engine, reference_moment):
|
||||||
|
natal_moon = _lon(own_engine, "Moon", reference_moment.when_utc)
|
||||||
|
hit = find_return(own_engine, "lunar", reference_moment,
|
||||||
|
dt.datetime(1984, 5, 27, tzinfo=dt.timezone.utc))
|
||||||
|
assert hit is not None
|
||||||
|
assert _delta_arcmin(_lon(own_engine, "Moon", hit), natal_moon) < 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_return_near_birth_is_close_to_birth(own_engine, reference_moment):
|
||||||
|
# powrót szukany wokół samych urodzin = ~moment urodzenia
|
||||||
|
hit = find_return(own_engine, "solar", reference_moment, reference_moment.when_utc)
|
||||||
|
assert hit is not None
|
||||||
|
assert abs(hit - reference_moment.when_utc) < dt.timedelta(days=1)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Testy punktacji siły faset (LOG-21)."""
|
||||||
|
from app.scoring import score_facet
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_and_house_base():
|
||||||
|
assert score_facet({"type": "sign"}) == 5.0
|
||||||
|
assert score_facet({"type": "house"}) == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_tighter_aspect_scores_higher():
|
||||||
|
tight = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 0.0, "allowed": 10.0})
|
||||||
|
loose = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 9.0, "allowed": 10.0})
|
||||||
|
assert tight > loose
|
||||||
|
assert tight == 12.0 # 6.0 * 1.0 * (1 + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conjunction_beats_sextile_at_same_orb():
|
||||||
|
conj = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 2.0, "allowed": 10.0})
|
||||||
|
sext = score_facet({"type": "aspect", "aspect": "sextile", "orb": 2.0, "allowed": 10.0})
|
||||||
|
assert conj > sext
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Token międzywarstwowy (LOG-32).
|
||||||
|
|
||||||
|
Warstwa logiczna oddaje treść baz, więc musi odrzucać żądania z pominięciem UI.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
|
||||||
|
TOKEN = "tajny-token-testowy"
|
||||||
|
|
||||||
|
|
||||||
|
def _app() -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
security.install(app, "testowa")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.get("/secret")
|
||||||
|
def secret():
|
||||||
|
return {"rows": ["treść z bazy"]}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def guarded(monkeypatch):
|
||||||
|
monkeypatch.setenv("INTERNAL_TOKEN", TOKEN)
|
||||||
|
return TestClient(_app())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def open_app(monkeypatch):
|
||||||
|
monkeypatch.delenv("INTERNAL_TOKEN", raising=False)
|
||||||
|
return TestClient(_app())
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_request_without_token(guarded):
|
||||||
|
assert guarded.get("/secret").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_wrong_token(guarded):
|
||||||
|
r = guarded.get("/secret", headers={security.HEADER: "zly"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert "treść z bazy" not in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepts_correct_token(guarded):
|
||||||
|
r = guarded.get("/secret", headers={security.HEADER: TOKEN})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["rows"] == ["treść z bazy"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_stays_public(guarded):
|
||||||
|
"""Sonda k8s nie zna tokenu — /health musi działać bez niego."""
|
||||||
|
assert guarded.get("/health").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_when_token_unset(open_app):
|
||||||
|
"""Brak konfiguracji = zgodność wstecz (dev), nie blokada."""
|
||||||
|
assert not security.enabled()
|
||||||
|
assert open_app.get("/secret").status_code == 200
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Testy mostu obliczenia → sygnifikatory → wyszukiwanie (bez efemeryd/HTTP)."""
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS
|
||||||
|
from app.significators import PLANET_ABBR, _ordinal, build_report
|
||||||
|
|
||||||
|
|
||||||
|
class FakeData:
|
||||||
|
def __init__(self, rows_by_value: dict) -> None:
|
||||||
|
self.rows_by_value = rows_by_value
|
||||||
|
|
||||||
|
def search(self, key, value, exact, limit, fields=None) -> dict:
|
||||||
|
rows = self.rows_by_value.get(value, [])
|
||||||
|
return {"provider": "fake", "total": len(rows), "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_and_house_facets():
|
||||||
|
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
|
||||||
|
data = FakeData({"[Su": [
|
||||||
|
{"significator": "[Su in [Tau", "actioneffect": "efekt znak"}, # faseta znak
|
||||||
|
{"significator": "[Su in 11th H.", "actioneffect": "efekt dom"}, # faseta dom
|
||||||
|
{"significator": "[Su in [Vir", "actioneffect": "inny"}, # żadna
|
||||||
|
{"significator": "[Su in [Tau", "actioneffect": "nan"}, # szum
|
||||||
|
]})
|
||||||
|
item = build_report(positions, data)["objects"][0]
|
||||||
|
assert item["object"] == "Sun" and item["house"] == 11
|
||||||
|
facets = {f["type"]: f for f in item["facets"]}
|
||||||
|
assert facets["sign"]["count"] == 1
|
||||||
|
assert facets["sign"]["samples"][0]["effect"] == "efekt znak"
|
||||||
|
assert facets["sign"]["samples"][0]["expanded"] == "Sun in Taurus" # rozwinięcie skrótu
|
||||||
|
assert facets["house"]["count"] == 1
|
||||||
|
assert facets["house"]["token"] == "11th H."
|
||||||
|
assert facets["house"]["samples"][0]["effect"] == "efekt dom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_house_facet_when_house_missing():
|
||||||
|
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx"}]
|
||||||
|
data = FakeData({"[Ma": [{"significator": "[Ma in [Sco", "actioneffect": "eff"}]})
|
||||||
|
types = [f["type"] for f in build_report(positions, data)["objects"][0]["facets"]]
|
||||||
|
assert "sign" in types and "house" not in types
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_by_significator_and_effect():
|
||||||
|
positions = [{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11}]
|
||||||
|
data = FakeData({"[Mo": [
|
||||||
|
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"},
|
||||||
|
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"}, # duplikat (sig+opis)
|
||||||
|
{"significator": "[Mo in 11th H.", "actioneffect": "inny efekt"}, # ten sam sig, inny opis
|
||||||
|
]})
|
||||||
|
facets = {f["type"]: f for f in build_report(positions, data)["objects"][0]["facets"]}
|
||||||
|
assert facets["house"]["count"] == 2 # duplikat odsiany, różny opis zostaje
|
||||||
|
|
||||||
|
|
||||||
|
def test_aspect_facet():
|
||||||
|
positions = [
|
||||||
|
{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11},
|
||||||
|
{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11},
|
||||||
|
]
|
||||||
|
aspects = [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.0}]
|
||||||
|
data = FakeData({"[Su": [{"significator": "[Su [conj [Mo", "actioneffect": "złączeni"}]})
|
||||||
|
sun = build_report(positions, data, aspects=aspects)["objects"][0]
|
||||||
|
asp = [f for f in sun["facets"] if f["type"] == "aspect"]
|
||||||
|
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouping_by_effect():
|
||||||
|
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
|
||||||
|
data = FakeData({"[Ma": [
|
||||||
|
{"significator": "[Ma in 5th H.", "actioneffect": "miscarriage"},
|
||||||
|
{"significator": "[Sa in 5th H.", "actioneffect": "miscarriage"}, # inny sig, ten sam opis
|
||||||
|
{"significator": "[Ma in 5th H.", "actioneffect": "fever"},
|
||||||
|
]})
|
||||||
|
house = [f for f in build_report(positions, data, group=True)["objects"][0]["facets"]
|
||||||
|
if f["type"] == "house"][0]
|
||||||
|
groups = {g["effect"]: g for g in house["groups"]}
|
||||||
|
assert groups["miscarriage"]["count"] == 2
|
||||||
|
assert len(groups["miscarriage"]["significators"]) == 2
|
||||||
|
assert "score" in house
|
||||||
|
|
||||||
|
|
||||||
|
def test_facets_carry_score():
|
||||||
|
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
|
||||||
|
data = FakeData({"[Su": [{"significator": "[Su in [Tau", "actioneffect": "e"}]})
|
||||||
|
facets = build_report(positions, data)["objects"][0]["facets"]
|
||||||
|
assert all("score" in f for f in facets)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordinal():
|
||||||
|
assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd"
|
||||||
|
assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th"
|
||||||
|
|
||||||
|
|
||||||
|
def test_planet_abbr_covers_all_default_objects():
|
||||||
|
for o in DEFAULT_OBJECTS:
|
||||||
|
assert o in PLANET_ABBR
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Walidacja silnika własnego względem wyroczni (LOG-01 + LOG-25).
|
||||||
|
|
||||||
|
Porównujemy pozycje policzone Skyfieldem ze znanymi wartościami Swiss Ephemeris
|
||||||
|
(astro.com) dla horoskopu referencyjnego. To dokładnie mechanizm LOG-25 — tyle
|
||||||
|
że wyrocznią są tu zapisane wcześniej wartości referencyjne zamiast żywej usługi
|
||||||
|
swisseph. Tolerancja 2' (120") z zapasem na różnicę barycentrum/centrum planety.
|
||||||
|
"""
|
||||||
|
from app.engine.compare import compare_positions
|
||||||
|
from app.engine.models import ObjectPosition
|
||||||
|
|
||||||
|
# obiekt -> (długość absolutna w stopniach, retrogradacja) — Swiss Ephemeris / astro.com
|
||||||
|
REFERENCE = {
|
||||||
|
"Sun": (40.139167, False), # Tau 10°08'21"
|
||||||
|
"Moon": (30.551389, False), # Tau 0°33'05"
|
||||||
|
"Mercury": (27.383889, True), # Ari 27°23'02" Rx
|
||||||
|
"Venus": (27.676944, False), # Ari 27°40'37"
|
||||||
|
"Mars": (234.541111, True), # Sco 24°32'28" Rx
|
||||||
|
"Jupiter": (282.961667, True), # Cap 12°57'42" Rx
|
||||||
|
"Saturn": (223.310833, True), # Sco 13°18'39" Rx
|
||||||
|
"Uranus": (252.813333, True), # Sag 12°48'48" Rx
|
||||||
|
"Neptune": (271.220833, True), # Cap 1°13'15" Rx
|
||||||
|
"Pluto": (210.478889, True), # Sco 0°28'44" Rx
|
||||||
|
}
|
||||||
|
|
||||||
|
TOLERANCE_ARCSEC = 120.0
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_positions():
|
||||||
|
return [
|
||||||
|
ObjectPosition(name, lon, 0.0, (-1.0 if retro else 1.0), retro)
|
||||||
|
for name, (lon, retro) in REFERENCE.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_match_swisseph_reference(own_engine, reference_moment):
|
||||||
|
got = own_engine.positions(reference_moment)
|
||||||
|
report = compare_positions(got, _reference_positions(), lon_tol_arcsec=TOLERANCE_ARCSEC)
|
||||||
|
assert report.ok, report.summary()
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrograde_flags_match(own_engine, reference_moment):
|
||||||
|
got = {p.name: p for p in own_engine.positions(reference_moment)}
|
||||||
|
for name, (_lon, retro) in REFERENCE.items():
|
||||||
|
assert got[name].retrograde == retro, f"{name}: kierunek niezgodny"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Testy wykrywania stacji (LOG-03).
|
||||||
|
|
||||||
|
Fakt historyczny: Mars w horoskopie referencyjnym (30.04.1984) jest w środku
|
||||||
|
retrogradacji — stacja SR ~5.04.1984 (ok. 25 dni wstecz), stacja SD ~19.06.1984
|
||||||
|
(ok. 50 dni w przód). Testy sprawdzają strukturę, klasyfikację SD/SR, przedziały
|
||||||
|
dat i samospójność (prędkość w znalezionym momencie ~0).
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.stations import STATION_SOON_DAYS, find_stations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def mars_stations(own_engine, reference_moment):
|
||||||
|
return find_stations(own_engine, reference_moment, "Mars")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_stations_for_sun_moon_and_points(own_engine, reference_moment):
|
||||||
|
for name in ("Sun", "Moon", "North Node", "South Node", "Lilith"):
|
||||||
|
assert find_stations(own_engine, reference_moment, name) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mars_prev_is_sr_next_is_sd(mars_stations):
|
||||||
|
assert mars_stations["prev"]["type"] == "SR" # wszedł w retrogradację
|
||||||
|
assert mars_stations["next"]["type"] == "SD" # wróci do ruchu prostego
|
||||||
|
|
||||||
|
|
||||||
|
def test_mars_station_windows_match_history(mars_stations):
|
||||||
|
# SR ~5.04.1984 -> ok. -25 dni; SD ~19/20.06.1984 -> ok. +50 dni
|
||||||
|
assert -35 < mars_stations["prev"]["days"] < -15
|
||||||
|
assert 40 < mars_stations["next"]["days"] < 60
|
||||||
|
assert mars_stations["prev"]["date"].startswith("1984-04")
|
||||||
|
assert mars_stations["next"]["date"].startswith("1984-06")
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_speed_is_near_zero(own_engine, reference_moment, mars_stations):
|
||||||
|
"""Samospójność: w znalezionym momencie stacji prędkość Marsa ~0."""
|
||||||
|
for key in ("prev", "next"):
|
||||||
|
when = dt.datetime.strptime(mars_stations[key]["date"], "%Y-%m-%d %H:%M").replace(
|
||||||
|
tzinfo=dt.timezone.utc
|
||||||
|
)
|
||||||
|
m = ChartMoment(when_utc=when, lat=reference_moment.lat, lon=reference_moment.lon)
|
||||||
|
speed = own_engine.positions(m, ["Mars"])[0].speed
|
||||||
|
assert abs(speed) < 0.01, f"{key}: speed={speed}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_soon_flag_consistent(mars_stations):
|
||||||
|
expected = any(
|
||||||
|
abs(mars_stations[k]["days"]) < STATION_SOON_DAYS
|
||||||
|
for k in ("prev", "next") if k in mars_stations
|
||||||
|
)
|
||||||
|
assert mars_stations["station_soon"] == expected
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Tabele pomocnicze horoskopu (LOG-23).
|
||||||
|
|
||||||
|
Wartości referencyjne dla horoskopu 30.04.1984 09:20 UTC, Kraków (50.0647N, 19.9450E)
|
||||||
|
sprawdzone wobec faktów NIEZALEŻNYCH od naszego kodu:
|
||||||
|
* 30.04.1984 to poniedziałek → władca dnia Księżyc; 5. godzina poniedziałku
|
||||||
|
w porządku chaldejskim to Słońce (Mo, Sa, Ju, Ma, Su),
|
||||||
|
* wschód/zachód dla Krakowa końcem kwietnia ≈ 5:18 / 19:57 czasu lokalnego
|
||||||
|
(CEST = UTC+2), czyli 03:18 / 17:57 UTC,
|
||||||
|
* pełnia poprzedzająca urodzenie: 15.04.1984 ok. 19:11 UTC.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.tables import (
|
||||||
|
build_tables,
|
||||||
|
critical_degrees,
|
||||||
|
dwadasamsa,
|
||||||
|
element_of,
|
||||||
|
moon_phase,
|
||||||
|
navamsa,
|
||||||
|
planetary_hours,
|
||||||
|
prenatal_syzygy,
|
||||||
|
quality_of,
|
||||||
|
tally,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def krakow():
|
||||||
|
return ChartMoment(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
||||||
|
lat=50.0647, lon=19.9450)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def tables(own_engine, krakow):
|
||||||
|
return build_tables(own_engine, krakow, build_chart(own_engine, krakow))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ żywioły i jakości
|
||||||
|
|
||||||
|
def test_element_and_quality_mapping():
|
||||||
|
assert element_of("Aries") == "Fire" and element_of("Cancer") == "Water"
|
||||||
|
assert quality_of("Aries") == "Cardinal" and quality_of("Taurus") == "Fixed"
|
||||||
|
assert quality_of("Gemini") == "Mutable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tally_matches_hand_count(tables):
|
||||||
|
"""Ręcznie przeliczone dla horoskopu referencyjnego (10 planet + Asc)."""
|
||||||
|
base = tables["tally"]["with_modern_10_plus_asc"]
|
||||||
|
assert base["elements"] == {"Fire": 4, "Earth": 4, "Air": 0, "Water": 3}
|
||||||
|
assert base["qualities"] == {"Cardinal": 4, "Fixed": 6, "Mutable": 1}
|
||||||
|
assert base["total"] == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_element_detected(tables):
|
||||||
|
"""Klasyczne „no air" — podstawa pod scoring siły (LOG-21)."""
|
||||||
|
assert tables["tally"]["missing_elements"] == ["Air"]
|
||||||
|
assert tables["tally"]["missing_qualities"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tally_variants_differ_by_object_count(tables):
|
||||||
|
t = tables["tally"]
|
||||||
|
assert t["classical_7"]["total"] == 7
|
||||||
|
assert t["with_modern_10"]["total"] == 10
|
||||||
|
assert t["classical_7_plus_asc"]["total"] == 8
|
||||||
|
assert t["with_modern_10_plus_asc"]["total"] == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_tally_sums_equal_counted_objects(tables):
|
||||||
|
for variant in ("classical_7", "with_modern_10", "with_modern_10_plus_asc"):
|
||||||
|
v = tables["tally"][variant]
|
||||||
|
assert sum(v["elements"].values()) == v["total"]
|
||||||
|
assert sum(v["qualities"].values()) == v["total"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- faza Księżyca
|
||||||
|
|
||||||
|
def test_moon_phase_reference_is_balsamic_new(tables):
|
||||||
|
"""Nów wypadł 1.05.1984, więc 30.04 Księżyc jest tuż przed nowiem."""
|
||||||
|
mp = tables["moon_phase"]
|
||||||
|
assert mp["phase"] == "New Moon"
|
||||||
|
assert 340.0 < mp["angle"] < 360.0
|
||||||
|
assert mp["illumination"] < 0.05
|
||||||
|
assert mp["waxing"] is False # elongacja > 180 = ubywa
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("angle,expected", [
|
||||||
|
(0.0, "New Moon"), (90.0, "First Quarter"), (180.0, "Full Moon"),
|
||||||
|
(270.0, "Last Quarter"), (46.0, "Waxing Crescent"), (300.0, "Waning Crescent"),
|
||||||
|
])
|
||||||
|
def test_moon_phase_buckets(angle, expected):
|
||||||
|
assert moon_phase(0.0, angle)["phase"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_moon_phase_illumination_extremes():
|
||||||
|
assert moon_phase(0.0, 0.0)["illumination"] == 0.0
|
||||||
|
assert moon_phase(0.0, 180.0)["illumination"] == 1.0
|
||||||
|
assert moon_phase(0.0, 90.0)["illumination"] == pytest.approx(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- stopnie krytyczne
|
||||||
|
|
||||||
|
def test_critical_degrees_reference(tables):
|
||||||
|
found = {c["name"]: c["flags"] for c in tables["critical_degrees"]}
|
||||||
|
assert "Jupiter" in found # Cap 12°57' -> 13° kardynalny
|
||||||
|
assert any("13" in f for f in found["Jupiter"])
|
||||||
|
assert "Pluto" in found # Sco 0°28' -> wejście w znak
|
||||||
|
|
||||||
|
|
||||||
|
def test_anaretic_degree_flagged():
|
||||||
|
flags = critical_degrees([{"name": "X", "sign": "Leo", "decimal": 149.5,
|
||||||
|
"in_sign": "Leo 29°30'"}])
|
||||||
|
assert flags and any("anaretyczny" in f for f in flags[0]["flags"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_flags_for_ordinary_degree():
|
||||||
|
assert critical_degrees([{"name": "X", "sign": "Leo", "decimal": 135.0,
|
||||||
|
"in_sign": "Leo 15°"}]) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- podziały
|
||||||
|
|
||||||
|
def test_dwadasamsa_starts_from_own_sign():
|
||||||
|
"""12. część liczy się OD znaku, w którym stoi punkt."""
|
||||||
|
assert dwadasamsa(0.0) == pytest.approx(0.0) # Ari 0 -> Ari
|
||||||
|
assert dwadasamsa(2.5) == pytest.approx(30.0) # Ari 2°30' -> Tau 0
|
||||||
|
assert dwadasamsa(30.0) == pytest.approx(30.0) # Tau 0 -> Tau
|
||||||
|
|
||||||
|
|
||||||
|
def test_navamsa_classic_starts():
|
||||||
|
"""Znaki kardynalne zaczynają od siebie, stałe od 9. znaku."""
|
||||||
|
assert navamsa(0.0) == pytest.approx(0.0) # Ari -> Ari
|
||||||
|
assert navamsa(30.0) == pytest.approx(270.0) # Tau -> Cap (9. od Byka)
|
||||||
|
assert navamsa(60.0) == pytest.approx(180.0) # Gem -> Lib
|
||||||
|
|
||||||
|
|
||||||
|
def test_divisional_covers_all_positions(tables, own_engine, krakow):
|
||||||
|
chart = build_chart(own_engine, krakow)
|
||||||
|
assert len(tables["divisional"]) == len(chart["positions"])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- dzień i godziny planetarne
|
||||||
|
|
||||||
|
def test_planetary_day_ruler_is_moon_on_monday(tables):
|
||||||
|
"""30.04.1984 to poniedziałek → władcą dnia jest Księżyc."""
|
||||||
|
assert tables["planetary_hours"]["day_ruler"] == "Moon"
|
||||||
|
|
||||||
|
|
||||||
|
def test_planetary_hour_matches_chaldean_sequence(tables):
|
||||||
|
"""Poniedziałek: 1=Mo, 2=Sa, 3=Ju, 4=Ma, 5=Su — urodzenie w 5. godzinie dnia."""
|
||||||
|
ph = tables["planetary_hours"]
|
||||||
|
assert ph["hour_number"] == 5
|
||||||
|
assert ph["hour_ruler"] == "Sun"
|
||||||
|
assert ph["daytime"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sunrise_sunset_match_krakow_late_april(tables):
|
||||||
|
"""Wschód ≈ 03:18 UTC, zachód ≈ 17:57 UTC (5:18 i 19:57 czasu lokalnego)."""
|
||||||
|
ph = tables["planetary_hours"]
|
||||||
|
assert ph["period_start"].startswith("1984-04-30T03:1")
|
||||||
|
assert ph["period_end"].startswith("1984-04-30T17:5")
|
||||||
|
|
||||||
|
|
||||||
|
def test_planetary_hours_are_unequal_and_complete(tables):
|
||||||
|
"""Godziny są nierówne: wiosną dzienna trwa dłużej niż 60 minut."""
|
||||||
|
ph = tables["planetary_hours"]
|
||||||
|
assert ph["hour_length_minutes"] > 60.0
|
||||||
|
assert len(ph["hours"]) == 12
|
||||||
|
assert sum(1 for h in ph["hours"] if h["current"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_polar_night_returns_none(own_engine):
|
||||||
|
"""Za kołem podbiegunowym w grudniu Słońce nie wschodzi — brak godzin."""
|
||||||
|
polar = ChartMoment(when_utc=datetime(2024, 12, 21, 12, 0, tzinfo=timezone.utc),
|
||||||
|
lat=78.0, lon=15.0)
|
||||||
|
assert planetary_hours(own_engine, polar) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- syzygia prenatalna
|
||||||
|
|
||||||
|
def test_prenatal_syzygy_is_april_1984_full_moon(tables):
|
||||||
|
"""Rzeczywista pełnia: 15.04.1984 ok. 19:11 UTC."""
|
||||||
|
s = tables["prenatal_syzygy"]
|
||||||
|
assert s["type"] == "full_moon"
|
||||||
|
assert s["when_utc"].startswith("1984-04-15T19:1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prenatal_syzygy_precedes_birth_within_a_cycle(tables):
|
||||||
|
days = tables["prenatal_syzygy"]["days_before_birth"]
|
||||||
|
assert 0 < days < 29.6, "syzygia musi być w ostatnim cyklu przed urodzeniem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prenatal_syzygy_elongation_is_at_target(own_engine, krakow):
|
||||||
|
"""W znalezionym momencie elongacja MUSI wynosić 0° albo 180°."""
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
s = prenatal_syzygy(own_engine, krakow)
|
||||||
|
when = datetime.fromisoformat(s["when_utc"])
|
||||||
|
pts = {p.name: p.longitude for p in
|
||||||
|
own_engine.positions(ChartMoment(when_utc=when, lat=krakow.lat, lon=krakow.lon),
|
||||||
|
["Sun", "Moon"])}
|
||||||
|
elong = norm360(pts["Moon"] - pts["Sun"])
|
||||||
|
target = 0.0 if s["type"] == "new_moon" else 180.0
|
||||||
|
assert abs(((elong - target + 180.0) % 360.0) - 180.0) < 0.02
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ całość
|
||||||
|
|
||||||
|
def test_build_tables_light_skips_numeric_search(own_engine, krakow):
|
||||||
|
"""heavy=False pomija to, co wymaga szukania numerycznego."""
|
||||||
|
light = build_tables(own_engine, krakow, build_chart(own_engine, krakow), heavy=False)
|
||||||
|
assert "tally" in light and "moon_phase" in light
|
||||||
|
assert "planetary_hours" not in light and "prenatal_syzygy" not in light
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Zbiorcza oś czasu z technik (LOG-14)."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.engine.timeline import (
|
||||||
|
NAIBOD_KEY,
|
||||||
|
build_timeline,
|
||||||
|
profection_events,
|
||||||
|
solar_arc_directions,
|
||||||
|
)
|
||||||
|
|
||||||
|
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||||
|
# natalne długości (z horoskopu referencyjnego)
|
||||||
|
NATAL = {
|
||||||
|
"Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55, "Mercury": 27.38,
|
||||||
|
"Venus": 27.68, "Mars": 234.54, "Jupiter": 282.96, "Saturn": 223.31,
|
||||||
|
"Uranus": 252.81, "Neptune": 271.22, "Pluto": 210.48,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _win(y0, y1):
|
||||||
|
return (dt.datetime(y0, 1, 1, tzinfo=dt.timezone.utc),
|
||||||
|
dt.datetime(y1, 12, 31, tzinfo=dt.timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
def test_profection_events_in_window():
|
||||||
|
lo, hi = _win(2024, 2026)
|
||||||
|
rows = profection_events(NATAL["Asc"], BIRTH, lo, hi)
|
||||||
|
# rok profekcyjny wiek 42 zaczyna się 30.04.2026 -> Asc Capricorn
|
||||||
|
ages = [r["significator"] for r in rows]
|
||||||
|
assert any("Asc Capricorn" in a and "wiek 42" in a for a in ages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_arc_exact_matches_arc_over_key():
|
||||||
|
lo, hi = _win(2020, 2030)
|
||||||
|
rows = solar_arc_directions(NATAL, BIRTH, lo, hi, key=NAIBOD_KEY)
|
||||||
|
assert rows, "brak dyrekcji w oknie"
|
||||||
|
# dla każdej dyrekcji: łuk = (wiek * klucz), a data = urodziny + wiek -> spójne
|
||||||
|
for r in rows[:5]:
|
||||||
|
exact = dt.date.fromisoformat(r["exact"])
|
||||||
|
age_years = (dt.datetime(exact.year, exact.month, exact.day, tzinfo=dt.timezone.utc)
|
||||||
|
- BIRTH).days / 365.2422
|
||||||
|
assert 36 <= age_years <= 47 # okno 2020-2030 = wiek ~36-46
|
||||||
|
assert r["technique"] == "solar_arc" and "dyr." in r["significator"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_directed_sun_conjunct_natal_mc_date():
|
||||||
|
# Sun natal 40.14 -> MC natal 352.59: łuk koniunkcji = (352.59-40.14)%360 = 312.45
|
||||||
|
# to > lifespan przy Naibod (~317 lat) -> NIE powinno być w oknie życia
|
||||||
|
lo, hi = _win(1984, 2084)
|
||||||
|
rows = solar_arc_directions(NATAL, BIRTH, lo, hi)
|
||||||
|
sun_mc = [r for r in rows if r["significator"] == "dyr. Sun koniunkcja MC"]
|
||||||
|
assert not sun_mc # łuk 312° = poza życiem
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_timeline_sorted_and_merged():
|
||||||
|
events = build_timeline(_FakeEngine(), _FakeNatal(), NATAL,
|
||||||
|
"2025-01-01", "2027-01-01",
|
||||||
|
techniques=["profection", "solar_arc"])
|
||||||
|
assert events
|
||||||
|
dates = [e["exact"] for e in events]
|
||||||
|
assert dates == sorted(dates) # posortowane po dacie dokładnej
|
||||||
|
techs = {e["technique"] for e in events}
|
||||||
|
assert "profection" in techs and "solar_arc" in techs
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeNatal:
|
||||||
|
when_utc = BIRTH
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeEngine:
|
||||||
|
"""Silnik-atrapa — build_timeline z solar_return by go użył, tu go pomijamy."""
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Spięcie osi czasu z bazą interpretacji (LOG-14 → 1B→2B)."""
|
||||||
|
from app.significators import _event_tokens, interpret_events
|
||||||
|
|
||||||
|
|
||||||
|
class FakeData:
|
||||||
|
def __init__(self, rows_by_value):
|
||||||
|
self.rows_by_value = rows_by_value
|
||||||
|
|
||||||
|
def search(self, key, value, exact, limit, fields=None):
|
||||||
|
rows = self.rows_by_value.get(value, [])
|
||||||
|
return {"provider": "fake", "total": len(rows), "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_solar_arc():
|
||||||
|
ev = {"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"}
|
||||||
|
assert _event_tokens(ev) == ["[Ve", "[conj", "[NN"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_solar_arc_to_angle_has_no_target_token():
|
||||||
|
ev = {"technique": "solar_arc", "directed": "Sun", "aspect": "square", "target": "MC"}
|
||||||
|
assert _event_tokens(ev) == ["[Su", "[sq"] # MC nie ma tokenu planety
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_profection():
|
||||||
|
ev = {"technique": "profection", "lord": "Saturn", "sign": "Capricorn"}
|
||||||
|
assert _event_tokens(ev) == ["[Sa", "[Cap"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_return_has_no_tokens():
|
||||||
|
assert _event_tokens({"technique": "solar_return"}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_interpret_attaches_matches_with_all_tokens():
|
||||||
|
events = [
|
||||||
|
{"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"},
|
||||||
|
{"technique": "solar_return"},
|
||||||
|
]
|
||||||
|
data = FakeData({"[Ve": [
|
||||||
|
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # wszystkie tokeny
|
||||||
|
{"significator": "[Ve [conj [Mo", "actioneffect": "inny"}, # brak [NN
|
||||||
|
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # duplikat
|
||||||
|
]})
|
||||||
|
interpret_events(events, data)
|
||||||
|
assert events[0]["interpretations_count"] == 1 # duplikat odsiany, tylko z [NN
|
||||||
|
assert events[0]["interpretations"][0]["expanded"] == "Venus conjunction North Node"
|
||||||
|
assert "interpretations" not in events[1] # solar_return pominięty
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Systemy zodiaku (LOG-04): ayanamsy, syderyczny, draconic, RA.
|
||||||
|
|
||||||
|
Wartości referencyjne ayanams pochodzą ze **Swiss Ephemeris** (get_ayanamsa_ut)
|
||||||
|
użytego jako wyrocznia — silnika B nie importujemy tu (izolacja AGPL), więc stałe
|
||||||
|
są wpięte tak jak referencje astro-seek w innych testach. Nasz model odtwarza je
|
||||||
|
z dokładnością do ~0,02" w latach 1900–2100.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine import zodiac as Z
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.formats import sign_index
|
||||||
|
|
||||||
|
# wyrocznia: swisseph get_ayanamsa_ut (JD UT -> ayanamsa °)
|
||||||
|
ORACLE = {
|
||||||
|
"sidereal_lahiri": {
|
||||||
|
2451545.0: 23.857092, 2445820.88889: 23.638183,
|
||||||
|
2415021.0: 22.460550, 2433283.0: 23.158744,
|
||||||
|
2469808.0: 24.555632, 2488070.0: 25.254287,
|
||||||
|
},
|
||||||
|
"sidereal_fagan_bradley": {
|
||||||
|
2451545.0: 24.740300, 2445820.88889: 24.521391,
|
||||||
|
2415021.0: 23.343757, 2433283.0: 24.041952,
|
||||||
|
2469808.0: 25.438840, 2488070.0: 26.137495,
|
||||||
|
},
|
||||||
|
"sidereal_krishnamurti": {
|
||||||
|
2451545.0: 23.760240, 2415021.0: 22.363697,
|
||||||
|
2469808.0: 24.458780, 2488070.0: 25.157435,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _arcsec(a, b):
|
||||||
|
return abs(a - b) * 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", list(ORACLE))
|
||||||
|
def test_ayanamsha_matches_swisseph_oracle(name):
|
||||||
|
for jd, ref in ORACLE[name].items():
|
||||||
|
got = Z.ayanamsha(name, jd)
|
||||||
|
assert _arcsec(got, ref) < 0.1, f"{name} @ JD{jd}: {got} vs {ref} ({_arcsec(got, ref):.3f}\")"
|
||||||
|
|
||||||
|
|
||||||
|
def test_julian_day_j2000():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
jd = Z.julian_day(datetime(2000, 1, 1, 12, 0, tzinfo=timezone.utc))
|
||||||
|
assert abs(jd - 2451545.0) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_tropical_is_zero():
|
||||||
|
assert Z.offset("tropical", 2451545.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_draconic_is_node_longitude():
|
||||||
|
assert Z.offset("draconic", 2451545.0, node_lon=68.0) == 68.0
|
||||||
|
# normalizacja
|
||||||
|
assert Z.offset("draconic", 2451545.0, node_lon=428.0) == pytest.approx(68.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_draconic_requires_node():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Z.offset("draconic", 2451545.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_zodiac_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Z.offset("bzdura", 2451545.0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Z.ayanamsha("sidereal_nieistnieje", 2451545.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_subtracts_and_normalizes():
|
||||||
|
assert Z.apply(10.0, 20.0) == pytest.approx(350.0)
|
||||||
|
assert Z.apply(40.0, 24.74) == pytest.approx(15.26)
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_equatorial_reference_points():
|
||||||
|
# punkt równonocy: λ=0,β=0 -> RA=0, dec=0
|
||||||
|
ra, dec = Z.to_equatorial(0.0, 0.0, 23.4392911)
|
||||||
|
assert ra == pytest.approx(0.0, abs=1e-9) and dec == pytest.approx(0.0, abs=1e-9)
|
||||||
|
# przesilenie letnie: λ=90,β=0 -> RA=90, dec=+ε
|
||||||
|
ra, dec = Z.to_equatorial(90.0, 0.0, 23.4392911)
|
||||||
|
assert ra == pytest.approx(90.0, abs=1e-6)
|
||||||
|
assert dec == pytest.approx(23.4392911, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- integracja z pełnym horoskopem ----
|
||||||
|
|
||||||
|
def test_sidereal_chart_shifts_signs_back(own_engine, reference_moment):
|
||||||
|
trop = build_chart(own_engine, reference_moment, "whole_sign")
|
||||||
|
sid = build_chart(own_engine, reference_moment, "whole_sign", zodiac="sidereal_lahiri")
|
||||||
|
assert sid["zodiac"] == "sidereal_lahiri"
|
||||||
|
assert "ayanamsha" in sid and 23.0 < sid["ayanamsha"] < 24.5
|
||||||
|
tp = {p["name"]: p for p in trop["positions"]}
|
||||||
|
sp = {p["name"]: p for p in sid["positions"]}
|
||||||
|
# syderyczna długość = tropikalna − ayanamsa (mod 360) dla każdego obiektu
|
||||||
|
ay = sid["ayanamsha"]
|
||||||
|
for name in tp:
|
||||||
|
expect = (tp[name]["decimal"] - ay) % 360.0
|
||||||
|
assert abs(((sp[name]["decimal"] - expect + 180) % 360) - 180) < 1e-4
|
||||||
|
|
||||||
|
|
||||||
|
def test_sidereal_preserves_house_numbers(own_engine, reference_moment):
|
||||||
|
# obrót zodiaku nie zmienia numerów domów (geometria niezmiennicza)
|
||||||
|
trop = build_chart(own_engine, reference_moment, "whole_sign")
|
||||||
|
sid = build_chart(own_engine, reference_moment, "whole_sign", zodiac="sidereal_lahiri")
|
||||||
|
tp = {p["name"]: p["house"] for p in trop["positions"]}
|
||||||
|
sp = {p["name"]: p["house"] for p in sid["positions"]}
|
||||||
|
assert tp == sp
|
||||||
|
|
||||||
|
|
||||||
|
def test_draconic_puts_node_at_zero_aries(own_engine, reference_moment):
|
||||||
|
drac = build_chart(own_engine, reference_moment, "whole_sign", zodiac="draconic")
|
||||||
|
assert drac["zodiac"] == "draconic"
|
||||||
|
nn = next(p for p in drac["positions"] if p["name"] == "North Node")
|
||||||
|
# węzeł wznoszący = 0° Barana w draconic
|
||||||
|
assert sign_index(nn["decimal"]) == 0
|
||||||
|
assert nn["decimal"] < 0.001 or nn["decimal"] > 359.999
|
||||||
|
|
||||||
|
|
||||||
|
def test_tropical_default_unchanged(own_engine, reference_moment):
|
||||||
|
# domyślnie tropikalny: brak ayanamshy, zodiac=tropical
|
||||||
|
chart = build_chart(own_engine, reference_moment, "whole_sign")
|
||||||
|
assert chart["zodiac"] == "tropical"
|
||||||
|
assert "ayanamsha" not in chart
|
||||||
@@ -5,30 +5,146 @@ opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from app import link_crypto
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers() -> dict[str, str]:
|
||||||
|
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
|
||||||
|
token = os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
return {"X-Astrololo-Token": token} if token else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _link() -> link_crypto.Link | None:
|
||||||
|
"""Klucz łącza prezentacja↔logika. Czytany przy każdym wywołaniu, bo
|
||||||
|
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
|
||||||
|
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
|
||||||
|
return link_crypto.Link(key) if key else None
|
||||||
|
|
||||||
|
|
||||||
class LogicClient:
|
class LogicClient:
|
||||||
def __init__(self, base_url: str | None = None) -> None:
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
||||||
|
|
||||||
|
def _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||||
|
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
|
||||||
|
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
|
||||||
|
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
||||||
|
payload=payload, headers=_auth_headers(),
|
||||||
|
link=_link())
|
||||||
|
|
||||||
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
return self._post("/api/query", payload, settings.http_timeout)
|
||||||
r = client.post(f"{self.base_url}/api/query", json=payload)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
def positions(
|
def positions(
|
||||||
self, when_utc_iso: str, lat: float, lon: float, objects: list[str] | None = None
|
self,
|
||||||
|
when_utc_iso: str,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
objects: list[str] | None = None,
|
||||||
|
house_system: str = "whole_sign",
|
||||||
|
stations: bool = False,
|
||||||
|
zodiac: str = "tropical",
|
||||||
|
tables: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Pozycje obiektów dla danego momentu — woła logic /chart/positions."""
|
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "objects": objects}
|
payload = {
|
||||||
|
"when_utc": when_utc_iso,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"objects": objects,
|
||||||
|
"house_system": house_system,
|
||||||
|
"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 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))
|
||||||
|
|
||||||
|
def prompt(
|
||||||
|
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||||
|
budget: str = "medium", from_date: str | None = None, to_date: str | None = None,
|
||||||
|
provider: str | None = None, model: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Gotowy prompt do LLM z wyliczeń (LOG-29/30) — woła logic /chart/prompt.
|
||||||
|
|
||||||
|
`provider` jest potrzebny dla budżetu „maksymalny kontekst modelu": limit
|
||||||
|
znaków zależy wtedy od okna kontekstu konkretnego modelu."""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"profile": profile, "when_utc": when_utc_iso,
|
||||||
|
"lat": lat, "lon": lon, "budget": budget, "provider": provider,
|
||||||
|
"model": model,
|
||||||
|
}
|
||||||
|
if from_date and to_date:
|
||||||
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
|
return self._post("/chart/prompt", payload, max(settings.http_timeout, 60.0))
|
||||||
|
|
||||||
|
def horoscope(
|
||||||
|
self, profile: str, when_utc_iso: str, lat: float, lon: float,
|
||||||
|
budget: str = "medium", provider: str | None = None, model: str | None = None,
|
||||||
|
from_date: str | None = None, to_date: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Napisany horoskop (LOG-31) — woła logic /chart/horoscope.
|
||||||
|
|
||||||
|
Generowanie tekstu trwa (zwłaszcza na modelu lokalnym), stąd długi timeout.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"profile": profile, "when_utc": when_utc_iso,
|
||||||
|
"lat": lat, "lon": lon, "budget": budget,
|
||||||
|
}
|
||||||
|
if provider:
|
||||||
|
payload["provider"] = provider
|
||||||
|
if model:
|
||||||
|
payload["model"] = model
|
||||||
|
if from_date and to_date:
|
||||||
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
|
return self._post("/chart/horoscope", payload, max(settings.http_timeout, 300.0))
|
||||||
|
|
||||||
|
def horoscope_stream(self, payload: dict[str, Any]):
|
||||||
|
"""Strumień postępu pisania horoskopu (NDJSON) — przekazywany do przeglądarki.
|
||||||
|
|
||||||
|
Timeout jest długi, bo generowanie trwa; strumień i tak niesie heartbeat,
|
||||||
|
więc cisza na łączu nie zostanie wzięta za zerwanie.
|
||||||
|
|
||||||
|
Idzie przez szyfrowane łącze jak reszta ruchu w dół (PRE-16). Bez tego przy
|
||||||
|
włączonym `LINK_ENCRYPTION_REQUIRED` serwer odrzuciłby nieszyfrowane żądanie
|
||||||
|
i okno postępu przestałoby działać. `stream_lines` pieczętuje żądanie i
|
||||||
|
odszyfrowuje odpowiedź ramka po ramce, zachowując dostarczanie na żywo.
|
||||||
|
"""
|
||||||
|
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
|
||||||
|
yield from link_crypto.stream_lines(
|
||||||
|
client, f"{self.base_url}/chart/horoscope/stream",
|
||||||
|
payload=payload, headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
|
def 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:
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
r = client.post(f"{self.base_url}/chart/positions", json=payload)
|
return link_crypto.call_json(client, "GET", f"{self.base_url}/llm/models",
|
||||||
r.raise_for_status()
|
headers=_auth_headers(), link=_link())
|
||||||
return r.json()
|
|
||||||
|
def timeline(
|
||||||
|
self, when_utc_iso: str, lat: float, lon: float,
|
||||||
|
from_date: str, to_date: str, interpret: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
|
||||||
|
payload = {
|
||||||
|
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
||||||
|
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
||||||
|
}
|
||||||
|
return self._post("/chart/timeline", payload, max(settings.http_timeout, 60.0))
|
||||||
|
|||||||
@@ -15,3 +15,17 @@ class Settings:
|
|||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
# Domyślna (wklepana) lokalizacja formularzy — miejsce urodzenia właściciela:
|
||||||
|
# Szpital Barlickiego w Łodzi, 51°46'26.1"N 19°28'58.6"E (potwierdzone reverse-
|
||||||
|
# geokodowaniem OSM: Kopcińskiego 22/28). QoL — nie trzeba wpisywać jej za każdym
|
||||||
|
# razem. Przycisk „Tu i teraz" nadal ją nadpisuje geolokalizacją przeglądarki.
|
||||||
|
# Można nadpisać zmiennymi środowiskowymi (np. inny współpracownik).
|
||||||
|
DEFAULT_LAT: float = float(os.getenv("DEFAULT_LAT", "51.7739"))
|
||||||
|
DEFAULT_LON: float = float(os.getenv("DEFAULT_LON", "19.4829"))
|
||||||
|
DEFAULT_LOCATION_LABEL: str = os.getenv("DEFAULT_LOCATION_LABEL", "Szpital Barlickiego, Łódź")
|
||||||
|
|
||||||
|
|
||||||
|
def default_form() -> dict:
|
||||||
|
"""Wstępnie wypełnione pola lokalizacji na czystym formularzu (GET)."""
|
||||||
|
return {"lat": DEFAULT_LAT, "lon": DEFAULT_LON}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Proxy geokodera (OpenStreetMap / Nominatim) dla wyszukiwarki lokalizacji w UI.
|
||||||
|
|
||||||
|
QoL: pozwala wpisać nazwę / adres / POI i dostać współrzędne (oraz odwrotnie —
|
||||||
|
z punktu na mapie nazwę miejsca). Wołamy Nominatim **po stronie serwera**, nie
|
||||||
|
z przeglądarki, bo:
|
||||||
|
- polityka Nominatim wymaga sensownego User-Agent i max ~1 zapytanie/s — łatwiej
|
||||||
|
to wyegzekwować i cache'ować centralnie niż z wielu klientów,
|
||||||
|
- działa niezależnie od „secure context" przeglądarki (na http://<ip> też).
|
||||||
|
|
||||||
|
Atrybucja „© OpenStreetMap contributors" jest pokazywana w UI (mapa + wyniki).
|
||||||
|
Zależność sieciowa jest OPCJONALNA: brak internetu = wyszukiwarka zwraca błąd,
|
||||||
|
ale ręczne wpisanie lat/lon oraz mapa działają dalej.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
NOMINATIM = "https://nominatim.openstreetmap.org"
|
||||||
|
# Nominatim wymaga identyfikującego User-Agent (inaczej blokuje). Kontakt = właściciel.
|
||||||
|
USER_AGENT = "astrololo/1.0 (self-hosted astrology tool; +mtuszowski@gmail.com)"
|
||||||
|
_MIN_INTERVAL = 1.1 # s — polityka Nominatim: max ~1 zapytanie/s
|
||||||
|
_CACHE_TTL = 3600.0 # s — powtórki tego samego zapytania z pamięci przez godzinę
|
||||||
|
_TIMEOUT = 6.0
|
||||||
|
|
||||||
|
_lock = threading.Lock() # serializuje wołania (throttling globalny w procesie)
|
||||||
|
_last_call = 0.0
|
||||||
|
_cache: dict[str, tuple[float, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _throttle() -> None:
|
||||||
|
global _last_call
|
||||||
|
wait = _MIN_INTERVAL - (time.monotonic() - _last_call)
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
_last_call = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _cached(key: str) -> Any | None:
|
||||||
|
hit = _cache.get(key)
|
||||||
|
if hit and (time.monotonic() - hit[0]) < _CACHE_TTL:
|
||||||
|
return hit[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path: str, params: dict) -> Any:
|
||||||
|
"""Wołanie Nominatim z throttlingiem; zakłada trzymany `_lock`."""
|
||||||
|
_throttle()
|
||||||
|
r = httpx.get(
|
||||||
|
f"{NOMINATIM}{path}",
|
||||||
|
params=params,
|
||||||
|
headers={"User-Agent": USER_AGENT, "Accept-Language": "pl"},
|
||||||
|
timeout=_TIMEOUT,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def search(query: str, limit: int = 5) -> list[dict]:
|
||||||
|
"""Nazwa/adres/POI → lista kandydatów [{name, lat, lon}] (najlepsze pierwsze)."""
|
||||||
|
query = (query or "").strip()
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
key = f"s:{limit}:{query.lower()}"
|
||||||
|
with _lock:
|
||||||
|
cached = _cached(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = _get("/search", {"q": query, "format": "jsonv2",
|
||||||
|
"limit": limit, "addressdetails": 0})
|
||||||
|
out = [
|
||||||
|
{"name": item.get("display_name", ""),
|
||||||
|
"lat": round(float(item["lat"]), 6),
|
||||||
|
"lon": round(float(item["lon"]), 6)}
|
||||||
|
for item in data if "lat" in item and "lon" in item
|
||||||
|
]
|
||||||
|
_cache[key] = (time.monotonic(), out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(lat: float, lon: float) -> dict:
|
||||||
|
"""Punkt (lat, lon) → nazwa miejsca {name, lat, lon}."""
|
||||||
|
key = f"r:{round(lat, 5)}:{round(lon, 5)}"
|
||||||
|
with _lock:
|
||||||
|
cached = _cached(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = _get("/reverse", {"lat": lat, "lon": lon, "format": "jsonv2"})
|
||||||
|
out = {"name": data.get("display_name", ""),
|
||||||
|
"lat": round(lat, 6), "lon": round(lon, 6)}
|
||||||
|
_cache[key] = (time.monotonic(), out)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
||||||
|
|
||||||
|
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
||||||
|
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
||||||
|
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
||||||
|
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
||||||
|
zrzut z węzła), miał je w całości.
|
||||||
|
|
||||||
|
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
||||||
|
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
||||||
|
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
||||||
|
podatne na modyfikację".
|
||||||
|
|
||||||
|
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
||||||
|
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
||||||
|
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
||||||
|
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
||||||
|
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
||||||
|
|
||||||
|
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
||||||
|
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
||||||
|
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
||||||
|
|
||||||
|
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
||||||
|
|
||||||
|
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
||||||
|
|
||||||
|
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
||||||
|
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
||||||
|
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
||||||
|
|
||||||
|
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
||||||
|
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
||||||
|
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
||||||
|
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.link")
|
||||||
|
|
||||||
|
MAGIC = b"AL1"
|
||||||
|
VERSION = "v1"
|
||||||
|
NONCE_BYTES = 12
|
||||||
|
KEY_BYTES = 32 # AES-256
|
||||||
|
LENGTH_PREFIX = 4
|
||||||
|
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
||||||
|
MAX_SKEW_SECONDS = 300.0
|
||||||
|
|
||||||
|
HEADER_ENC = "X-Astrololo-Enc"
|
||||||
|
HEADER_TS = "X-Astrololo-Enc-Ts"
|
||||||
|
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
||||||
|
|
||||||
|
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||||
|
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||||
|
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||||
|
|
||||||
|
REQUEST, RESPONSE = b"req", b"res"
|
||||||
|
|
||||||
|
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
||||||
|
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
|
||||||
|
|
||||||
|
class LinkError(Exception):
|
||||||
|
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- klucze
|
||||||
|
|
||||||
|
def parse_key(raw: str) -> bytes:
|
||||||
|
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
raise LinkError("pusty klucz łącza")
|
||||||
|
try:
|
||||||
|
key = bytes.fromhex(text)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(text, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
||||||
|
if len(key) != KEY_BYTES:
|
||||||
|
raise LinkError(
|
||||||
|
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
||||||
|
f"`openssl rand -hex 32`"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def key_from_env(env_name: str) -> bytes | None:
|
||||||
|
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
||||||
|
raw = os.getenv(env_name, "")
|
||||||
|
return parse_key(raw) if raw.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_required() -> bool:
|
||||||
|
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
||||||
|
|
||||||
|
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
||||||
|
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
||||||
|
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
||||||
|
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
||||||
|
"""
|
||||||
|
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
||||||
|
return HKDF(
|
||||||
|
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
||||||
|
info=b"astrololo/link/" + direction,
|
||||||
|
).derive(link_key)
|
||||||
|
|
||||||
|
|
||||||
|
class Link:
|
||||||
|
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
||||||
|
|
||||||
|
def __init__(self, link_key: bytes) -> None:
|
||||||
|
self._by_direction = {
|
||||||
|
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
||||||
|
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- pojedyncza ramka
|
||||||
|
|
||||||
|
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
||||||
|
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
||||||
|
stamp.encode("ascii"), str(seq).encode("ascii")])
|
||||||
|
|
||||||
|
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
plaintext: bytes) -> bytes:
|
||||||
|
nonce = os.urandom(NONCE_BYTES)
|
||||||
|
sealed = self._by_direction[direction].encrypt(
|
||||||
|
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
||||||
|
return MAGIC + nonce + sealed
|
||||||
|
|
||||||
|
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||||
|
frame: bytes) -> bytes:
|
||||||
|
if not frame.startswith(MAGIC):
|
||||||
|
raise LinkError("ramka bez znacznika astrololo")
|
||||||
|
body = frame[len(MAGIC):]
|
||||||
|
if len(body) <= NONCE_BYTES:
|
||||||
|
raise LinkError("ramka za krótka")
|
||||||
|
nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:]
|
||||||
|
try:
|
||||||
|
return self._by_direction[direction].decrypt(
|
||||||
|
nonce, sealed, self._aad(direction, path, stamp, seq))
|
||||||
|
except InvalidTag as exc:
|
||||||
|
# Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt,
|
||||||
|
# przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich
|
||||||
|
# na zewnątrz podpowiadałoby atakującemu, w co trafił.
|
||||||
|
raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ strumień ramek
|
||||||
|
|
||||||
|
def seal_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
chunks: Iterable[bytes]) -> Iterator[bytes]:
|
||||||
|
for seq, chunk in enumerate(chunks):
|
||||||
|
yield frame_out(self.seal(direction, path, stamp, seq, chunk))
|
||||||
|
|
||||||
|
def open_stream(self, direction: bytes, path: str, stamp: str,
|
||||||
|
raw: bytes) -> Iterator[bytes]:
|
||||||
|
for seq, frame in enumerate(frames_in(raw)):
|
||||||
|
yield self.open(direction, path, stamp, seq, frame)
|
||||||
|
|
||||||
|
def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes:
|
||||||
|
return b"".join(self.open_stream(direction, path, stamp, raw))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ramkowanie
|
||||||
|
|
||||||
|
def frame_out(payload: bytes) -> bytes:
|
||||||
|
return struct.pack(">I", len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def frames_in(raw: bytes) -> Iterator[bytes]:
|
||||||
|
"""Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych."""
|
||||||
|
offset = 0
|
||||||
|
while offset < len(raw):
|
||||||
|
if offset + LENGTH_PREFIX > len(raw):
|
||||||
|
raise LinkError("urwana ramka (brak nagłówka długości)")
|
||||||
|
(size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
offset += LENGTH_PREFIX
|
||||||
|
if offset + size > len(raw):
|
||||||
|
raise LinkError("urwana ramka (za mało danych)")
|
||||||
|
yield raw[offset:offset + size]
|
||||||
|
offset += size
|
||||||
|
|
||||||
|
|
||||||
|
def unframe_incremental(buffer: bytearray) -> Iterator[bytes]:
|
||||||
|
"""Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później.
|
||||||
|
|
||||||
|
Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi
|
||||||
|
rozjechać się między dwa odczyty.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
if len(buffer) < LENGTH_PREFIX:
|
||||||
|
return
|
||||||
|
(size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX])
|
||||||
|
if size > MAX_FRAME:
|
||||||
|
raise LinkError("ramka ponad dopuszczalny rozmiar")
|
||||||
|
if len(buffer) < LENGTH_PREFIX + size:
|
||||||
|
return
|
||||||
|
frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size])
|
||||||
|
del buffer[:LENGTH_PREFIX + size]
|
||||||
|
yield frame
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- świeżość ruchu
|
||||||
|
|
||||||
|
def stamp_now() -> str:
|
||||||
|
return f"{time.time():.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def check_stamp(stamp: str) -> None:
|
||||||
|
"""Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się
|
||||||
|
odtworzyć w dowolnym momencie w przyszłości."""
|
||||||
|
try:
|
||||||
|
sent = float(stamp)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise LinkError("brak albo błędny znacznik czasu") from exc
|
||||||
|
if abs(time.time() - sent) > MAX_SKEW_SECONDS:
|
||||||
|
raise LinkError("znacznik czasu poza dopuszczalnym oknem")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================== strona serwerowa
|
||||||
|
|
||||||
|
class LinkCryptoMiddleware:
|
||||||
|
"""Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi.
|
||||||
|
|
||||||
|
Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba
|
||||||
|
podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić
|
||||||
|
odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, link: Link | None, layer: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.link = link
|
||||||
|
self.layer = layer
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS:
|
||||||
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
path = scope["path"]
|
||||||
|
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
|
||||||
|
|
||||||
|
if headers.get(HEADER_ENC.lower()) != VERSION:
|
||||||
|
# Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo
|
||||||
|
# pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie
|
||||||
|
# chcemy po cichu wrócić do jawnego ruchu.
|
||||||
|
log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)",
|
||||||
|
self.layer, path)
|
||||||
|
return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.")
|
||||||
|
|
||||||
|
stamp = headers.get(HEADER_TS.lower(), "")
|
||||||
|
try:
|
||||||
|
check_stamp(stamp)
|
||||||
|
plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive))
|
||||||
|
except LinkError as exc:
|
||||||
|
log.warning("warstwa %s: %s (%s)", self.layer, exc, path)
|
||||||
|
return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.")
|
||||||
|
|
||||||
|
scope = dict(scope)
|
||||||
|
scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext))
|
||||||
|
await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path))
|
||||||
|
|
||||||
|
def _sealing_send(self, send, path: str):
|
||||||
|
state: dict = {"stamp": "", "seq": 0}
|
||||||
|
|
||||||
|
async def sealing(message):
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
state["stamp"] = stamp_now()
|
||||||
|
keep = [(k, v) for k, v in message.get("headers", [])
|
||||||
|
if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
message = dict(message)
|
||||||
|
message["headers"] = keep + [
|
||||||
|
(b"content-type", CONTENT_TYPE.encode()),
|
||||||
|
(HEADER_ENC.lower().encode(), VERSION.encode()),
|
||||||
|
(HEADER_TS.lower().encode(), state["stamp"].encode()),
|
||||||
|
]
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
if message["type"] == "http.response.body":
|
||||||
|
chunk = message.get("body", b"")
|
||||||
|
sealed = b""
|
||||||
|
if chunk:
|
||||||
|
sealed = frame_out(self.link.seal(
|
||||||
|
RESPONSE, path, state["stamp"], state["seq"], chunk))
|
||||||
|
state["seq"] += 1
|
||||||
|
return await send({"type": "http.response.body", "body": sealed,
|
||||||
|
"more_body": message.get("more_body", False)})
|
||||||
|
|
||||||
|
return await send(message)
|
||||||
|
|
||||||
|
return sealing
|
||||||
|
|
||||||
|
|
||||||
|
def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int):
|
||||||
|
"""Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI
|
||||||
|
próbowałby sparsować JSON o cudzej deklarowanej wielkości."""
|
||||||
|
kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")]
|
||||||
|
kept.append((b"content-length", str(length).encode()))
|
||||||
|
if length:
|
||||||
|
kept.append((b"content-type", b"application/json"))
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_body(receive) -> bytes:
|
||||||
|
body = bytearray()
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.disconnect":
|
||||||
|
raise LinkError("rozłączenie w trakcie odbioru żądania")
|
||||||
|
body += message.get("body", b"")
|
||||||
|
if not message.get("more_body", False):
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _replay(body: bytes, original):
|
||||||
|
"""Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji.
|
||||||
|
|
||||||
|
Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu
|
||||||
|
zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia
|
||||||
|
równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji
|
||||||
|
okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały.
|
||||||
|
"""
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
nonlocal delivered
|
||||||
|
if delivered:
|
||||||
|
return await original()
|
||||||
|
delivered = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
|
||||||
|
return receive
|
||||||
|
|
||||||
|
|
||||||
|
async def _refuse(send, detail: str) -> None:
|
||||||
|
"""Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować,
|
||||||
|
więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny."""
|
||||||
|
payload = f'{{"detail":"{detail}"}}'.encode("utf-8")
|
||||||
|
await send({"type": "http.response.start", "status": 400, "headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"content-length", str(len(payload)).encode()),
|
||||||
|
]})
|
||||||
|
await send({"type": "http.response.body", "body": payload})
|
||||||
|
|
||||||
|
|
||||||
|
def install(app, env_name: str, layer: str):
|
||||||
|
"""Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa
|
||||||
|
tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał."""
|
||||||
|
link_key = key_from_env(env_name)
|
||||||
|
if link_key is None and encryption_required():
|
||||||
|
# Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania:
|
||||||
|
# wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni
|
||||||
|
# niczego. Pod w CrashLoop widać od razu, jawny ruch — nie.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — "
|
||||||
|
f"warstwa {layer} nie wystartuje bez klucza łącza"
|
||||||
|
)
|
||||||
|
if link_key is None:
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, "
|
||||||
|
"więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha "
|
||||||
|
"ruch wewnątrz sieci.", env_name, layer,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
link = Link(link_key)
|
||||||
|
app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer)
|
||||||
|
log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name)
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ strona kliencka
|
||||||
|
|
||||||
|
def call(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None) -> bytes:
|
||||||
|
"""Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi.
|
||||||
|
|
||||||
|
Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego
|
||||||
|
argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby
|
||||||
|
się niejasnym błędem odszyfrowania.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero
|
||||||
|
# zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie
|
||||||
|
# sygnifikatory, o które pytamy bazę.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie "
|
||||||
|
f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
response = client.request(method, url, json=payload, headers=request_headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
path = httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8")
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp,
|
||||||
|
"Content-Type": CONTENT_TYPE})
|
||||||
|
|
||||||
|
response = client.request(method, url, content=body, headers=request_headers)
|
||||||
|
if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
log.error("łącze %s odmówiło: %s", path, response.text[:200])
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony")
|
||||||
|
reply_stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(reply_stamp)
|
||||||
|
return link.open_all(RESPONSE, path, reply_stamp, response.content)
|
||||||
|
|
||||||
|
|
||||||
|
def call_json(client, method: str, url: str, *, payload=None,
|
||||||
|
headers: dict[str, str] | None = None, link: Link | None):
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
return _json.loads(call(client, method, url, payload=payload,
|
||||||
|
headers=headers, link=link))
|
||||||
|
|
||||||
|
|
||||||
|
def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
|
||||||
|
"""Odbiór odpowiedzi płynącej kawałkami (okno postępu).
|
||||||
|
|
||||||
|
Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją
|
||||||
|
w buforze zamiast zakładać, że każdy kawałek to komplet.
|
||||||
|
"""
|
||||||
|
if link is None:
|
||||||
|
yield from response.iter_bytes()
|
||||||
|
return
|
||||||
|
if response.headers.get(HEADER_ENC) != VERSION:
|
||||||
|
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
|
||||||
|
stamp = response.headers.get(HEADER_TS, "")
|
||||||
|
check_stamp(stamp)
|
||||||
|
path = response.request.url.path
|
||||||
|
buffer = bytearray()
|
||||||
|
seq = 0
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
buffer += chunk
|
||||||
|
for frame in unframe_incremental(buffer):
|
||||||
|
yield link.open(RESPONSE, path, stamp, seq, frame)
|
||||||
|
seq += 1
|
||||||
|
if buffer:
|
||||||
|
raise LinkError("strumień urwał się w połowie ramki")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
|
||||||
|
link: Link | None) -> Iterator[str]:
|
||||||
|
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
|
||||||
|
|
||||||
|
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
|
||||||
|
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
|
||||||
|
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
|
||||||
|
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
|
||||||
|
dopiero na znakach nowej linii.
|
||||||
|
|
||||||
|
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
|
||||||
|
działał bez zmian.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
import httpx as _httpx
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if link is None:
|
||||||
|
if encryption_required():
|
||||||
|
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
|
||||||
|
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
|
||||||
|
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
|
||||||
|
raise LinkError(
|
||||||
|
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
|
||||||
|
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
|
||||||
|
)
|
||||||
|
with client.stream("POST", url, json=payload, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
for text_line in response.iter_lines():
|
||||||
|
if text_line:
|
||||||
|
yield text_line
|
||||||
|
return
|
||||||
|
|
||||||
|
path = _httpx.URL(url).path
|
||||||
|
stamp = stamp_now()
|
||||||
|
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
|
||||||
|
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
|
||||||
|
with client.stream("POST", url, content=body, headers=request_headers) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
buffer = bytearray()
|
||||||
|
for plain in open_response_stream(response, link):
|
||||||
|
buffer += plain
|
||||||
|
while True:
|
||||||
|
nl = buffer.find(b"\n")
|
||||||
|
if nl < 0:
|
||||||
|
break
|
||||||
|
text_line = bytes(buffer[:nl])
|
||||||
|
del buffer[:nl + 1]
|
||||||
|
if text_line:
|
||||||
|
yield text_line.decode("utf-8")
|
||||||
|
if buffer:
|
||||||
|
yield bytes(buffer).decode("utf-8")
|
||||||
@@ -9,20 +9,24 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, Request
|
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app import geocode, security
|
||||||
from app.clients.logic_client import LogicClient
|
from app.clients.logic_client import LogicClient
|
||||||
|
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa prezentacji")
|
app = FastAPI(title="astrololo · warstwa prezentacji")
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
logic = LogicClient()
|
logic = LogicClient()
|
||||||
|
security.install(app) # logowanie + limit żądań (LOG-32)
|
||||||
|
|
||||||
|
|
||||||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||||
@@ -36,6 +40,15 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
|||||||
return utc.isoformat(), label
|
return utc.isoformat(), label
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_catalog() -> dict:
|
||||||
|
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
|
||||||
|
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
|
||||||
|
try:
|
||||||
|
return logic.llm_models()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return {"providers": {}, "defaults": {}}
|
||||||
|
|
||||||
|
|
||||||
def _logic_error(e: Exception) -> str:
|
def _logic_error(e: Exception) -> str:
|
||||||
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
||||||
return (
|
return (
|
||||||
@@ -48,7 +61,11 @@ def _logic_error(e: Exception) -> str:
|
|||||||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def chart_form(request: Request):
|
def chart_form(request: Request):
|
||||||
return templates.TemplateResponse(request, "chart.html", {"result": None, "form": {}})
|
return templates.TemplateResponse(
|
||||||
|
request, "chart.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
|
"llm_catalog": _llm_catalog()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/", response_class=HTMLResponse)
|
@app.post("/", response_class=HTMLResponse)
|
||||||
@@ -59,13 +76,22 @@ def chart_compute(
|
|||||||
tz_offset: float = Form(0.0),
|
tz_offset: float = Form(0.0),
|
||||||
lat: float = Form(0.0),
|
lat: float = Form(0.0),
|
||||||
lon: float = Form(0.0),
|
lon: float = Form(0.0),
|
||||||
|
house_system: str = Form("whole_sign"),
|
||||||
|
stations: bool = Form(False),
|
||||||
|
zodiac: str = Form("tropical"),
|
||||||
|
tables: bool = Form(False),
|
||||||
):
|
):
|
||||||
form = {"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, "house_system": house_system, "stations": stations,
|
||||||
|
"zodiac": zodiac, "tables": tables}
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
ctx["result"] = logic.positions(when_utc_iso=iso_utc, lat=lat, lon=lon)
|
ctx["result"] = logic.positions(
|
||||||
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
|
||||||
|
)
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -96,6 +122,188 @@ def significators_search(
|
|||||||
return templates.TemplateResponse(request, "significators.html", ctx)
|
return templates.TemplateResponse(request, "significators.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
|
||||||
|
@app.get("/interpret", response_class=HTMLResponse)
|
||||||
|
def interpret_form(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "interpret.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
|
"llm_catalog": _llm_catalog()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/interpret", response_class=HTMLResponse)
|
||||||
|
def interpret_run(
|
||||||
|
request: Request,
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
group: bool = Form(False),
|
||||||
|
action: str = Form("report"),
|
||||||
|
prompt_budget: str = Form("medium"),
|
||||||
|
llm_provider: str = Form("local"),
|
||||||
|
llm_model: str = Form(""),
|
||||||
|
):
|
||||||
|
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||||
|
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||||||
|
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||||
|
"llm_catalog": _llm_catalog()}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
if action == "prompt":
|
||||||
|
ctx["prompt_result"] = logic.prompt(
|
||||||
|
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget,
|
||||||
|
provider=llm_provider, model=llm_model,
|
||||||
|
)
|
||||||
|
elif action == "horoscope":
|
||||||
|
ctx["prompt_result"] = logic.horoscope(
|
||||||
|
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "interpret.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
|
||||||
|
@app.get("/timeline", response_class=HTMLResponse)
|
||||||
|
def timeline_form(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "timeline.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||||
|
"llm_catalog": _llm_catalog()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/timeline", response_class=HTMLResponse)
|
||||||
|
def timeline_run(
|
||||||
|
request: Request,
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
from_date: str = Form(...),
|
||||||
|
to_date: str = Form(...),
|
||||||
|
action: str = Form("timeline"),
|
||||||
|
prompt_budget: str = Form("medium"),
|
||||||
|
llm_provider: str = Form("local"),
|
||||||
|
llm_model: str = Form(""),
|
||||||
|
):
|
||||||
|
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||||
|
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||||||
|
"llm_provider": llm_provider, "llm_model": llm_model}
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||||||
|
"llm_catalog": _llm_catalog()}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
if action == "prompt":
|
||||||
|
ctx["prompt_result"] = logic.prompt(
|
||||||
|
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
||||||
|
provider=llm_provider, model=llm_model,
|
||||||
|
)
|
||||||
|
elif action == "horoscope":
|
||||||
|
ctx["prompt_result"] = logic.horoscope(
|
||||||
|
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||||||
|
from_date=from_date, to_date=to_date,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ctx["result"] = logic.timeline(
|
||||||
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
from_date=from_date, to_date=to_date, interpret=True,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "timeline.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
|
||||||
|
@app.post("/horoscope/stream")
|
||||||
|
def horoscope_stream(
|
||||||
|
profile: str = Form("natal"),
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
prompt_budget: str = Form("medium"),
|
||||||
|
llm_provider: str = Form("local"),
|
||||||
|
llm_model: str = Form(""),
|
||||||
|
from_date: str = Form(""),
|
||||||
|
to_date: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
|
||||||
|
|
||||||
|
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby
|
||||||
|
przeładowanie strony — jedno źródło prawdy dla wyglądu wyniku.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
try:
|
||||||
|
iso_utc, _ = _build_utc(date, time, tz_offset)
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
||||||
|
|
||||||
|
payload: dict = {
|
||||||
|
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
|
||||||
|
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
|
||||||
|
}
|
||||||
|
if profile == "period" and from_date and to_date:
|
||||||
|
payload["from_date"], payload["to_date"] = from_date, to_date
|
||||||
|
|
||||||
|
def relay():
|
||||||
|
try:
|
||||||
|
for raw in logic.horoscope_stream(payload):
|
||||||
|
try:
|
||||||
|
event = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if event.get("type") == "result":
|
||||||
|
html = templates.get_template("_prompt_result.html").render(
|
||||||
|
prompt_result=event.get("result") or {}
|
||||||
|
)
|
||||||
|
event["html"] = html
|
||||||
|
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
yield json.dumps({"type": "error", "message": _logic_error(e)},
|
||||||
|
ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
return StreamingResponse(relay(), media_type="application/x-ndjson",
|
||||||
|
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
||||||
|
@app.get("/geocode")
|
||||||
|
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
||||||
|
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
|
||||||
|
try:
|
||||||
|
return {"results": geocode.search(q)}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/reverse")
|
||||||
|
def geocode_reverse(lat: float, lon: float):
|
||||||
|
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
|
||||||
|
try:
|
||||||
|
return geocode.reverse(lat, lon)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {"status": "ok", "layer": "presentation"}
|
return {"status": "ok", "layer": "presentation"}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Ochrona dostępu do aplikacji (LOG-32).
|
||||||
|
|
||||||
|
Rdzeniem produktu są oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
|
||||||
|
na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
|
||||||
|
uwierzytelnienia oznacza, że każdy w sieci może je wypompować — bez udziału
|
||||||
|
jakiegokolwiek modelu językowego. Ten moduł zamyka tę drogę.
|
||||||
|
|
||||||
|
Dwa mechanizmy:
|
||||||
|
* **HTTP Basic** — wejście do aplikacji; włącza się, gdy ustawiono APP_PASSWORD.
|
||||||
|
* **limit żądań** — hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań);
|
||||||
|
rozliczany per adres klienta, a za odwrotnym proxy — po TRUST_PROXY=true —
|
||||||
|
per adres z nagłówka, nie per adres proxy (patrz `client_ip`).
|
||||||
|
|
||||||
|
Świadomie NIE logujemy treści żądań ani promptów — logi to kolejny nośnik wycieku.
|
||||||
|
|
||||||
|
UWAGA: bez APP_PASSWORD ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev).
|
||||||
|
Wtedy przy starcie leci głośne ostrzeżenie — żeby nikt nie wdrożył tego w
|
||||||
|
przekonaniu, że jest chroniony.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
|
log = logging.getLogger("astrololo.security")
|
||||||
|
|
||||||
|
MAX_TRACKED_CLIENTS_DEFAULT = 4096
|
||||||
|
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
|
||||||
|
|
||||||
|
|
||||||
|
# konfiguracja czytana leniwie — testy i restart mogą ją zmienić bez importu modułu
|
||||||
|
def app_user() -> str:
|
||||||
|
return os.getenv("APP_USER", "astrololo")
|
||||||
|
|
||||||
|
|
||||||
|
def app_password() -> str:
|
||||||
|
return os.getenv("APP_PASSWORD", "")
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit_per_min() -> int:
|
||||||
|
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
|
||||||
|
|
||||||
|
|
||||||
|
def trust_proxy() -> bool:
|
||||||
|
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
PUBLIC_PATHS = frozenset({"/health"})
|
||||||
|
PUBLIC_PREFIXES = ("/static/",)
|
||||||
|
|
||||||
|
_hits: dict[str, deque[float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def auth_enabled() -> bool:
|
||||||
|
return bool(app_password())
|
||||||
|
|
||||||
|
|
||||||
|
def _is_public(path: str) -> bool:
|
||||||
|
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def _authorized(header: str | None) -> bool:
|
||||||
|
if not header or not header.lower().startswith("basic "):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
||||||
|
user, _, password = raw.partition(":")
|
||||||
|
except (binascii.Error, UnicodeDecodeError, IndexError):
|
||||||
|
return False
|
||||||
|
# porównanie odporne na atak czasowy; oba pola muszą się zgadzać
|
||||||
|
ok_user = secrets.compare_digest(user, app_user())
|
||||||
|
ok_pass = secrets.compare_digest(password, app_password())
|
||||||
|
return ok_user and ok_pass
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
"""Adres, po którym rozliczamy limit żądań.
|
||||||
|
|
||||||
|
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
|
||||||
|
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
|
||||||
|
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
|
||||||
|
wyklika limit, odcięłaby pozostałe.
|
||||||
|
|
||||||
|
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
|
||||||
|
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
|
||||||
|
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
|
||||||
|
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
|
||||||
|
klient, więc nie znaczą nic.
|
||||||
|
"""
|
||||||
|
peer = request.client.host if request.client else "?"
|
||||||
|
if not trust_proxy():
|
||||||
|
return peer
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "")
|
||||||
|
if forwarded:
|
||||||
|
last = forwarded.rsplit(",", 1)[-1].strip()
|
||||||
|
if last:
|
||||||
|
return last
|
||||||
|
return request.headers.get("x-real-ip", "").strip() or peer
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limited(client: str) -> bool:
|
||||||
|
cap = rate_limit_per_min()
|
||||||
|
if cap <= 0:
|
||||||
|
return False
|
||||||
|
now = time.monotonic()
|
||||||
|
window = _hits.get(client)
|
||||||
|
if window is None:
|
||||||
|
if len(_hits) >= MAX_TRACKED_CLIENTS:
|
||||||
|
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
|
||||||
|
window = _hits[client] = deque()
|
||||||
|
while window and now - window[0] > 60.0:
|
||||||
|
window.popleft()
|
||||||
|
if len(window) >= cap:
|
||||||
|
return True
|
||||||
|
window.append(now)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def install(app) -> None:
|
||||||
|
"""Podpina ochronę pod wszystkie ścieżki poza /health i /static."""
|
||||||
|
if not auth_enabled():
|
||||||
|
log.warning(
|
||||||
|
"UWAGA: APP_PASSWORD nie ustawione — aplikacja jest OTWARTA dla każdego, "
|
||||||
|
"kto ma do niej dostęp sieciowy, wraz z treścią baz interpretacyjnych."
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _guard(request: Request, call_next):
|
||||||
|
if _is_public(request.url.path):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
if _rate_limited(client_ip(request)):
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
||||||
|
status_code=429, headers={"Retry-After": "60"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if auth_enabled() and not _authorized(request.headers.get("authorization")):
|
||||||
|
return HTMLResponse(
|
||||||
|
"<h1>401 — wymagane logowanie</h1>", status_code=401,
|
||||||
|
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Kopiowanie do schowka dla przycisków [data-copy="#selektor"].
|
||||||
|
// clipboard API wymaga secure context (https/localhost), a aplikacja bywa serwowana
|
||||||
|
// po http://<ip> — dlatego jest awaryjne przejście na zaznaczenie + execCommand.
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
const btn = e.target.closest('[data-copy]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const src = document.querySelector(btn.getAttribute('data-copy'));
|
||||||
|
if (!src) return;
|
||||||
|
|
||||||
|
const done = ok => {
|
||||||
|
const label = btn.textContent;
|
||||||
|
btn.textContent = ok ? 'Skopiowano ✓' : 'Nie udało się — zaznacz i skopiuj ręcznie';
|
||||||
|
setTimeout(() => { btn.textContent = label; }, 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = src.value !== undefined ? src.value : src.textContent;
|
||||||
|
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => done(true), () => done(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// awaryjnie: zaznacz zawartość i spróbuj starym poleceniem
|
||||||
|
try {
|
||||||
|
src.focus();
|
||||||
|
if (src.select) src.select();
|
||||||
|
done(document.execCommand('copy'));
|
||||||
|
} catch (err) {
|
||||||
|
done(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Wyszukiwarka lokalizacji + mapa (OpenStreetMap / Leaflet, bez klucza API).
|
||||||
|
// Wpina się do formularza z polami input[name=lat] / input[name=lon]:
|
||||||
|
// - wpisanie nazwy/adresu/POI -> /geocode -> lista trafień -> klik ustawia lat/lon,
|
||||||
|
// - klik na mapie lub przeciągnięcie pineski -> ustawia lat/lon + /reverse pokazuje nazwę,
|
||||||
|
// - „Tu i teraz" (now.js) emituje event 'astrololo:coords' -> mapa się synchronizuje.
|
||||||
|
// Bez internetu wyszukiwarka/kafelki nie działają, ale ręczne lat/lon dalej tak.
|
||||||
|
(function () {
|
||||||
|
function ready(fn) {
|
||||||
|
if (document.readyState !== 'loading') fn();
|
||||||
|
else document.addEventListener('DOMContentLoaded', fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
ready(function () {
|
||||||
|
var picker = document.getElementById('geoPicker');
|
||||||
|
if (!picker || typeof L === 'undefined') return; // brak Leafleta = po cichu pas
|
||||||
|
var latEl = document.querySelector('input[name=lat]');
|
||||||
|
var lonEl = document.querySelector('input[name=lon]');
|
||||||
|
if (!latEl || !lonEl) return;
|
||||||
|
|
||||||
|
var searchEl = document.getElementById('geoSearch');
|
||||||
|
var searchBtn = document.getElementById('geoSearchBtn');
|
||||||
|
var mapToggle = document.getElementById('geoMapToggle');
|
||||||
|
var resultsEl = document.getElementById('geoResults');
|
||||||
|
var mapEl = document.getElementById('map');
|
||||||
|
|
||||||
|
var num = function (el) { var v = parseFloat(el.value); return isNaN(v) ? 0 : v; };
|
||||||
|
var curLat = function () { return num(latEl); };
|
||||||
|
var curLon = function () { return num(lonEl); };
|
||||||
|
|
||||||
|
var pin = L.divIcon({ className: 'geo-pin', html: '📍', iconSize: [24, 24], iconAnchor: [12, 24] });
|
||||||
|
var map = L.map(mapEl).setView([curLat(), curLon()], 12);
|
||||||
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
var marker = L.marker([curLat(), curLon()], { draggable: true, icon: pin }).addTo(map);
|
||||||
|
|
||||||
|
// Kontener mógł być renderowany zanim ustaliła się jego szerokość — poprawka.
|
||||||
|
setTimeout(function () { map.invalidateSize(); }, 60);
|
||||||
|
|
||||||
|
function setCoords(lat, lon, fromMap) {
|
||||||
|
latEl.value = lat.toFixed(4);
|
||||||
|
lonEl.value = lon.toFixed(4);
|
||||||
|
marker.setLatLng([lat, lon]);
|
||||||
|
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
|
||||||
|
if (fromMap) reverseName(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverseName(lat, lon) {
|
||||||
|
fetch('/reverse?lat=' + lat + '&lon=' + lon)
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (d) { if (d && d.name) searchEl.value = d.name; })
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
marker.on('dragend', function () {
|
||||||
|
var p = marker.getLatLng();
|
||||||
|
setCoords(p.lat, p.lng, true);
|
||||||
|
});
|
||||||
|
map.on('click', function (e) { setCoords(e.latlng.lat, e.latlng.lng, true); });
|
||||||
|
|
||||||
|
// ---- wyszukiwanie po nazwie ----
|
||||||
|
function renderResults(items) {
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
if (!items.length) { resultsEl.hidden = true; return; }
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.textContent = it.name;
|
||||||
|
li.addEventListener('click', function () {
|
||||||
|
setCoords(it.lat, it.lon, false);
|
||||||
|
searchEl.value = it.name;
|
||||||
|
resultsEl.hidden = true;
|
||||||
|
});
|
||||||
|
resultsEl.appendChild(li);
|
||||||
|
});
|
||||||
|
resultsEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSearch() {
|
||||||
|
var q = searchEl.value.trim();
|
||||||
|
if (q.length < 3) { resultsEl.hidden = true; return; }
|
||||||
|
fetch('/geocode?q=' + encodeURIComponent(q))
|
||||||
|
.then(function (r) { return r.ok ? r.json() : { results: [] }; })
|
||||||
|
.then(function (d) { renderResults(d.results || []); })
|
||||||
|
.catch(function () { renderResults([]); });
|
||||||
|
}
|
||||||
|
|
||||||
|
var timer = null;
|
||||||
|
searchBtn.addEventListener('click', doSearch);
|
||||||
|
searchEl.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); doSearch(); } // nie wysyłaj formularza
|
||||||
|
});
|
||||||
|
searchEl.addEventListener('input', function () {
|
||||||
|
clearTimeout(timer); timer = setTimeout(doSearch, 500); // debounce (limit Nominatim)
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- zwijanie/rozwijanie mapy ----
|
||||||
|
mapToggle.addEventListener('click', function () {
|
||||||
|
var hidden = mapEl.hasAttribute('hidden');
|
||||||
|
if (hidden) {
|
||||||
|
mapEl.removeAttribute('hidden');
|
||||||
|
mapToggle.textContent = 'Ukryj mapę';
|
||||||
|
setTimeout(function () { map.invalidateSize(); map.setView([curLat(), curLon()]); }, 60);
|
||||||
|
} else {
|
||||||
|
mapEl.setAttribute('hidden', '');
|
||||||
|
mapToggle.textContent = 'Pokaż mapę';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- synchronizacja z „Tu i teraz" ----
|
||||||
|
document.addEventListener('astrololo:coords', function () {
|
||||||
|
marker.setLatLng([curLat(), curLon()]);
|
||||||
|
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Podpowiedzi modeli zależne od wybranego dostawcy.
|
||||||
|
//
|
||||||
|
// Pole modelu jest CELOWO tekstowe (input + datalist), a nie zamkniętym <select>:
|
||||||
|
// katalog to tylko wygoda, a konto może mieć dostęp do modeli, o których kod nie
|
||||||
|
// wie. Puste pole = model domyślny dostawcy.
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const providerEl = document.getElementById('llmProvider');
|
||||||
|
const modelEl = document.getElementById('llmModel');
|
||||||
|
const listEl = document.getElementById('llmModelList');
|
||||||
|
const hintEl = document.getElementById('llmModelHint');
|
||||||
|
const dataEl = document.getElementById('llmCatalog');
|
||||||
|
if (!providerEl || !modelEl || !listEl || !dataEl) return;
|
||||||
|
|
||||||
|
let catalog = {};
|
||||||
|
let defaults = {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(dataEl.textContent || '{}');
|
||||||
|
catalog = parsed.providers || {};
|
||||||
|
defaults = parsed.defaults || {};
|
||||||
|
} catch (e) {
|
||||||
|
return; // brak katalogu — pole nadal działa jako wolny tekst
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = n =>
|
||||||
|
n >= 1000000 ? (n / 1000000) + 'M' : (n >= 1000 ? Math.round(n / 1000) + 'k' : String(n));
|
||||||
|
|
||||||
|
function refresh(resetValue) {
|
||||||
|
const provider = providerEl.value;
|
||||||
|
const models = catalog[provider] || [];
|
||||||
|
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
models.forEach(m => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m.id;
|
||||||
|
opt.label = m.label + ' · kontekst ' + fmt(m.context_window);
|
||||||
|
listEl.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallback = defaults[provider] || '';
|
||||||
|
modelEl.placeholder = fallback ? 'domyślny: ' + fallback : 'domyślny dostawcy';
|
||||||
|
|
||||||
|
// po zmianie dostawcy stary model nie ma sensu (np. gpt-4o u Anthropica)
|
||||||
|
if (resetValue) modelEl.value = '';
|
||||||
|
|
||||||
|
if (hintEl) {
|
||||||
|
const chosen = models.find(m => m.id === modelEl.value.trim());
|
||||||
|
hintEl.textContent = chosen
|
||||||
|
? chosen.label + ' — okno kontekstu ' + fmt(chosen.context_window) +
|
||||||
|
' tokenów, maksymalna odpowiedź ' + fmt(chosen.max_output) + '.'
|
||||||
|
: 'Zostaw puste, by użyć modelu domyślnego. Możesz też wpisać dowolny ' +
|
||||||
|
'identyfikator modelu, do którego Twoje konto ma dostęp.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
providerEl.addEventListener('change', () => refresh(true));
|
||||||
|
modelEl.addEventListener('input', () => refresh(false));
|
||||||
|
refresh(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// „Tu i teraz": uzupełnia datę/godzinę/strefę z przeglądarki oraz — jeśli to
|
||||||
|
// możliwe — lokalizację (lat/lon).
|
||||||
|
//
|
||||||
|
// UWAGA: geolokalizacja przeglądarki działa tylko w "secure context"
|
||||||
|
// (https:// lub localhost). Na http://<ip> przeglądarka NIE pyta o zgodę,
|
||||||
|
// tylko po cichu odmawia — dlatego pokazujemy jawny komunikat w #geoNote.
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const btn = document.getElementById('nowBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const note = document.getElementById('geoNote');
|
||||||
|
const say = msg => { if (note) note.textContent = msg; };
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
document.querySelector('input[name=date]').value =
|
||||||
|
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||||
|
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
||||||
|
|
||||||
|
if (!('geolocation' in navigator)) {
|
||||||
|
say('Ta przeglądarka nie udostępnia geolokalizacji — wpisz lat/lon ręcznie.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
say('Lokalizacja z przeglądarki wymaga HTTPS lub localhost (otwarto przez http://) — wpisz lat/lon ręcznie.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
say('Pobieram lokalizację…');
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
function (pos) {
|
||||||
|
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
||||||
|
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
||||||
|
document.dispatchEvent(new CustomEvent('astrololo:coords')); // zsynchronizuj mapę (geo.js)
|
||||||
|
say('Lokalizacja pobrana ✓');
|
||||||
|
},
|
||||||
|
function (err) {
|
||||||
|
say('Nie udało się pobrać lokalizacji: ' + (err && err.message ? err.message : 'odmowa dostępu'));
|
||||||
|
},
|
||||||
|
{ timeout: 8000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Okno postępu przy pisaniu horoskopu.
|
||||||
|
//
|
||||||
|
// Problem: generowanie trwa minutami, a zwykły POST formularza nie daje żadnego
|
||||||
|
// sygnału — aplikacja wygląda na zawieszoną. Zamiast udawanego paska postępu
|
||||||
|
// czytamy strumień RZECZYWISTYCH zdarzeń z serwera (NDJSON) i wypisujemy je
|
||||||
|
// jako log: budowa promptu, limity modelu, każda tura generowania.
|
||||||
|
//
|
||||||
|
// Degradacja: jeśli przeglądarka nie umie strumieniować `fetch`, nie przechwytujemy
|
||||||
|
// wysyłki — formularz idzie klasycznie i wszystko działa jak wcześniej, tylko bez okna.
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const canStream = typeof fetch === 'function' && typeof ReadableStream === 'function' &&
|
||||||
|
typeof TextDecoder === 'function';
|
||||||
|
const form = document.querySelector('form[action="/interpret"], form[action="/timeline"]');
|
||||||
|
if (!canStream || !form) return;
|
||||||
|
|
||||||
|
const profile = form.getAttribute('action') === '/timeline' ? 'period' : 'natal';
|
||||||
|
const btn = form.querySelector('button[value="horoscope"]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
// --- okno ---------------------------------------------------------------
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'progress-overlay';
|
||||||
|
overlay.hidden = true;
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="progress-box" role="dialog" aria-modal="true" aria-label="Postęp generowania">' +
|
||||||
|
'<div class="progress-head">' +
|
||||||
|
'<span class="progress-spinner" aria-hidden="true"></span>' +
|
||||||
|
'<strong id="progressTitle">Piszę horoskop…</strong>' +
|
||||||
|
'<span class="progress-clock" id="progressClock">0:00</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<ol class="progress-log" id="progressLog"></ol>' +
|
||||||
|
'<p class="muted small">Nie zamykaj tej karty — generowanie trwa na serwerze.</p>' +
|
||||||
|
'<div class="actions"><button type="button" class="ghost" id="progressClose" hidden>Zamknij</button></div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const logEl = overlay.querySelector('#progressLog');
|
||||||
|
const clockEl = overlay.querySelector('#progressClock');
|
||||||
|
const titleEl = overlay.querySelector('#progressTitle');
|
||||||
|
const closeEl = overlay.querySelector('#progressClose');
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function addLine(text, kind) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
if (kind) li.className = 'log-' + kind;
|
||||||
|
const now = new Date();
|
||||||
|
li.textContent = String(now.getHours()).padStart(2, '0') + ':' +
|
||||||
|
String(now.getMinutes()).padStart(2, '0') + ':' +
|
||||||
|
String(now.getSeconds()).padStart(2, '0') + ' ' + text;
|
||||||
|
logEl.appendChild(li);
|
||||||
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startClock() {
|
||||||
|
const t0 = Date.now();
|
||||||
|
timer = setInterval(function () {
|
||||||
|
const s = Math.floor((Date.now() - t0) / 1000);
|
||||||
|
clockEl.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(title, allowClose) {
|
||||||
|
if (timer) { clearInterval(timer); timer = null; }
|
||||||
|
titleEl.textContent = title;
|
||||||
|
overlay.querySelector('.progress-spinner').style.visibility = 'hidden';
|
||||||
|
if (allowClose) closeEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEl.addEventListener('click', function () { overlay.hidden = true; });
|
||||||
|
|
||||||
|
// --- przechwycenie wysyłki ----------------------------------------------
|
||||||
|
btn.addEventListener('click', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!form.reportValidity()) return; // te same reguły co przy zwykłej wysyłce
|
||||||
|
|
||||||
|
const data = new FormData(form);
|
||||||
|
data.set('profile', profile);
|
||||||
|
|
||||||
|
logEl.innerHTML = '';
|
||||||
|
closeEl.hidden = true;
|
||||||
|
overlay.querySelector('.progress-spinner').style.visibility = '';
|
||||||
|
titleEl.textContent = 'Piszę horoskop…';
|
||||||
|
overlay.hidden = false;
|
||||||
|
startClock();
|
||||||
|
addLine('Wysyłam żądanie…');
|
||||||
|
|
||||||
|
fetch('/horoscope/stream', { method: 'POST', body: data })
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok || !response.body) throw new Error('HTTP ' + response.status);
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
function pump() {
|
||||||
|
return reader.read().then(function (chunk) {
|
||||||
|
if (chunk.done) return;
|
||||||
|
buffer += decoder.decode(chunk.value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop(); // ostatni może być niepełny
|
||||||
|
lines.forEach(function (raw) {
|
||||||
|
if (!raw.trim()) return;
|
||||||
|
let ev;
|
||||||
|
try { ev = JSON.parse(raw); } catch (e) { return; }
|
||||||
|
if (ev.type === 'ping') return; // sam heartbeat, nie logujemy
|
||||||
|
if (ev.type === 'result') {
|
||||||
|
addLine(ev.message || 'Gotowe.', 'ok');
|
||||||
|
if (ev.html) {
|
||||||
|
const host = document.getElementById('promptResult');
|
||||||
|
if (host) host.innerHTML = ev.html;
|
||||||
|
}
|
||||||
|
finish('Gotowe', true);
|
||||||
|
overlay.hidden = true;
|
||||||
|
const host = document.getElementById('promptResult');
|
||||||
|
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
|
||||||
|
: ev.type === 'warn' ? 'warn' : null);
|
||||||
|
if (ev.type === 'error') finish('Nie udało się', true);
|
||||||
|
});
|
||||||
|
return pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pump();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
addLine('Połączenie przerwane: ' + e.message, 'err');
|
||||||
|
addLine('Możesz spróbować ponownie — nic nie zostało utracone.');
|
||||||
|
finish('Nie udało się', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,3 +50,69 @@ table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: v
|
|||||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
||||||
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
tr:last-child td { border-bottom: none; }
|
tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: .85rem; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
.sig-item { margin-top: 1.25rem; }
|
||||||
|
.sig-head { padding: .5rem 0; }
|
||||||
|
.samples { margin-top: .3rem; }
|
||||||
|
.samples td { font-size: .92rem; vertical-align: top; }
|
||||||
|
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
|
||||||
|
.facet { margin: .4rem 0 .6rem 1rem; }
|
||||||
|
.facet-head { color: var(--ink); font-size: .9rem; }
|
||||||
|
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||||
|
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||||
|
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
||||||
|
|
||||||
|
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
|
||||||
|
.geo { margin-top: .9rem; }
|
||||||
|
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||||
|
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
|
||||||
|
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
|
||||||
|
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
|
||||||
|
.geo-results li:last-child { border-bottom: none; }
|
||||||
|
.geo-results li:hover { background: var(--panel); color: #fff; }
|
||||||
|
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.geo-map[hidden] { display: none; }
|
||||||
|
.geo-attrib { margin: .4rem 0 0; }
|
||||||
|
.geo-attrib a { color: var(--accent); }
|
||||||
|
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||||
|
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||||
|
.leaflet-control-attribution { font-size: .68rem; }
|
||||||
|
|
||||||
|
/* Generator promptu do LLM (LOG-29/30) */
|
||||||
|
textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
|
||||||
|
background: #12132a; color: var(--ink); border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
|
||||||
|
textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* Okno postępu przy generowaniu horoskopu */
|
||||||
|
/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
|
||||||
|
atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
|
||||||
|
kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
|
||||||
|
wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
|
||||||
|
.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1200; padding: 1rem; }
|
||||||
|
.progress-overlay[hidden] { display: none; }
|
||||||
|
.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
|
||||||
|
padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
|
||||||
|
.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
|
||||||
|
.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
|
||||||
|
color: var(--muted); font-size: .9rem; }
|
||||||
|
.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
|
||||||
|
border: 2px solid var(--line); border-top-color: var(--accent);
|
||||||
|
animation: spin .8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
|
||||||
|
.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
|
||||||
|
background: #12132a; border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
max-height: 15rem; overflow-y: auto;
|
||||||
|
font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
|
||||||
|
.progress-log li { color: var(--ink); white-space: pre-wrap; }
|
||||||
|
.progress-log li.log-ok { color: #7fd18b; }
|
||||||
|
.progress-log li.log-warn { color: #e0c060; }
|
||||||
|
.progress-log li.log-err { color: #ef6b6b; }
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
/* required styles */
|
||||||
|
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-tile-container,
|
||||||
|
.leaflet-pane > svg,
|
||||||
|
.leaflet-pane > canvas,
|
||||||
|
.leaflet-zoom-box,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Prevents IE11 from highlighting tiles in blue */
|
||||||
|
.leaflet-tile::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||||
|
.leaflet-safari .leaflet-tile {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||||
|
.leaflet-safari .leaflet-tile-container {
|
||||||
|
width: 1600px;
|
||||||
|
height: 1600px;
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||||
|
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||||
|
.leaflet-container .leaflet-overlay-pane svg {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-marker-pane img,
|
||||||
|
.leaflet-container .leaflet-shadow-pane img,
|
||||||
|
.leaflet-container .leaflet-tile-pane img,
|
||||||
|
.leaflet-container img.leaflet-image-layer,
|
||||||
|
.leaflet-container .leaflet-tile {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container img.leaflet-tile {
|
||||||
|
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag {
|
||||||
|
-ms-touch-action: pinch-zoom;
|
||||||
|
/* Fallback for FF which doesn't support pinch-zoom */
|
||||||
|
touch-action: none;
|
||||||
|
touch-action: pinch-zoom;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tile {
|
||||||
|
filter: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile-loaded {
|
||||||
|
visibility: inherit;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 800;
|
||||||
|
}
|
||||||
|
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||||
|
.leaflet-overlay-pane svg {
|
||||||
|
-moz-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane { z-index: 400; }
|
||||||
|
|
||||||
|
.leaflet-tile-pane { z-index: 200; }
|
||||||
|
.leaflet-overlay-pane { z-index: 400; }
|
||||||
|
.leaflet-shadow-pane { z-index: 500; }
|
||||||
|
.leaflet-marker-pane { z-index: 600; }
|
||||||
|
.leaflet-tooltip-pane { z-index: 650; }
|
||||||
|
.leaflet-popup-pane { z-index: 700; }
|
||||||
|
|
||||||
|
.leaflet-map-pane canvas { z-index: 100; }
|
||||||
|
.leaflet-map-pane svg { z-index: 200; }
|
||||||
|
|
||||||
|
.leaflet-vml-shape {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.lvml {
|
||||||
|
behavior: url(#default#VML);
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* control positioning */
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 800;
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.leaflet-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.leaflet-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* zoom and fade animations */
|
||||||
|
|
||||||
|
.leaflet-fade-anim .leaflet-popup {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: opacity 0.2s linear;
|
||||||
|
-moz-transition: opacity 0.2s linear;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
}
|
||||||
|
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-animated {
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
-ms-transform-origin: 0 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
svg.leaflet-zoom-animated {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||||
|
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
}
|
||||||
|
.leaflet-zoom-anim .leaflet-tile,
|
||||||
|
.leaflet-pan-anim .leaflet-tile {
|
||||||
|
-webkit-transition: none;
|
||||||
|
-moz-transition: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* cursors */
|
||||||
|
|
||||||
|
.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaflet-grab {
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.leaflet-crosshair,
|
||||||
|
.leaflet-crosshair .leaflet-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.leaflet-popup-pane,
|
||||||
|
.leaflet-control {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.leaflet-dragging .leaflet-grab,
|
||||||
|
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||||
|
.leaflet-dragging .leaflet-marker-draggable {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* marker & overlays interactivity */
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-pane > svg path,
|
||||||
|
.leaflet-tile-container {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.leaflet-interactive,
|
||||||
|
.leaflet-image-layer.leaflet-interactive,
|
||||||
|
.leaflet-pane > svg path.leaflet-interactive,
|
||||||
|
svg.leaflet-image-layer.leaflet-interactive path {
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visual tweaks */
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #ddd;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
color: #0078A8;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
border: 2px dotted #38f;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general typography */
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general toolbar styles */
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.leaflet-bar a,
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:hover,
|
||||||
|
.leaflet-bar a:focus {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaflet-bar a.leaflet-disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar a {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zoom control */
|
||||||
|
|
||||||
|
.leaflet-control-zoom-in,
|
||||||
|
.leaflet-control-zoom-out {
|
||||||
|
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||||
|
text-indent: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* layers control */
|
||||||
|
|
||||||
|
.leaflet-control-layers {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers.png);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.leaflet-retina .leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers-2x.png);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers .leaflet-control-layers-list,
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-scrollbar {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-selector {
|
||||||
|
margin-top: 2px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 5px -10px 5px -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default icon URLs */
|
||||||
|
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||||
|
background-image: url(images/marker-icon.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* attribution and scale controls */
|
||||||
|
|
||||||
|
.leaflet-container .leaflet-control-attribution {
|
||||||
|
background: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a:hover,
|
||||||
|
.leaflet-control-attribution a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.leaflet-attribution-flag {
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
width: 1em;
|
||||||
|
height: 0.6669em;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control-scale {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control-scale {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
border: 2px solid #777;
|
||||||
|
border-top: none;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 2px 5px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
text-shadow: 1px 1px #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child) {
|
||||||
|
border-top: 2px solid #777;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||||
|
border-bottom: 2px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-attribution,
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* popup */
|
||||||
|
|
||||||
|
.leaflet-popup {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 13px 24px 13px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 17px 0;
|
||||||
|
margin: 1.3em 0;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -20px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
margin: -10px auto 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-moz-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||||
|
color: #757575;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||||
|
color: #585858;
|
||||||
|
}
|
||||||
|
.leaflet-popup-scrolled {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||||
|
-ms-zoom: 1;
|
||||||
|
}
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
width: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||||
|
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-control-zoom,
|
||||||
|
.leaflet-oldie .leaflet-control-layers,
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* div icon */
|
||||||
|
|
||||||
|
.leaflet-div-icon {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
/* Base styles for the element that has a tooltip */
|
||||||
|
.leaflet-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions */
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -12px;
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left {
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
right: 0;
|
||||||
|
margin-right: -12px;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
left: 0;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Prevent printers from removing background-images of controls. */
|
||||||
|
.leaflet-control {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user