Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10ade55525 | |||
| 2e9d3706ec | |||
| c73ba6a964 | |||
| 16cf25468f | |||
| caf4fd80d1 | |||
| e7b6d5013b | |||
| 320a0ab24e | |||
| 1d0ff2f72e | |||
| f0d07ee8c3 | |||
| 71bb3b9c0a | |||
| ee3c515d59 | |||
| ac8a0e9fc6 | |||
| b838cf4723 | |||
| e2b50b284f | |||
| 003deb9404 | |||
| 6466ab89a9 | |||
| a833965909 | |||
| baf4e0e38a | |||
| 8b6ecc727d | |||
| 21a00b0000 | |||
| 4d1daab35a | |||
| aec3f84331 | |||
| 7266f671a4 | |||
| 548d9301f3 | |||
| 1be57a47d8 | |||
| 40f5e459e0 | |||
| 86a0f16f9e | |||
| e3114f3e7c | |||
| bc80745a94 | |||
| 6e7cfcbbd7 | |||
| 70c83cfc0c | |||
| 4c1e7f8808 | |||
| d3d9b365fe | |||
| dd32f7e82f | |||
| 78af6d4755 | |||
| 7b435d4263 | |||
| a9f2a038fa | |||
| b36b3bee19 | |||
| a0d1135db1 | |||
| 81e09aa6df | |||
| 8ebce816cd | |||
| 52b7c20c2a | |||
| 9b1e4dbb20 | |||
| 4bdfb673cc | |||
| 9a5d61b5eb | |||
| bb64758fa0 | |||
| 91c4f918dc | |||
| f5dec15e4d | |||
| 495f3734c5 | |||
| adce568729 | |||
| 998c83b26e | |||
| 4b17f2dd67 | |||
| 623603b157 | |||
| 171deff2d1 | |||
| 15964dd0df | |||
| 9ddcdea5e2 | |||
| 56131b209a | |||
| f24616d342 | |||
| 6c9029c496 | |||
| 8cc329ab17 | |||
| 76fbdefeaa | |||
| f34016a4a5 | |||
| 5c82e8bd9f | |||
| 40c9bf7988 | |||
| 1d7f3e2136 | |||
| ace76183e5 | |||
| f1956a08ff | |||
| e8f868e907 | |||
| 56fdf01d7d | |||
| 23416cb1f9 | |||
| 473d059a6a | |||
| 929b691238 | |||
| 114b7eebdf | |||
| 163ace4283 | |||
| f33cdc88f4 | |||
| 877ec91ff0 | |||
| 487dbb8fd3 | |||
| 5203ba9e76 | |||
| 64d1afc76d | |||
| 4da5f5fe7e | |||
| 4ef90b30bc | |||
| 73fd41b9d3 | |||
| 752f477a27 | |||
| 04b26afa6d | |||
| 2892b71be3 | |||
| 6f87b2b323 | |||
| a2aabd37a5 | |||
| de5d58958c | |||
| ab6fc0722c | |||
| 6ced2ddd22 | |||
| 79bce8ae90 | |||
| 85e9182f12 | |||
| c75f8377bf | |||
| 5234e86c95 | |||
| 0ac8df250c | |||
| 9323803cb4 | |||
| 678c3bad76 | |||
| 88a1ecd3c4 | |||
| b28c612bf7 | |||
| 2bda4e311d | |||
| 5d0d86cb90 | |||
| 9d5b65ddbd | |||
| cff0ac194c | |||
| 73f39e7df8 | |||
| 96d983b26a | |||
| c4a181b810 | |||
| 97ad21d2e4 | |||
| 2dbd3410e9 | |||
| ca458fd741 | |||
| b013831492 | |||
| d14a77360a | |||
| a8c3072e62 | |||
| 93932246f3 | |||
| 8d579de34a | |||
| db3d1e5117 | |||
| 4a86d34f5a | |||
| fb317172ff | |||
| f6323cac10 | |||
| 243d02d55b | |||
| 166b438f83 | |||
| 41d7ca491d | |||
| 6acd3546fb | |||
| 82809665ff | |||
| 0e74566a78 | |||
| cea6907f84 | |||
| dabbaef9dd | |||
| a81ae698a3 | |||
| eef67d37b5 | |||
| 94c3023d3a | |||
| e4f6a16c9c | |||
| 413c46b5dd | |||
| 340b3058e4 | |||
| 376a4bfded | |||
| 3195d9b003 | |||
| e69c0714b9 | |||
| 9bf297d461 |
@@ -0,0 +1,33 @@
|
||||
# Kontekst builda dla obrazów budowanych z KORZENIA repo (dziś: pomocniczy obraz
|
||||
# wyroczni w CI — patrz .gitea/workflows/tests.yml). Obrazy usług mają własne
|
||||
# konteksty (services/<usługa>), więc ten plik ich nie dotyczy.
|
||||
#
|
||||
# UWAGA: docker NIE czyta .gitignore. Bez tego pliku do demona poleciałby m.in.
|
||||
# lokalny wirtualenv (~344 MB) i jądra efemeryd — a runner miał już incydent
|
||||
# „no space left on device".
|
||||
|
||||
# środowiska lokalne
|
||||
.env/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# cache Pythona i narzędzi
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# dane generowane/pobierane, odtwarzalne
|
||||
**/.ephemeris/
|
||||
**/.cache/
|
||||
|
||||
# historia i metadane repo
|
||||
.git/
|
||||
.gitea/
|
||||
.claude/
|
||||
|
||||
# rzeczy nieużywane w obrazach
|
||||
docs/
|
||||
*.md
|
||||
@@ -0,0 +1,34 @@
|
||||
name: build-render
|
||||
|
||||
# Osobny pipeline dla uslugi render (raport PDF, PRE-24) — celowo ODDZIELONY od
|
||||
# glownego build.yaml (data/logic/presentation). Obraz dzwiga TeX Live (setki MB),
|
||||
# wiec budowanie go przy KAZDYM pushu do mastera spowalnialoby kazdy deploy — a to
|
||||
# wlasnie ta izolacja mial usunac (patrz services/render/app/main.py, LOG-27).
|
||||
# Buduje sie tylko, gdy zmienia sie sama usluga.
|
||||
#
|
||||
# Obraz konsumuje astrololo/render.yaml w repo `deploy`.
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'services/render/**'
|
||||
- '.gitea/workflows/build-render.yaml'
|
||||
workflow_dispatch: {} # reczne odpalenie (bootstrap pierwszego obrazu)
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Login
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||
- name: Build & push render (TeX Live — pierwszy build trwa dluzej)
|
||||
run: |
|
||||
TAG=${GITHUB_SHA::8}
|
||||
IMG=gitea.czernobog.pl/gitea/astrololo-render
|
||||
# Dockerfile sprawdza obecnosc xelatex + rsvg-convert przy budowie, wiec
|
||||
# build jest zarazem testem, ze obraz ma komplet narzedzi.
|
||||
docker build -t $IMG:$TAG -t $IMG:latest ./services/render
|
||||
docker push $IMG:$TAG
|
||||
docker push $IMG:latest
|
||||
echo "Zbudowano i wypchnieto: $IMG:$TAG (+ latest)"
|
||||
@@ -0,0 +1,32 @@
|
||||
name: build-swisseph
|
||||
|
||||
# Osobny pipeline dla silnika B (Swiss Ephemeris, AGPL) — celowo ODDZIELONY od
|
||||
# głównego build.yaml (data/logic/presentation). Buduje się tylko, gdy zmienia się
|
||||
# sam silnik, i nie miesza obrazu AGPL do pipeline'u permisywnego produktu.
|
||||
#
|
||||
# Obraz konsumuje profil deployu `astrololo-swisseph` w repo `deploy`.
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'services/engine-swisseph/**'
|
||||
- '.gitea/workflows/build-swisseph.yaml'
|
||||
workflow_dispatch: {} # ręczne odpalenie (bootstrap pierwszego obrazu)
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Login
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||
- name: Build & push engine-swisseph (AGPL, izolowany)
|
||||
run: |
|
||||
TAG=${GITHUB_SHA::8}
|
||||
IMG=gitea.czernobog.pl/gitea/astrololo-engine-swisseph
|
||||
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312) — build jest
|
||||
# zarazem realnym testem Dockerfile'a.
|
||||
docker build -t $IMG:$TAG -t $IMG:latest ./services/engine-swisseph
|
||||
docker push $IMG:$TAG
|
||||
docker push $IMG:latest
|
||||
echo "Zbudowano i wypchnięto: $IMG:$TAG (+ latest)"
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
# astrodemo dołącza do tej samej pętli: dzieli warstwę logiczną i łącze,
|
||||
# więc jego obraz ma powstawać z tego samego commita co reszta produktu.
|
||||
- name: Build & push (data, logic, presentation, astrodemo)
|
||||
run: |
|
||||
TAG=${GITHUB_SHA::8}
|
||||
for SVC in data logic presentation astrodemo; do
|
||||
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
|
||||
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
|
||||
done
|
||||
echo "Tag: $TAG"
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Wyrocznia domów — nocny przemiał
|
||||
|
||||
# DLACZEGO TU, A NIE JAKO JOB W KLASTRZE
|
||||
# Pierwotny plan zakładał CronJob w k3s, bo „duży przemiał jest kosztowny".
|
||||
# Pomiar tego nie potwierdził: 500 000 przypadków × 13 systemów = 70 mln porównań
|
||||
# w 64 sekundy, skalowanie liniowe. Osobny obraz w rejestrze (który już raz zapchał
|
||||
# dysk hosta), manifest, CronJob i kopia harnessu poza repo byłyby infrastrukturą
|
||||
# do problemu, którego nie ma — a każda kopia harnessu poza repo to ryzyko cichego
|
||||
# rozjazdu z kodem, który ma testować.
|
||||
#
|
||||
# Zestaw brzegowy blokuje KAŻDY build (patrz tests.yml). Tutaj chodzi o co innego:
|
||||
# duża losowa próbka z INNYM ZIARNEM co noc, żeby z czasem przeczesać dziedzinę
|
||||
# gęściej, niż zrobi to pojedynczy przebieg.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 2 * * *' # 02:17 — poza godzinami budowania
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
count:
|
||||
description: 'Liczba przypadków'
|
||||
default: '500000'
|
||||
seed:
|
||||
description: 'Ziarno (puste = z daty)'
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Przemiał losowy (13 systemów)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Obraz wyroczni (silnik B + nasza logika + harness)
|
||||
run: |
|
||||
docker build -t astrololo/engine-swisseph:sweep services/engine-swisseph
|
||||
# Pliki WBUDOWANE, nie montowane: job Gitea Actions sam działa w kontenerze,
|
||||
# więc `-v $PWD/...` rozwiązałoby się na HOŚCIE (docker cicho tworzy pusty
|
||||
# katalog i skrypt „znika"). Kontekst builda jest strumieniowany do demona.
|
||||
docker build -t astrololo/oracle:sweep -f - . <<'DOCKERFILE'
|
||||
FROM astrololo/engine-swisseph:sweep
|
||||
COPY services/logic /logic
|
||||
COPY tests/oracle /oracle
|
||||
ENV LOGIC_PATH=/logic
|
||||
DOCKERFILE
|
||||
|
||||
- name: Przemiał
|
||||
run: |
|
||||
COUNT="${{ inputs.count }}"; COUNT="${COUNT:-500000}"
|
||||
SEED="${{ inputs.seed }}"; SEED="${SEED:-$(date -u +%Y%m%d)}"
|
||||
echo "count=$COUNT seed=$SEED"
|
||||
# Kod wyjścia 1 przy przekroczeniu tolerancji ALBO niezgodności dziedziny,
|
||||
# więc job czerwieni się sam — bez parsowania tekstu raportu.
|
||||
docker run --rm astrololo/oracle:sweep \
|
||||
python /oracle/run.py --mode sweep --count "$COUNT" --seed "$SEED"
|
||||
|
||||
# Runner miał już incydent „no space left on device" — sprzątamy zawsze.
|
||||
- name: Usuń obrazy pomocnicze
|
||||
if: always()
|
||||
run: docker rmi -f astrololo/oracle:sweep astrololo/engine-swisseph:sweep 2>/dev/null || true
|
||||
@@ -0,0 +1,242 @@
|
||||
name: Testy
|
||||
|
||||
# Odpala się przy każdym pushu (dowolna gałąź) oraz dla pull requestów do master.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
logic-tests:
|
||||
name: Testy warstwy logicznej (silnik)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Python 3.12 (jak w obrazach Dockera)
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: services/logic/requirements-dev.txt
|
||||
|
||||
# Jądro efemeryd JPL (de421.bsp, ~17 MB). Cache'ujemy je między runami.
|
||||
- name: Cache jądra efemeryd
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: services/logic/.ephemeris
|
||||
key: ephemeris-de421
|
||||
|
||||
# Pobieramy jawnie (a nie licząc na auto-pobranie przez Skyfield), żeby
|
||||
# brak jądra był twardym błędem, a nie cichym pomijaniem testów.
|
||||
- name: Pobierz jądro efemeryd (gdy brak w cache)
|
||||
run: |
|
||||
mkdir -p services/logic/.ephemeris
|
||||
if [ ! -s services/logic/.ephemeris/de421.bsp ]; then
|
||||
curl -fSL --retry 3 --max-time 300 \
|
||||
-o services/logic/.ephemeris/de421.bsp \
|
||||
https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp
|
||||
fi
|
||||
ls -lh services/logic/.ephemeris/de421.bsp
|
||||
|
||||
- name: Instalacja zależności
|
||||
run: pip install -r services/logic/requirements-dev.txt
|
||||
|
||||
# CI=true (ustawiane przez GitHub) sprawia, że brak silnika = błąd,
|
||||
# a nie pominięcie — patrz tests/conftest.py.
|
||||
- name: Testy (pytest)
|
||||
working-directory: services/logic
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
|
||||
run: pytest tests -q -rs
|
||||
|
||||
presentation-tests:
|
||||
name: Testy warstwy prezentacji (dostęp do baz)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: services/presentation/requirements-dev.txt
|
||||
- name: Instalacja zależności
|
||||
run: pip install -r services/presentation/requirements-dev.txt
|
||||
# Bramka chroniąca oryginalne bazy — nietestowany kod ochronny jest gorszy
|
||||
# niż jego brak, bo daje złudzenie zabezpieczenia.
|
||||
- name: Testy (pytest)
|
||||
working-directory: services/presentation
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: pytest tests -q -rs
|
||||
|
||||
data-tests:
|
||||
name: Testy warstwy bazodanowej (ochrona baz)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: services/data/requirements-dev.txt
|
||||
- name: Instalacja zależności
|
||||
run: pip install -r services/data/requirements-dev.txt
|
||||
# Rekordy-pułapki (DAN-26): nietestowany kod ochronny jest gorszy niż jego
|
||||
# brak, bo daje złudzenie zabezpieczenia. Ta warstwa dotąd nie miała testów.
|
||||
- name: Testy (pytest)
|
||||
working-directory: services/data
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: pytest tests -q -rs
|
||||
|
||||
astrodemo-tests:
|
||||
name: Testy astrodemo
|
||||
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/astrodemo/requirements-dev.txt
|
||||
- name: Instalacja zależności
|
||||
run: pip install -r services/astrodemo/requirements-dev.txt
|
||||
# Demo rozmawia z warstwą danych PRODUKCJI, więc jego powierzchnia musi być
|
||||
# pilnowana tak samo jak reszty: testy sprawdzają m.in., że nie przybyła
|
||||
# żadna trasa poza dwiema funkcjami.
|
||||
- name: Testy (pytest)
|
||||
working-directory: services/astrodemo
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: pytest tests -q -rs
|
||||
|
||||
swisseph-image:
|
||||
name: Build obrazu silnika B (swisseph)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312), więc ten
|
||||
# build jest realnym testem Dockerfile'a — nie tylko pobraniem paczek.
|
||||
- name: docker build
|
||||
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
|
||||
|
||||
# Test biegnie WEWNĄTRZ obrazu, bez sieci i bez kontenera w tle. Poprzednia
|
||||
# wersja startowała kontener w tle (--name swe) i pukała curl-em w
|
||||
# localhost:8003 — co miało dwie wady:
|
||||
# 1. nie sprzątała kontenera, więc każdy kolejny przebieg padał na
|
||||
# konflikcie nazwy (Conflict. The container name "/swe" is already in use),
|
||||
# 2. job Gitea Actions sam działa w kontenerze, a -p publikuje port na
|
||||
# HOŚCIE — więc localhost joba to nie ten sam localhost.
|
||||
# Wywołanie funkcji endpointów wprost omija oba problemy, a sprawdza to samo:
|
||||
# obraz się zbudował, pyswisseph liczy, kontrakt /positions się zgadza.
|
||||
# --rm gwarantuje, że nic nie zostaje po przebiegu.
|
||||
- name: Smoke test (health + pozycje) wewnątrz obrazu
|
||||
run: |
|
||||
docker run --rm astrololo/engine-swisseph:ci python - <<'PY'
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.main import DEFAULT_OBJECTS, PositionsRequest, health, positions
|
||||
|
||||
h = health()
|
||||
assert h["status"] == "ok", h
|
||||
print("health:", h)
|
||||
|
||||
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
|
||||
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
|
||||
req = PositionsRequest(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
||||
lat=50.0647, lon=19.9450)
|
||||
out = positions(req)
|
||||
by = {p["name"]: p for p in out["positions"]}
|
||||
|
||||
assert out["engine"] == "swisseph", out["engine"]
|
||||
assert len(by) == len(DEFAULT_OBJECTS), sorted(by)
|
||||
sun = by["Sun"]["longitude"]
|
||||
assert 39.5 < sun < 41.0, f"Slonce poza oczekiwanym zakresem: {sun}"
|
||||
nn, sn = by["North Node"]["longitude"], by["South Node"]["longitude"]
|
||||
assert abs(((sn - nn) % 360.0) - 180.0) < 1e-6, (nn, sn)
|
||||
|
||||
print(f"Sun={sun:.4f} NN={nn:.4f} obiektow={len(by)}")
|
||||
|
||||
# /houses — kontrakt parzystosci (LOG-28) po stronie DOMOW. Sprawdzamy
|
||||
# nie tylko, ze liczy, ale i ze ODMAWIA tam, gdzie system nie istnieje:
|
||||
# ciche podstawienie innego systemu byloby niewykrywalne dla wolajacego.
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.main import _HOUSE_CODES, HousesRequest, houses
|
||||
|
||||
WHEN = "1984-04-30T09:20:00Z"
|
||||
h = houses(HousesRequest(when_utc=WHEN, lat=50.0647, lon=19.9450, system="campanus"))
|
||||
assert h["engine"] == "swisseph" and h["system"] == "campanus"
|
||||
assert len(h["cusps"]) == 12, h["cusps"]
|
||||
assert [c["house"] for c in h["cusps"]] == list(range(1, 13))
|
||||
assert all(0.0 <= c["longitude"] < 360.0 for c in h["cusps"])
|
||||
assert {"Asc", "MC", "ARMC"} <= set(h["angles"]), h["angles"]
|
||||
|
||||
# kazdy ogloszony system musi dac 12 cuspow tam, gdzie ma definicje
|
||||
for name in _HOUSE_CODES:
|
||||
out = houses(HousesRequest(when_utc=WHEN, lat=50.0647, lon=19.9450, system=name))
|
||||
assert len(out["cusps"]) == 12, name
|
||||
print(f"/houses: {len(_HOUSE_CODES)} systemow OK")
|
||||
|
||||
for bad, why in ((dict(lat=69.65, lon=18.96, system="placidus"), "Tromso/placidus"),
|
||||
(dict(lat=50.0, lon=19.0, system="nie-ma-takiego"), "nieznany system")):
|
||||
try:
|
||||
houses(HousesRequest(when_utc=WHEN, **bad))
|
||||
raise AssertionError(f"{why}: mialo byc 422, a przeszlo")
|
||||
except HTTPException as e:
|
||||
assert e.status_code == 422, (why, e.status_code)
|
||||
print("/houses: odmowy poza dziedzina OK")
|
||||
|
||||
print("SMOKE OK")
|
||||
PY
|
||||
|
||||
# Zgodność naszych domów z wyrocznią (Swiss Ephemeris). Odpalamy WEWNĄTRZ
|
||||
# obrazu silnika B — tylko tam jest pyswisseph — montując naszą warstwę
|
||||
# logiczną i framework. Bez klastra i bez HTTP: to czysta funkcja.
|
||||
# BLOKUJE build: błędny system domów jest CICHY (wykres wygląda dobrze,
|
||||
# planety siedzą w złych domach), więc lepiej zatrzymać go przed wypuszczeniem
|
||||
# niż wykryć po fakcie.
|
||||
- name: Domy — zgodność z wyrocznią (brzegi + wnętrze)
|
||||
run: |
|
||||
# Pliki WBUDOWUJEMY w obraz, a nie montujemy przez -v. Powód ten sam,
|
||||
# dla którego wyżej nie startujemy kontenera w tle: job Gitea Actions sam
|
||||
# działa w kontenerze, więc `-v $PWD/...` docker rozwiązuje na HOŚCIE,
|
||||
# gdzie tej ścieżki nie ma. Docker nie zgłasza wtedy błędu — po cichu
|
||||
# tworzy PUSTY katalog, przez co skrypt „znika". Kontekst builda jest
|
||||
# strumieniowany do demona, więc działa niezależnie od tego, gdzie on stoi.
|
||||
docker build -t astrololo/oracle:ci -f - . <<'DOCKERFILE'
|
||||
FROM astrololo/engine-swisseph:ci
|
||||
COPY services/logic /logic
|
||||
COPY tests/oracle /oracle
|
||||
ENV LOGIC_PATH=/logic
|
||||
DOCKERFILE
|
||||
docker run --rm astrololo/oracle:ci python /oracle/run.py --mode build
|
||||
|
||||
# Obraz pomocniczy nie jest już potrzebny — a runner miał już incydent
|
||||
# „no space left on device", więc sprzątamy po sobie od razu.
|
||||
- name: Usuń obraz pomocniczy wyroczni
|
||||
if: always()
|
||||
run: docker rmi -f astrololo/oracle:ci 2>/dev/null || true
|
||||
|
||||
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
|
||||
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
|
||||
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
|
||||
- name: Usuń osierocony kontener ze starych przebiegów
|
||||
if: always()
|
||||
run: docker rm -f swe 2>/dev/null || true
|
||||
|
||||
compile-all:
|
||||
name: Kontrola składni wszystkich warstw
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
# Sam kompilator — bez instalowania zależności warstw (w tym AGPL-owego
|
||||
# silnika swisseph, który nie wchodzi do produktu).
|
||||
- name: py_compile
|
||||
run: python -m compileall -q services
|
||||
@@ -10,8 +10,14 @@ services/data/.cache/
|
||||
*.parquet
|
||||
*.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)
|
||||
services/data/data_files/*.xlsx
|
||||
# Rejestr plików (DAN-27) powstaje przy uruchomieniu — to stan, nie kod.
|
||||
services/data/data_files/.files-state.json
|
||||
!services/data/data_files/.gitkeep
|
||||
|
||||
# Narzędzia
|
||||
|
||||
@@ -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:
|
||||
@echo "up - uruchom wszystkie 3 warstwy (docker compose)"
|
||||
@echo "down - zatrzymaj"
|
||||
@echo "sample - wygeneruj przykładowe pliki .xlsx"
|
||||
@echo "reindex - zbuduj cache + indeks warstwy bazodanowej"
|
||||
@echo "migrate - ETL: Excel -> SQL"
|
||||
@echo "sql - uruchom z warstwą SQL (DATA_PROVIDER=sql)"
|
||||
@echo "dev-* - uruchom pojedynczą warstwę lokalnie (bez dockera)"
|
||||
@echo "install - zainstaluj zależności WSZYSTKICH warstw do aktywnego venv"
|
||||
@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 "migrate - ETL: Excel -> SQL"
|
||||
@echo "up / down - uruchom / zatrzymaj cały stos (Docker; wykryto: $(COMPOSE))"
|
||||
@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:
|
||||
docker compose up --build
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
# --- instalacja zależności do aktywnego środowiska ---
|
||||
install:
|
||||
$(PY) -m pip install \
|
||||
-r services/data/requirements.txt \
|
||||
-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:
|
||||
cd services/data && python scripts/make_sample_data.py
|
||||
cd services/data && $(PY) scripts/make_sample_data.py
|
||||
|
||||
reindex:
|
||||
cd services/data && python -m app.ingest.build_index
|
||||
cd services/data && $(PY) -m app.ingest.build_index
|
||||
|
||||
migrate:
|
||||
cd services/data && python -m app.ingest.to_sql
|
||||
cd services/data && $(PY) -m app.ingest.to_sql
|
||||
|
||||
sql:
|
||||
DATA_PROVIDER=sql docker compose up --build
|
||||
test:
|
||||
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) ---
|
||||
dev-data:
|
||||
@@ -36,3 +61,20 @@ dev-logic:
|
||||
|
||||
dev-presentation:
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
cd services/data && pip install -r requirements.txt && python scripts/make_sample_data.py
|
||||
make dev-data # terminal 1 -> :8002
|
||||
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
|
||||
- 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
|
||||
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
|
||||
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
|
||||
# updater test Mon 20 Jul 2026 19:52:08 CEST
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
services:
|
||||
data:
|
||||
image: gitea.czernobog.pl/gitea/astrololo-data:latest
|
||||
container_name: astrololo-data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATA_PROVIDER: ${DATA_PROVIDER:-excel}
|
||||
EXCEL_DIR: /app/data_files
|
||||
CACHE_DIR: /app/.cache
|
||||
INDEXED_KEYS: name,id,symbol
|
||||
volumes:
|
||||
- ./services/data/data_files:/app/data_files
|
||||
- data_cache:/app/.cache
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
logic:
|
||||
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
|
||||
container_name: astrololo-logic
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATA_URL: http://data:8002
|
||||
EPHEMERIS_ENGINE: own # silnik własny (permisywny) — domyślny
|
||||
# adres silnika B (AGPL) używany tylko w trybie porównawczym (profil comparison)
|
||||
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||
depends_on:
|
||||
- data
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||
# docker compose --profile comparison up
|
||||
# Nie wchodzi do domyślnego (zamkniętego) produktu — patrz services/engine-swisseph/LICENSE.
|
||||
engine-swisseph:
|
||||
image: gitea.czernobog.pl/gitea/astrololo-engine-swisseph:latest
|
||||
container_name: astrololo-engine-swisseph
|
||||
restart: unless-stopped
|
||||
profiles: ["comparison"]
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
presentation:
|
||||
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
|
||||
container_name: astrololo-presentation
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LOGIC_URL: http://logic:8001
|
||||
depends_on:
|
||||
- logic
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
volumes:
|
||||
data_cache:
|
||||
+22
-4
@@ -9,17 +9,35 @@ services:
|
||||
volumes:
|
||||
- ./services/data/data_files:/app/data_files
|
||||
- data_cache:/app/.cache
|
||||
ports:
|
||||
- "8002:8002"
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
logic:
|
||||
build: ./services/logic
|
||||
environment:
|
||||
DATA_URL: http://data:8002
|
||||
EPHEMERIS_ENGINE: own # silnik własny (permisywny) — domyślny
|
||||
# adres silnika B (AGPL) używany tylko w trybie porównawczym (profil comparison)
|
||||
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||
depends_on:
|
||||
- data
|
||||
ports:
|
||||
- "8001:8001"
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
# 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"]
|
||||
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania
|
||||
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
|
||||
# `docker compose run --publish`.
|
||||
|
||||
presentation:
|
||||
build: ./services/presentation
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,58 @@
|
||||
# Rekordy-pułapki (canary) — instrukcja (DAN-26)
|
||||
|
||||
Zabezpieczenie **detekcyjne**: nie zapobiega wyciekowi baz, ale pozwala go
|
||||
**wykryć** i wskazać **z której kopii** wyciekł. Bazy są kupione i są rdzeniem
|
||||
produktu — jeśli krążą gdzie indziej, chcemy to udowodnić.
|
||||
|
||||
Mechanizm żyje w warstwie danych (`services/data/app/canary.py`) i działa na
|
||||
wyjściu z `/search`, więc pułapki **nie docierają** ani do użytkownika, ani do
|
||||
promptu LLM (wymóg LOG-30) — niezależnie od dostawcy (Excel/SQL).
|
||||
|
||||
## Jak to działa
|
||||
|
||||
1. **Marker** — unikalny ciąg, który nie występuje w realnych danych, wpleciony
|
||||
w kilka wiarygodnie wyglądających rekordów-pułapek w bazach (np. w polu
|
||||
znaczącym: `Ve Tau ASTROLOLO-CANARY-7f3a9`).
|
||||
2. **Odsiewanie** — warstwa danych wykrywa rekord z markerem i usuwa go z wyników,
|
||||
zanim opuszczą usługę. Interpretacje i prompty są czyste (log `info`).
|
||||
3. **Tripwire** — jeśli zapytanie **celuje wprost** w marker (ktoś enumeruje bazę,
|
||||
a nie liczy horoskopu), leci `warning` — sygnał podejrzanego zachowania.
|
||||
|
||||
## Konfiguracja (per wdrożenie)
|
||||
|
||||
Zmienne środowiskowe usługi `data`:
|
||||
|
||||
| Zmienna | Znaczenie |
|
||||
|---|---|
|
||||
| `CANARY_MARKERS` | markery oddzielone przecinkami (kilka na wariant) |
|
||||
| `CANARY_VARIANT` | etykieta wariantu tego wdrożenia (np. `prod-2026`, `partnerX`) |
|
||||
|
||||
Bez `CANARY_MARKERS` mechanizm jest **przezroczysty** (zero kosztu). Markery są
|
||||
sekretem — trzymaj je jak `astrololo-auth` (poza repo GitOps), przez `secretKeyRef`.
|
||||
|
||||
## Rejestr wariant → kopia (traitor tracing)
|
||||
|
||||
Sedno atrybucji: **każda dystrybuowana kopia baz dostaje inny zestaw pułapek**,
|
||||
a Ty trzymasz mapę, który wariant trafił dokąd. Gdy zobaczysz pułapkę w cudzej
|
||||
kopii → sprawdzasz marker w rejestrze → wiesz, skąd wyciekła.
|
||||
|
||||
Rejestr trzymaj **poza kodem i repo** (arkusz/menedżer sekretów po stronie ops),
|
||||
np.:
|
||||
|
||||
| Wariant | Markery | Wdrożenie / odbiorca | Data |
|
||||
|---|---|---|---|
|
||||
| `prod-2026` | `…7f3a9`, `…b12c` | produkcja czernobog | 2026-08 |
|
||||
| `partnerX` | `…9de4`, `…0a1b` | kopia dla partnera X | 2026-08 |
|
||||
|
||||
## Wstrzyknięcie pułapek do baz (krok właściciela)
|
||||
|
||||
To robi właściciel na **realnych** plikach (kod tego nie robi — nie ruszamy
|
||||
kupionych baz automatycznie): dodać kilka rekordów-pułapek z markerem danego
|
||||
wariantu, w stylu nieodróżnialnym od prawdziwych wpisów. Kilka, wtopionych —
|
||||
łatwiej, gdy ktoś zna mechanizm, wyciąć jeden oczywisty niż wszystkie.
|
||||
|
||||
## Granice
|
||||
|
||||
Canary dowodzi pochodzenia tylko, gdy wyciek **zawiera** treść pułapki (pełna
|
||||
kopia — tak; parafraza — niekoniecznie). Nie wykrywa retencji *prawdziwej* treści
|
||||
u dostawcy LLM — od tego jest bramka LOG-32 i wniosek o Zero Data Retention.
|
||||
@@ -0,0 +1,195 @@
|
||||
# DAN-25 — zamknięcie dostępu do baz na NFS (TrueNAS SCALE)
|
||||
|
||||
Bazy interpretacyjne leżą na `192.168.1.34:/mnt/Tank1/astrololo`. Dziś udział jest
|
||||
osiągalny z całej sieci, więc **kto ma dostęp do LAN, bierze kompletne bazy
|
||||
w oryginale — z pominięciem logowania, limitów, audytu i canary**. Żadne
|
||||
zabezpieczenie w kodzie tego nie zamyka: to najkrótsza droga do wycieku.
|
||||
|
||||
Cel: udział `astrololo` widoczny **tylko dla trzech węzłów k3s**, **tylko do
|
||||
odczytu**, z **root_squash**.
|
||||
|
||||
## ⚠️ Zasada nadrzędna: ruszamy WYŁĄCZNIE udział astrololo
|
||||
|
||||
Tank1 obsługuje cały homelab — Proxmox (`proxmox-NFS`), conjurera (`conjurer_swap`,
|
||||
z **zapisem**), media, LXC-e, stację roboczą. **Nie dotykamy globalnych ustawień
|
||||
usługi NFS ani innych udziałów** — inaczej wywalimy VM-y, bota i bibliotekę mediów.
|
||||
Każda komenda niżej celuje w jeden konkretny udział.
|
||||
|
||||
Druga zasada: **w TrueNAS SCALE nie edytuje się `/etc/exports` ręcznie.** Plik
|
||||
generuje middleware i nadpisze każdą ręczną zmianę. Wszystko robimy przez `midclt`
|
||||
(albo GUI: *Shares → Unix (NFS) Shares*).
|
||||
|
||||
## Ustalone dane
|
||||
|
||||
| Co | Wartość |
|
||||
|---|---|
|
||||
| NAS | `192.168.1.34` (TrueNAS SCALE / Community Edition) |
|
||||
| Udział do zamknięcia | `/mnt/Tank1/astrololo` |
|
||||
| Węzły k3s (jedyni uprawnieni) | `192.168.1.73` (server), `192.168.1.80` (agent2), `192.168.1.81` (agent1) |
|
||||
| Kto montuje astrololo | wyłącznie pod `data` w ns `astrololo`, **read-only** |
|
||||
|
||||
---
|
||||
|
||||
## Faza 0 — rozpoznanie (nic nie zmienia)
|
||||
|
||||
```bash
|
||||
ssh admin@192.168.1.34
|
||||
```
|
||||
|
||||
Wersja systemu (potwierdza, że komendy niżej pasują):
|
||||
|
||||
```bash
|
||||
midclt call system.version
|
||||
```
|
||||
|
||||
Lista udziałów NFS z ich obecnymi ustawieniami — **stąd bierzemy ID udziału astrololo**:
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.query | python3 -m json.tool
|
||||
```
|
||||
|
||||
> W wyniku poszukaj wpisu ze ścieżką `/mnt/Tank1/astrololo` i zapamiętaj jego `id`.
|
||||
> **Sprawdź też, jak nazywają się pola** (`path` vs `paths`, `hosts`, `networks`,
|
||||
> `ro`, `maproot_user`, `mapall_user`) — middleware zmieniało ich nazwy między
|
||||
> wersjami SCALE. Dalsze komendy używają nazw z Twojego wyniku.
|
||||
|
||||
Kto jest teraz podłączony (żeby nie odciąć czegoś w trakcie pracy):
|
||||
|
||||
```bash
|
||||
ss -tn state established '( sport = :2049 )'
|
||||
```
|
||||
|
||||
## Faza 1 — dowód dziury (zrób PRZED zmianą)
|
||||
|
||||
Na **stacji roboczej** (Mac mini, czyli host spoza klastra):
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/nfs-test && sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
|
||||
```
|
||||
|
||||
```bash
|
||||
ls -la /tmp/nfs-test | head
|
||||
```
|
||||
|
||||
Jeśli widzisz pliki baz — **to jest dokładnie problem, który zamykamy**. Odmontuj:
|
||||
|
||||
```bash
|
||||
sudo umount /tmp/nfs-test
|
||||
```
|
||||
|
||||
## Faza 2 — kopia obecnej konfiguracji (możliwość cofnięcia)
|
||||
|
||||
Na NAS-ie, podstaw `<ID>` z Fazy 0:
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.query '[["id","=",<ID>]]' > /root/astrololo-nfs-share.backup.json && cat /root/astrololo-nfs-share.backup.json
|
||||
```
|
||||
|
||||
## Faza 3 — zawężenie udziału
|
||||
|
||||
Jedna komenda ustawia wszystkie trzy zabezpieczenia naraz: listę hostów, tylko
|
||||
odczyt i root_squash. Podstaw `<ID>`:
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.update <ID> '{"hosts": ["192.168.1.73", "192.168.1.80", "192.168.1.81"], "ro": true, "maproot_user": null, "maproot_group": null, "mapall_user": null, "mapall_group": null}'
|
||||
```
|
||||
|
||||
Co robi każdy element:
|
||||
|
||||
| Ustawienie | Znaczenie |
|
||||
|---|---|
|
||||
| `hosts` | eksport **tylko** dla trzech węzłów k3s — reszta LAN przestaje widzieć udział |
|
||||
| `ro: true` | tylko odczyt; aplikacja i tak montuje read-only, więc niczego nie łamie |
|
||||
| `maproot_*`, `mapall_*` = `null` | **root_squash**: root z klienta nie jest rootem na udziale |
|
||||
|
||||
Zastosuj i sprawdź, że middleware przepisał eksporty:
|
||||
|
||||
```bash
|
||||
midclt call service.reload nfs && exportfs -v | grep -A1 astrololo
|
||||
```
|
||||
|
||||
> Jeśli Twoja wersja nie ma `service.reload`, użyj GUI (*Shares → NFS → zapisz*),
|
||||
> co wymusi to samo.
|
||||
|
||||
## Faza 4 — weryfikacja (wszystkie cztery testy)
|
||||
|
||||
**1. Spoza klastra ma NIE działać.** Na Macu:
|
||||
|
||||
```bash
|
||||
sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
|
||||
```
|
||||
|
||||
Oczekiwane: `access denied` / `Operation not permitted`. **Jeśli montuje się dalej —
|
||||
zmiana nie zadziałała, nie idź dalej.**
|
||||
|
||||
**2. Z węzła klastra ma działać.**
|
||||
|
||||
```bash
|
||||
ssh 192.168.1.73 'sudo mount -t nfs -o ro 192.168.1.34:/mnt/Tank1/astrololo /mnt/test && ls /mnt/test | head -3 && sudo umount /mnt/test'
|
||||
```
|
||||
|
||||
**3. Aplikacja żyje.** Pody muszą wstać i realnie czytać bazy:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout restart deploy/data && kubectl -n astrololo rollout status deploy/data
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo exec deploy/data -- ls /app/data_files | head -3
|
||||
```
|
||||
|
||||
**4. Reszta homelabu nietknięta** — conjurer (zapis!) i pozostałe udziały:
|
||||
|
||||
```bash
|
||||
kubectl -n conjurer get pods
|
||||
```
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.query | python3 -c "import sys,json;[print(s.get('path') or s.get('paths'), '| hosts:', s.get('hosts'), '| ro:', s.get('ro')) for s in json.load(sys.stdin)]"
|
||||
```
|
||||
|
||||
Oczekiwane: **tylko** astrololo ma zawężone `hosts` i `ro: true`; reszta bez zmian.
|
||||
|
||||
## Faza 5 — wycofanie (gdyby coś padło)
|
||||
|
||||
```bash
|
||||
midclt call sharing.nfs.update <ID> '{"hosts": [], "ro": false}'
|
||||
```
|
||||
|
||||
```bash
|
||||
midclt call service.reload nfs
|
||||
```
|
||||
|
||||
To przywraca poprzedni stan (pełna kopia w `/root/astrololo-nfs-share.backup.json`).
|
||||
|
||||
---
|
||||
|
||||
## Pułapki, o których warto wiedzieć
|
||||
|
||||
**Hookscript Proxmoxa.** Na Proxmoxie działa `wait-truenas.sh`, który przed startem
|
||||
VM czeka w pętli na `showmount -e 192.168.1.34`. Zawężamy tylko udział astrololo,
|
||||
więc `showmount` nadal zwróci pozostałe eksporty i pętla przejdzie. Mimo to sprawdź
|
||||
po zmianie:
|
||||
|
||||
```bash
|
||||
ssh root@pve2 'showmount -e 192.168.1.34'
|
||||
```
|
||||
|
||||
**Aktualizacja baz przestanie działać przez NFS.** Po `ro: true` nikt nie wgra
|
||||
nowych plików baz przez ten udział — również Ty. Do wgrywania użyj GUI TrueNAS,
|
||||
SMB albo SSH bezpośrednio na NAS-ie. To celowe: udział ma być drogą tylko do
|
||||
czytania przez aplikację.
|
||||
|
||||
**`hosts` przyjmuje adresy IP, nie nazwy** — świadomie, żeby dostęp nie zależał od
|
||||
DNS-u (AdGuard na `.57`). Gdyby doszedł czwarty węzeł k3s, trzeba dopisać jego IP,
|
||||
inaczej pod `data` na nim nie wstanie.
|
||||
|
||||
**To nie jest uwierzytelnianie.** Lista IP zatrzymuje przypadkowy i oportunistyczny
|
||||
dostęp, ale adres da się podszyć w tej samej sieci. Docelowo (poza zakresem tego
|
||||
kroku): NFSv4 + Kerberos albo przeniesienie plików na wolumen nieosiągalny poza
|
||||
klastrem — tak mówi samo wymaganie DAN-25.
|
||||
|
||||
## Po wykonaniu
|
||||
|
||||
Zaktualizuj status DAN-25 w `docs/astrololo_wymagania.xlsx` na **Zrobione** (albo
|
||||
**W trakcie**, jeśli zostawiasz Kerberosa jako etap docelowy).
|
||||
@@ -0,0 +1,65 @@
|
||||
# Konta imienne i dziennik audytowy (PRE-17)
|
||||
|
||||
Zamiast jednego wspólnego hasła: **konta imienne**, bo przy bazach o realnej
|
||||
wartości handlowej trzeba wiedzieć **kto** sięgał do treści — i móc odciąć jedną
|
||||
osobę bez zmiany hasła całej reszcie.
|
||||
|
||||
## Zakładanie konta
|
||||
|
||||
Hasło podajesz interaktywnie (nie trafia do historii powłoki ani do listy procesów);
|
||||
na wyjściu dostajesz **hash**, nie hasło:
|
||||
|
||||
```bash
|
||||
cd services/presentation && python scripts/make_user.py alicja
|
||||
```
|
||||
|
||||
Wynik wklejasz do `APP_USERS` (wpisy po przecinku):
|
||||
|
||||
```
|
||||
APP_USERS='alicja:scrypt$…,bartek:scrypt$…'
|
||||
```
|
||||
|
||||
Hash liczy `scrypt` ze stdlib — **bez nowych zależności**. Sekret ustawiasz jak
|
||||
resztę (`kubectl create secret …`, `secretKeyRef`), nigdy w repo GitOps.
|
||||
|
||||
## Odebranie dostępu jednej osobie
|
||||
|
||||
Usuń jej wpis z `APP_USERS` i zrestartuj `presentation`. **Pozostali nie zmieniają
|
||||
haseł** — to była główna bolączka wspólnego hasła.
|
||||
|
||||
## Uwaga: wspólne hasło przestaje działać
|
||||
|
||||
Gdy `APP_USERS` jest ustawione, stare `APP_PASSWORD` **nie działa** (aplikacja
|
||||
zgłasza to ostrzeżeniem przy starcie). Celowo: działające obok kont wspólne hasło
|
||||
byłoby tylnym wejściem bez śladu w dzienniku, czyli dokładnie problemem, który to
|
||||
wymaganie zamyka. Po migracji usuń `APP_PASSWORD` z konfiguracji.
|
||||
|
||||
Zgodność wstecz: dopóki `APP_USERS` **nie** jest ustawione, `APP_USER`/`APP_PASSWORD`
|
||||
działa jak dotąd — aktualizacja nie wywraca istniejącego wdrożenia.
|
||||
|
||||
## Dziennik audytowy
|
||||
|
||||
Każde żądanie do chronionej ścieżki zostawia wpis na stdout (w k8s zbierany
|
||||
standardowo):
|
||||
|
||||
```
|
||||
2026-08-03 21:45:44 INFO AUDYT user=alicja ip=10.1.2.3 method=POST path=/interpret status=200 records=428 ms=1530
|
||||
```
|
||||
|
||||
| Pole | Znaczenie |
|
||||
|---|---|
|
||||
| `user` | kto (`-` przy nieudanym logowaniu — nie podpowiadamy, które konto istnieje) |
|
||||
| `path`, `method`, `status` | co robił i z jakim skutkiem |
|
||||
| `records` | **ile rekordów baz** oddaliśmy (`-` gdy żądanie nie dotyka baz) |
|
||||
| `ms` | czas obsługi |
|
||||
|
||||
`records` jest tu najważniejsze: pojedyncze zapytanie wygląda niewinnie, ale suma
|
||||
pokazuje **powolne wypompowywanie bazy** przez osobę uprawnioną — czego żadne
|
||||
uwierzytelnienie nie wykryje. Liczone m.in. dla wyszukiwarki sygnifikatorów,
|
||||
raportu interpretacji i **eksportu do Excela** (ten wynosi najwięcej naraz).
|
||||
|
||||
**W dzienniku nie ma treści** — ani rekordów, ani promptów, ani danych
|
||||
urodzeniowych. Logi byłyby kolejnym nośnikiem wycieku; do wykrycia nadużycia
|
||||
wystarczą metadane i liczby.
|
||||
|
||||
Poziom sterujesz przez `AUDIT_LEVEL` (domyślnie `INFO`).
|
||||
@@ -0,0 +1,168 @@
|
||||
# Konta i uprawnienia (PRE-27)
|
||||
|
||||
Rozszerzenie kont imiennych z [PRE-17](konta-i-audyt.md): konta zakłada się
|
||||
**z aplikacji**, a każde dostaje własny zestaw funkcji.
|
||||
|
||||
## Dwie zasady, z których wynika reszta
|
||||
|
||||
**1. Konto ograniczone widzi program KOMPLETNY — tylko mniejszy.**
|
||||
Nic nie może zdradzać, że istnieje coś więcej. Żadnych wyszarzonych zakładek,
|
||||
żadnego „brak uprawnień", żadnego 403 — bo **403 samo w sobie jest informacją**,
|
||||
że pod tym adresem coś jest. Ścieżka bez uprawnienia odpowiada **404**, tak samo
|
||||
jak adres, którego nie ma.
|
||||
|
||||
Z tej zasady wynikło też wyłączenie `/docs`, `/redoc` i `/openapi.json`.
|
||||
Automatyczna dokumentacja FastAPI wypisuje komplet tras — czyli spis wszystkich
|
||||
funkcji programu. Ochrona zakładek nic by nie dała, gdyby obok leżał ich katalog.
|
||||
(Znalezione testem, nie przeglądem kodu.)
|
||||
|
||||
**2. Konto administracyjne pochodzi WYŁĄCZNIE ze środowiska.**
|
||||
`APP_USER` / `APP_PASSWORD` (albo `APP_USERS`) — jak dotąd. To konto ma wszystkie
|
||||
uprawnienia i jako jedyne zarządza pozostałymi. **Nie leży w pliku kont**, więc
|
||||
nie da się go skasować ani ograniczyć z ekranu — nawet przez pomyłkę, nawet
|
||||
spreparowanym żądaniem. Konto założone w pliku o tym samym loginie **nie
|
||||
przesłoni** administracyjnego (kolejność sprawdzania jest odwrotna).
|
||||
|
||||
## Podział funkcji
|
||||
|
||||
**Ekrany** — zakładki widoczne w nawigacji:
|
||||
|
||||
| klucz | zakładka |
|
||||
|---|---|
|
||||
| `chart` | Horoskop |
|
||||
| `interpret` | Interpretacje |
|
||||
| `timeline` | Kalendarz |
|
||||
| `synastry` | Synastria |
|
||||
| `significators` | Sygnifikatory |
|
||||
| `compile` | Skompiluj |
|
||||
| `settings` | Ustawienia |
|
||||
|
||||
**Rozszerzenia** — poziomy złożoności wewnątrz ekranów:
|
||||
|
||||
| klucz | co daje |
|
||||
|---|---|
|
||||
| `houses_compare` | wybór systemu domów, zestawienie kilku obok siebie, obrót koła |
|
||||
| `extra_charts` | aspektarian, wykres deklinacji, oś antyscji |
|
||||
| `advanced_calc` | stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne |
|
||||
| `ai` | horoskopy pisane przez model językowy (**każde użycie kosztuje**) |
|
||||
| `export` | pobieranie PDF i Excela |
|
||||
|
||||
Konto bez `houses_compare` dostaje horoskop w Whole Sign i **nie widzi**, że
|
||||
systemów jest trzynaście. Konto bez `ai` nie zobaczy przycisku generowania ani
|
||||
nie wywoła go z pominięciem interfejsu.
|
||||
|
||||
## Gdzie leży granica
|
||||
|
||||
W handlerze, nie w szablonie. Ukrycie pola w formularzu chroni przed przypadkiem,
|
||||
ale nie przed kimś, kto zna nazwy pól — dlatego `_limit_options()` ścina opcje
|
||||
**po stronie serwera**, a rysunki dodatkowe bez uprawnienia w ogóle nie powstają
|
||||
(nie ma ich nawet w źródle strony).
|
||||
|
||||
Mapa `trasa → uprawnienie` jest **jedna**, w `app/features.py`. Rozproszenie jej
|
||||
po dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura
|
||||
jest niewidoczna do chwili, gdy ktoś ją znajdzie. Trasa bez wpisu w mapie wymaga
|
||||
uprawnień administracyjnych: **przeoczenie ma zamykać, nie otwierać**. Test
|
||||
przechodzi po wszystkich trasach aplikacji i wymaga, by każda była opisana.
|
||||
|
||||
### Gdy jedna trasa robi kilka rzeczy
|
||||
|
||||
Mapa tras nie wystarcza tam, gdzie jedna trasa obsługuje kilka funkcji naraz.
|
||||
`POST /interpret` liczy horoskop, ale to samo pole `action` prosi o wygenerowanie
|
||||
promptu, napisanie horoskopu przez model albo eksport arkusza. Konto, które ma
|
||||
mieć Interpretacje bez generowania, musi dostać tę trasę — więc granica przebiega
|
||||
wewnątrz niej, po akcjach: `_AKCJE_POD_UPRAWNIENIEM` przypisuje akcji uprawnienie,
|
||||
a `_dozwolona_akcja()` sprowadza żądanie bez uprawnienia do akcji domyślnej ekranu.
|
||||
|
||||
Sprowadza — nie odrzuca. Komunikat „brak uprawnień do generowania" sam w sobie
|
||||
mówiłby, że taka funkcja istnieje, czyli łamałby zasadę drugą po to, żeby
|
||||
wyegzekwować pierwszą. Akcja bez uprawnienia ma wyglądać na literówkę w formularzu.
|
||||
|
||||
### Ślad to nie tylko przycisk
|
||||
|
||||
Wymaganie brzmi „nie może być śladu", i to jest mocniejsze niż schowanie kontrolki.
|
||||
Największym wyciekiem po stronie generowania nie był przycisk, tylko **katalog
|
||||
modeli** — nazwy dostawców, nazwy modeli i rozmiary okien kontekstu — wstrzykiwany
|
||||
w stronę blokiem JSON na każdym ekranie z generowaniem, niezależnie od uprawnień.
|
||||
Dlatego `_llm_catalog_for()` oddaje pusty katalog kontu bez uprawnienia, a szablony
|
||||
trzymają pod bramką także znaczniki (`natalNote`, `reportNatal`), pliki skryptów
|
||||
(`models.js`, `progress.js`, `natal.js`, `predictions.js`) i **zdania opisujące
|
||||
funkcję** — podtytuł ekranu Skompiluj wymieniał interpretację od AI z nazwy.
|
||||
|
||||
Testu na to nie da się napisać przez „sprawdź, czy przycisku nie ma": trzeba
|
||||
sprawdzić, że w źródle strony nie ma żadnego z tych śladów, i mieć kontrolę
|
||||
pozytywną, że przy uprawnieniu wszystkie są. Inaczej test przechodzi także wtedy,
|
||||
gdy generowanie jest zepsute dla wszystkich.
|
||||
|
||||
## Pełna paranoja: ukrywanie jest nadrzędne
|
||||
|
||||
Właściciel produktu postawił to wyżej niż wygodę i wyżej niż czytelność
|
||||
komunikatów: *„nie chcę, żeby osoba wrzucająca bazy wiedziała, po co to robi
|
||||
i jak będzie w przyszłości działał program, bo to rozgada"*. Persona nazywa się
|
||||
**wgrywacz** — konto z uprawnieniami `files` + `files_input` i niczym więcej.
|
||||
|
||||
Nie ma dowiedzieć się: jakie inne funkcje istnieją, że teksty pisze model
|
||||
językowy i u jakiego dostawcy, do czego służą wgrywane pliki, co jest planowane,
|
||||
że istnieje walidacja plików, ani że istnieje konto, które widzi więcej.
|
||||
|
||||
### Wyciek prawie nigdy nie siedzi tam, gdzie się go szuka
|
||||
|
||||
Audyt sześciu kanałów potwierdził 26 wycieków. Ani jeden nie był przyciskiem.
|
||||
|
||||
| Kanał | Co wyciekało |
|
||||
|---|---|
|
||||
| `/static/**` poza bramką | komplet skryptów i arkuszy dla **niezalogowanego** |
|
||||
| komentarze w CSS/JS | pełne zdania po polsku o funkcjach, o kwarantannie i o tym, że administrator widzi więcej |
|
||||
| `styles.css` jako jeden plik | nazwy selektorów = spis funkcji programu |
|
||||
| `base.html` | skrypty kosmogramu na **każdej** stronie, łącznie z „przyszłą zakładką" |
|
||||
| komunikaty błędu | nazwa trasy, nazwa podsystemu, nazwa gałęzi rozwojowej, wewnętrzny `host:port` |
|
||||
| komunikat po wgraniu | słowo „administrator" — i **dwie różne treści**, czyli wyrocznia do odgadywania reguł walidacji |
|
||||
| `/openapi.json` warstw wewnętrznych | katalog wszystkich funkcji, bez logowania |
|
||||
|
||||
### Trzy zasady, które z tego wynikają
|
||||
|
||||
**Zasób jest częścią funkcji.** Skrypt i arkusz przechodzą przez tę samą bramkę
|
||||
co ekran (`features.STATIC`). Nazwa pliku jest zgadywalna, więc plik publiczny
|
||||
opowiada o funkcji równie dokładnie jak przycisk. Publiczny został jeden
|
||||
`base.css` — bo potrzebuje go ekran logowania — i dlatego nie wolno w nim
|
||||
umieścić niczego, co nazywa funkcję.
|
||||
|
||||
**Komentarz nie jedzie na drut.** `_asset_body()` usuwa komentarze przy
|
||||
serwowaniu. Zostają w repozytorium, gdzie są potrzebne. Ta jedna zmiana zamyka
|
||||
cztery z sześciu kanałów naraz.
|
||||
|
||||
**Różnica jest informacją.** Dwa różne komunikaty po wgraniu pliku były
|
||||
wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać odpowiedź. Teraz
|
||||
komunikat jest jeden, niezależnie od wyniku. Z tego samego powodu odmowa to
|
||||
404 identyczne z „nie ma takiej trasy", a akcja bez uprawnienia cofa się do
|
||||
domyślnej zamiast tłumaczyć, czego brakuje.
|
||||
|
||||
### Zapora słownikowa
|
||||
|
||||
Łatanie punkt po punkcie przegrywa z następną zmianą. Dlatego
|
||||
`test_slownik_zakazany.py` nie sprawdza miejsc, tylko przechodzi **wszystko**,
|
||||
co dane konto może pobrać, i szuka słów, które nie mają prawa paść.
|
||||
|
||||
Dwie z trzech list biorą się wprost z katalogu funkcji — nazwa funkcji, adres
|
||||
jej ekranu i nazwy jej zasobów — więc dopisanie funkcji automatycznie dopisuje
|
||||
je do tego, czego konto bez niej nie może zobaczyć. Trzecia lista, słownictwo
|
||||
dziedziny i mechanizmów, jest pisana ręcznie, bo katalog jej nie zna.
|
||||
|
||||
Test ma kontrolę pozytywną: dla administratora te same słowa **muszą** się
|
||||
pojawiać. Bez niej przechodziłby także wtedy, gdyby program był pusty.
|
||||
|
||||
## Gdzie leżą konta
|
||||
|
||||
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
|
||||
na NFS — **własny podkatalog prezentacji**, nie katalog z bazami: zamontowanie
|
||||
tutaj całego udziału obeszłoby bokiem zamknięcie dostępu z DAN-25.
|
||||
|
||||
Hasła wyłącznie jako hash scrypt, tym samym mechanizmem co `APP_USERS` — jedna
|
||||
implementacja, więc nie ma czego rozjechać. Zapis jest **atomowy** (plik
|
||||
tymczasowy + `os.replace` w tym samym katalogu): przerwanie zapisu nie obetnie
|
||||
pliku, czyli nie skasuje wszystkich kont naraz.
|
||||
|
||||
## Czego ten mechanizm NIE robi
|
||||
|
||||
Nie zastępuje ochrony baz na poziomie sieci ani NFS (DAN-25). Ktoś z dostępem do
|
||||
udziału albo do warstwy danych nadal je odczyta — uprawnienia w aplikacji
|
||||
ograniczają to, co widać **przez aplikację**, i tyle.
|
||||
@@ -0,0 +1,171 @@
|
||||
# LOG-33 — sekrety w spoczynku i procedura rotacji
|
||||
|
||||
Sekrety (`APP_PASSWORD`/`APP_USERS`, `INTERNAL_TOKEN`, klucze łącz AES, klucze API
|
||||
do dostawców LLM) trafiają do obiektów Secret w Kubernetesie, gdzie domyślnie są
|
||||
**tylko zakodowane base64** — jawne dla każdego, kto przeczyta magazyn stanu k3s
|
||||
albo ma prawo odczytu sekretów w namespace.
|
||||
|
||||
Ten dokument opisuje: **macierz rotacji** (co restartować przy zmianie czego),
|
||||
**procedury rotacji per sekret** i **kroki hartowania**, które wymagają dostępu do
|
||||
węzła.
|
||||
|
||||
---
|
||||
|
||||
## Macierz zależności — kto używa którego sekretu
|
||||
|
||||
Wyliczona z żywych deploymentów, nie z założeń:
|
||||
|
||||
| Sekret / klucz | Usługi, które go czytają | Restart obejmuje |
|
||||
|---|---|---|
|
||||
| `astrololo-auth` / **`INTERNAL_TOKEN`** | data, logic, presentation, **render** | **wszystkie cztery, równocześnie** |
|
||||
| `astrololo-auth` / `APP_PASSWORD`, `APP_USERS` | presentation | tylko presentation |
|
||||
| `astrololo-link` / `LINK_KEY_LOGIC_DATA` | logic, data | **para**: logic + data |
|
||||
| `astrololo-link` / `LINK_KEY_PRESENTATION_LOGIC` | presentation, logic | **para**: presentation + logic |
|
||||
| `astrololo-link` / `LINK_KEY_PRESENTATION_RENDER` | presentation, render | **para**: presentation + render |
|
||||
| `astrololo-llm` / `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | logic | tylko logic |
|
||||
|
||||
> **UWAGA — częsty błąd.** Wcześniejsza wersja tego wymagania mówiła o „wszystkich
|
||||
> **trzech** usługach" przy `INTERNAL_TOKEN`. To już nieprawda: `render` (PRE-24)
|
||||
> również go używa. Restart trzech zostawi render ze starym tokenem i **usługa po
|
||||
> cichu przestanie się dogadywać** — dokładnie ta awaria, przed którą wymaganie
|
||||
> ostrzega.
|
||||
|
||||
Klucze łącz są **parami** — rotacja jednego wymaga restartu tylko dwóch usług, nie
|
||||
całej czwórki. W trakcie wymiany para chwilowo się nie dogaduje (klucze muszą być
|
||||
zgodne po obu stronach łącza), dlatego restart obu naraz.
|
||||
|
||||
---
|
||||
|
||||
## Procedury rotacji
|
||||
|
||||
### A. Klucze LLM (najbezpieczniejsze do przećwiczenia)
|
||||
|
||||
Dotykają wyłącznie logiki, a awaria jest widoczna od razu i nieszkodliwa
|
||||
(niedostępna chmura, model lokalny działa dalej). **Zacznij ćwiczenie od tego.**
|
||||
|
||||
```bash
|
||||
read -rs -p "Nowy ANTHROPIC_API_KEY: " NEW; echo
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-llm \
|
||||
--from-literal=OPENAI_API_KEY="$(kubectl -n astrololo get secret astrololo-llm -o jsonpath='{.data.OPENAI_API_KEY}' | base64 -d)" \
|
||||
--from-literal=ANTHROPIC_API_KEY="$NEW" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
```bash
|
||||
unset NEW && kubectl -n astrololo rollout restart deploy/logic && kubectl -n astrololo rollout status deploy/logic
|
||||
```
|
||||
|
||||
### B. Klucz łącza (para usług)
|
||||
|
||||
Przykład dla `LINK_KEY_PRESENTATION_RENDER`. Pozostałe klucze zachowujemy bez zmian,
|
||||
odczytując je z istniejącego sekretu — inaczej skasowalibyśmy pozostałe łącza.
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-link \
|
||||
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
|
||||
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
|
||||
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout restart deploy/presentation deploy/render
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
|
||||
```
|
||||
|
||||
### C. `INTERNAL_TOKEN` (wszystkie cztery naraz)
|
||||
|
||||
Najbardziej wrażliwa rotacja: w trakcie usługi z różnymi tokenami **odrzucają się
|
||||
nawzajem**, więc restart musi objąć całą czwórkę.
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-auth \
|
||||
--from-literal=APP_PASSWORD="$(kubectl -n astrololo get secret astrololo-auth -o jsonpath='{.data.APP_PASSWORD}' | base64 -d)" \
|
||||
--from-literal=INTERNAL_TOKEN="$(openssl rand -hex 32)" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout restart deploy/data deploy/logic deploy/presentation deploy/render
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout status deploy/data && kubectl -n astrololo rollout status deploy/logic && kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
|
||||
```
|
||||
|
||||
> Jeśli używasz kont imiennych (PRE-17), zamiast `APP_PASSWORD` zachowaj `APP_USERS`
|
||||
> — patrz [`konta-i-audyt.md`](konta-i-audyt.md).
|
||||
|
||||
### Weryfikacja po KAŻDEJ rotacji
|
||||
|
||||
Sam `Running` nie wystarczy — pody wstaną nawet, gdy warstwy się nie dogadują.
|
||||
Trzeba sprawdzić **realny przelot przez wszystkie łącza**:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo get pods
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "aplikacja: %{http_code}\n" -u "<login>:<hasło>" https://astrololo.czernobog.pl/
|
||||
```
|
||||
|
||||
Policz horoskop w przeglądarce (dotyka presentation→logic→data) i wygeneruj PDF
|
||||
(dotyka presentation→render). Dopiero to potwierdza, że wszystkie cztery klucze
|
||||
i token są spójne.
|
||||
|
||||
---
|
||||
|
||||
## Hartowanie — kroki wymagające dostępu do węzła
|
||||
|
||||
### 1. Szyfrowanie sekretów w spoczynku (najważniejsze)
|
||||
|
||||
k3s z jednym serwerem trzyma stan w **SQLite**, nie w etcd, więc „szyfrowanie etcd"
|
||||
sprowadza się do wbudowanej funkcji k3s. Kroki są w sekcji „Co musisz zrobić sam"
|
||||
poniżej. Efekt: kopia pliku stanu albo snapshot VM przestaje być wyciekiem haseł
|
||||
i kluczy API.
|
||||
|
||||
**Granica:** klucz szyfrujący leży na tym samym serwerze. Chroni przed kradzieżą
|
||||
pliku/snapshotu — nie przed kimś, kto ma roota na węźle.
|
||||
|
||||
### 2. Ograniczenie tokenów kont serwisowych ✅
|
||||
|
||||
Zrobione: `automountServiceAccountToken: false` we wszystkich czterech usługach
|
||||
(deploy #13). Żadna nie rozmawia z API Kubernetesa — sekrety wstrzykuje kubelet,
|
||||
nie pod — więc token był zbędny, a stanowił gotowy punkt wyjścia do klastra.
|
||||
|
||||
### 3. RBAC
|
||||
|
||||
Stan sprawdzony: **zero RoleBindings** w `astrololo`, `cluster-admin` tylko dla
|
||||
`system:masters` i dwóch kont Helma w `kube-system`. Nie ma rozdanych nadmiarowych
|
||||
uprawnień do cofania.
|
||||
|
||||
Realna ekspozycja to **kubeconfig admina**. Sensowny krok: osobny, ograniczony
|
||||
kubeconfig do codziennej pracy, a admin tylko wtedy, gdy naprawdę potrzebny.
|
||||
|
||||
### 4. Sealed Secrets / SOPS — świadomie ODŁOŻONE
|
||||
|
||||
Dziś sekrety tworzone są ręcznie i **nie ma ich w repo GitOps** — czyli zasada
|
||||
„nie wpisywać sekretów do repozytorium" **jest już spełniona**. Kosztem jest
|
||||
odtwarzalność: po utracie klastra nikt nie wie, co tam było.
|
||||
|
||||
Sealed Secrets pozwoliłoby trzymać je w gicie w postaci zaszyfrowanej, ale to
|
||||
zmiana filozofii i **nowy pojedynczy punkt awarii**: utrata klucza kontrolera =
|
||||
utrata wszystkich sekretów. Rekomendacja: dopiero po punkcie 1, i tylko jeśli
|
||||
zależy Ci na odtwarzalności klastra z gita.
|
||||
|
||||
---
|
||||
|
||||
## Czego NIE robimy
|
||||
|
||||
- **Nie wpisujemy sekretów do logów.** Dziennik audytowy (PRE-17) niesie wyłącznie
|
||||
metadane i liczby.
|
||||
- **Nie wpisujemy sekretów do repo GitOps.** Manifesty odwołują się do sekretów
|
||||
przez `secretKeyRef` i celowo nie zawierają wartości.
|
||||
- **Nie zostawiamy haseł w historii powłoki** — stąd `read -rs` i odczyt istniejących
|
||||
wartości przez `kubectl … | base64 -d` zamiast wpisywania ich ponownie.
|
||||
@@ -0,0 +1,296 @@
|
||||
# Wdrożenie PRE-16 — HTTPS na wejściu i szyfrowanie łączy między warstwami
|
||||
|
||||
Instrukcja krok po kroku. **Kolejność ma znaczenie** — punkt „Dlaczego taka
|
||||
kolejność" niżej tłumaczy, co się stanie, jeśli ją zamienić.
|
||||
|
||||
Dotyczy dwóch pull requestów:
|
||||
|
||||
| Repo | PR | Co wnosi |
|
||||
|---|---|---|
|
||||
| `gitea/astrololo` | [#21](https://gitea.czernobog.pl/gitea/astrololo/pulls/21) | kod: szyfrowanie łączy, limit żądań za proxy |
|
||||
| `gitea/deploy` | [#4](https://gitea.czernobog.pl/gitea/deploy/pulls/4) | manifesty: Ingress, certyfikat, klucze łączy |
|
||||
|
||||
---
|
||||
|
||||
## Co się właściwie zmienia
|
||||
|
||||
**Na wejściu do aplikacji.** Dotąd logowanie szło przez HTTP Basic po zwykłym
|
||||
http — czyli hasło leciało siecią w postaci trywialnej do podsłuchania (base64 to
|
||||
nie szyfrowanie). Po zmianie wejście jest po https, a http odsyła na https.
|
||||
Przy okazji **odblokowują się dwie funkcje zepsute dziś z tego samego powodu**:
|
||||
geolokalizacja („Tu i teraz") i kopiowanie promptu do schowka działają wyłącznie
|
||||
w tzw. secure context i po http po prostu odmawiały.
|
||||
|
||||
**Między warstwami.** Prezentacja, logika i dane rozmawiały ze sobą otwartym
|
||||
tekstem wewnątrz klastra. Token międzywarstwowy mówił *kto* pyta, ale nie ukrywał
|
||||
*czego dotyczy odpowiedź* — a płyną nią surowe wiersze oryginalnych baz. Teraz
|
||||
każde ciało żądania i odpowiedzi jest szyfrowane **AES-256-GCM**, osobnym kluczem
|
||||
na każdą parę rozmówców.
|
||||
|
||||
**Wejście na świat pozostaje jedno: prompt do modelu.** Ta zmiana niczego tu nie
|
||||
rusza — dotyczy wyłącznie ruchu wewnątrz sieci i wejścia z przeglądarki.
|
||||
|
||||
---
|
||||
|
||||
## Zanim zaczniesz — stan wyjściowy
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo get deploy,svc
|
||||
kubectl -n astrololo get secret # powinny być: astrololo-auth, gitea-registry
|
||||
kubectl -n kube-system get svc traefik -o jsonpath='{.status.loadBalancer.ingress[*].ip}'; echo
|
||||
```
|
||||
|
||||
Zanotuj adres Traefika — będzie potrzebny w kroku 3. Sprawdź też, czy działa
|
||||
aplikacja w obecnej postaci (przez NodePort), żeby mieć punkt odniesienia.
|
||||
|
||||
---
|
||||
|
||||
## Krok 1 — sekret z kluczami łączy
|
||||
|
||||
**Przed czymkolwiek innym.** Klucze muszą istnieć, zanim pody spróbują wstać
|
||||
z nową konfiguracją, bo bez nich celowo **nie wystartują**.
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-link \
|
||||
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(openssl rand -hex 32)" \
|
||||
--from-literal=LINK_KEY_LOGIC_DATA="$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
Kluczy nikt nigdy nie musi oglądać — służą tylko usługom. Nie ma ich w repo
|
||||
GitOps i **nie ma ich tam wkładać**: cokolwiek trafi do gita, zostaje w historii
|
||||
na zawsze.
|
||||
|
||||
Dwa osobne klucze to nie ozdobnik. Przejęcie klucza prezentacji nie daje dostępu
|
||||
do warstwy danych, gdzie leżą całe bazy. Logika dostaje oba, bo rozmawia w obie
|
||||
strony; prezentacja i dane dostają wyłącznie swój.
|
||||
|
||||
Sprawdź:
|
||||
```bash
|
||||
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
|
||||
# oczekiwane: dwa klucze, każdy 64 znaki po odkodowaniu (32 bajty)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Krok 2 — cert-manager
|
||||
|
||||
Jednorazowo, na cały klaster:
|
||||
|
||||
```bash
|
||||
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml
|
||||
kubectl -n cert-manager rollout status deploy/cert-manager deploy/cert-manager-webhook --timeout=180s
|
||||
```
|
||||
|
||||
Poczekaj, aż **webhook** będzie gotowy — dopóki nie wstanie, tworzenie obiektów
|
||||
`Certificate` kończy się błędem połączenia i wygląda jak zepsuty manifest.
|
||||
|
||||
Sprawdź:
|
||||
```bash
|
||||
kubectl get crd | grep cert-manager | head -3 # muszą się pojawić
|
||||
```
|
||||
|
||||
> **Dlaczego własne CA, a nie Let's Encrypt.** Klaster stoi w LAN (Traefik trzyma
|
||||
> LoadBalancera na adresach 192.168.1.x), więc walidacja HTTP-01 nie ma jak dojść
|
||||
> z internetu, a DNS-01 wymagałby trzymania w klastrze tokena API do domeny.
|
||||
> Własne CA nie potrzebuje niczego z zewnątrz i odnawia certyfikaty samo. Cena:
|
||||
> raz na urządzenie importujesz korzeń (krok 6).
|
||||
|
||||
---
|
||||
|
||||
## Krok 3 — DNS
|
||||
|
||||
Wpis `astrololo.czernobog.pl` → adres Traefika z kroku „stan wyjściowy”.
|
||||
W routerze, lokalnym DNS-ie albo doraźnie w `/etc/hosts`:
|
||||
|
||||
```bash
|
||||
echo "192.168.1.73 astrololo.czernobog.pl" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
**To nie jest krok opcjonalny.** Service `presentation` przestaje być NodePortem
|
||||
(był drugą, nieszyfrowaną drogą do aplikacji — czyli obejściem całego PRE-16),
|
||||
więc po wdrożeniu manifestów nazwa jest jedynym wejściem. Awaryjnie zawsze zostaje:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo port-forward svc/presentation 8000:8000 # http://localhost:8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Krok 4 — merge PR-a aplikacji (astrololo #21)
|
||||
|
||||
Teraz, **przed** manifestami.
|
||||
|
||||
```bash
|
||||
tea pr merge --login gitea --repo gitea/astrololo 21
|
||||
```
|
||||
|
||||
Po merge'u CI zbuduje obrazy, a image-updater sam podbije tagi w repo `deploy`,
|
||||
skąd ArgoCD wymieni pody. Poczekaj, aż to się przetoczy:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
|
||||
kubectl -n astrololo get pods -o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}'
|
||||
```
|
||||
|
||||
Na tym etapie **nic się jeszcze nie szyfruje** — nowy kod to potrafi, ale zmienne
|
||||
z kluczami dokłada dopiero PR do `deploy`. Aplikacja działa dokładnie jak dotąd.
|
||||
To celowe: chcemy, żeby *cała* obsada podów umiała szyfrować, zanim ktokolwiek
|
||||
tego zażąda.
|
||||
|
||||
---
|
||||
|
||||
## Krok 5 — merge PR-a manifestów (deploy #4)
|
||||
|
||||
```bash
|
||||
tea pr merge --login gitea --repo gitea/deploy 4
|
||||
```
|
||||
|
||||
ArgoCD zsynchronizuje się sam (`automated`, `selfHeal`). Wjeżdża naraz: Ingress,
|
||||
certyfikat, zmienne z kluczami, `TRUST_PROXY` i zdjęcie NodePortu.
|
||||
|
||||
```bash
|
||||
kubectl -n argocd get application astrololo
|
||||
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
|
||||
kubectl -n astrololo get certificate # astrololo-ca i astrololo-tls: READY=True
|
||||
```
|
||||
|
||||
> **Spodziewaj się kilkudziesięciu sekund błędów w trakcie.** Pody wymieniają się
|
||||
> po kolei, więc przez chwilę stara prezentacja (jeszcze bez klucza) rozmawia
|
||||
> z nową logiką (już z kluczem) i dostaje odmowę. To zamierzone: alternatywą byłby
|
||||
> tryb „przyjmuj i szyfrowane, i jawne”, który zwykle zostaje włączony na zawsze.
|
||||
|
||||
Merge nie cofnie tagów obrazów — PR dotyka w `kustomization.yaml` wyłącznie listy
|
||||
`resources`, nie bloku `images`, więc git złoży to z nowszymi tagami z mastera.
|
||||
|
||||
---
|
||||
|
||||
## Krok 6 — zaufanie do własnego CA (raz na urządzenie)
|
||||
|
||||
Bez tego przeglądarka pokaże ostrzeżenie o certyfikacie. Korzeń jest ważny 10 lat,
|
||||
więc robisz to raz:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo get secret astrololo-ca -o jsonpath='{.data.ca\.crt}' \
|
||||
| base64 -d > astrololo-ca.crt
|
||||
|
||||
# macOS — do systemowego zaufania (poprosi o hasło administratora)
|
||||
sudo security add-trusted-cert -d -r trustRoot \
|
||||
-k /Library/Keychains/System.keychain astrololo-ca.crt
|
||||
|
||||
# Linux (Debian/Ubuntu)
|
||||
sudo cp astrololo-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
|
||||
```
|
||||
|
||||
Firefox ma **własny** magazyn certyfikatów — import przez *Ustawienia →
|
||||
Prywatność i bezpieczeństwo → Wyświetl certyfikaty → Organy certyfikacji*.
|
||||
|
||||
---
|
||||
|
||||
## Krok 7 — sprawdzenie, że działa to, co miało zadziałać
|
||||
|
||||
### Wejście po https
|
||||
```bash
|
||||
curl -sI http://astrololo.czernobog.pl/ | head -2 # 301 → https
|
||||
curl -s -o /dev/null -w "bez hasła: %{http_code}\n" https://astrololo.czernobog.pl/
|
||||
curl -s -o /dev/null -w "z hasłem: %{http_code}\n" -u astrololo:'<hasło>' https://astrololo.czernobog.pl/
|
||||
curl -sI -u astrololo:'<hasło>' https://astrololo.czernobog.pl/ | grep -i strict-transport
|
||||
```
|
||||
Oczekiwane: **301**, **401**, **200**, nagłówek HSTS obecny. Brak ostrzeżenia
|
||||
o certyfikacie w przeglądarce oznacza, że krok 6 się udał.
|
||||
|
||||
### W przeglądarce
|
||||
Kliknij **„Tu i teraz"** — powinno pobrać lokalizację (po http odmawiało).
|
||||
Wygeneruj prompt i kliknij **kopiuj** — schowek powinien zadziałać bez obejść.
|
||||
|
||||
### Szyfrowanie łączy — sprawdzenie wprost
|
||||
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **bez klucza**:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo exec deploy/presentation -- \
|
||||
python -c "
|
||||
import httpx, os
|
||||
r = httpx.post('http://logic:8001/chart/report',
|
||||
json={'when_utc':'1984-04-30T09:20:00+00:00','lat':50.06,'lon':19.94},
|
||||
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
|
||||
print(r.status_code, r.text[:120])
|
||||
"
|
||||
```
|
||||
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Zwróć uwagę, że
|
||||
żądanie miało **prawidłowy token** — sam token już nie wystarcza, i o to chodziło.
|
||||
|
||||
To samo w dół, do warstwy danych:
|
||||
```bash
|
||||
kubectl -n astrololo exec deploy/logic -- \
|
||||
python -c "
|
||||
import httpx, os
|
||||
r = httpx.post('http://data:8002/search',
|
||||
json={'key':'significator','value':'[Sat','exact':False,'limit':5},
|
||||
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
|
||||
print(r.status_code, r.text[:120])
|
||||
"
|
||||
```
|
||||
|
||||
### Logi startowe
|
||||
```bash
|
||||
kubectl -n astrololo logs deploy/logic | grep -i "łącze\|UWAGA"
|
||||
```
|
||||
Powinno być `łącze szyfrowane (AES-256-GCM…)`. Jeśli widzisz ostrzeżenie
|
||||
o rozmowie **jawnym tekstem** — klucz nie doszedł do poda.
|
||||
|
||||
---
|
||||
|
||||
## Dlaczego taka kolejność
|
||||
|
||||
| Kolejność | Skutek zamiany |
|
||||
|---|---|
|
||||
| Sekret **przed** manifestami | `LINK_ENCRYPTION_REQUIRED=true` bez klucza celowo wywraca start. Pody wpadną w CrashLoop i będą tak siedzieć do czasu utworzenia sekretu. |
|
||||
| cert-manager **przed** manifestami | API odrzuci `Certificate`/`Issuer` jako nieznane rodzaje zasobów, ArgoCD pokaże aplikację jako niezsynchronizowaną i sam tego nie naprawi. |
|
||||
| DNS **przed** manifestami | NodePort znika razem z nimi. Bez wpisu DNS zostaje tylko `port-forward`. |
|
||||
| Aplikacja **przed** manifestami | Odwrotnie: manifesty włączyłyby szyfrowanie na obrazach, które go nie znają — wszystkie żądania kończyłyby się odmową do czasu przebudowy obrazów. |
|
||||
|
||||
Fail-closed w obie strony jest zamierzony. Usługa, która wstała i **po cichu nie
|
||||
szyfruje**, jest gorsza niż pod w CrashLoop — awarii nie widać, a bazy jadą
|
||||
otwartym tekstem.
|
||||
|
||||
---
|
||||
|
||||
## Wycofanie
|
||||
|
||||
Manifestów: `git revert` merge'a w `deploy` — ArgoCD samo wróci do NodePortu
|
||||
i ruchu bez szyfrowania. Kod aplikacji **nie wymaga wycofania**: bez zmiennych
|
||||
`LINK_KEY_*` moduł przepuszcza ruch jak dotąd (i głośno o tym mówi w logach).
|
||||
|
||||
Certyfikat i CA zostają w namespace; usunięcie: `kubectl -n astrololo delete
|
||||
certificate astrololo-ca astrololo-tls`. cert-managera można zostawić — nie
|
||||
przeszkadza.
|
||||
|
||||
---
|
||||
|
||||
## Gdy coś nie gra
|
||||
|
||||
| Objaw | Przyczyna | Co zrobić |
|
||||
|---|---|---|
|
||||
| Pody w `CrashLoopBackOff`, w logach `LINK_ENCRYPTION_REQUIRED … nie ustawiony` | brak sekretu `astrololo-link` | krok 1, potem `rollout restart` |
|
||||
| `400 Łącze międzywarstwowe wymaga szyfrowania` przy normalnym korzystaniu | jedna warstwa ma klucz, druga nie (albo trwa rollout) | `rollout status`; sprawdź, czy wszystkie trzy pody mają zmienną |
|
||||
| `400 Nie udało się odczytać zaszyfrowanego żądania` | klucze po obu stronach łącza są **różne** | wymień sekret i zrestartuj **wszystkie trzy** naraz |
|
||||
| `Certificate` stoi w `READY=False` | webhook cert-managera jeszcze nie wstał | `kubectl -n cert-manager get pods`, poczekaj i sprawdź `kubectl -n astrololo describe certificate astrololo-tls` |
|
||||
| Przeglądarka: „połączenie nie jest prywatne” | korzeń CA nieimportowany na tym urządzeniu | krok 6 (pamiętaj, że Firefox ma osobny magazyn) |
|
||||
| `404` z Traefika pod adresem aplikacji | DNS wskazuje gdzie indziej niż LoadBalancer Traefika | porównaj `dig +short astrololo.czernobog.pl` z adresem z kroku „stan wyjściowy” |
|
||||
| Limit żądań odcina wszystkich naraz | brak `TRUST_PROXY=true` — cały ruch liczony jako jeden klient | sprawdź zmienną w `deploy/presentation` |
|
||||
|
||||
---
|
||||
|
||||
## Czego to nie załatwia
|
||||
|
||||
- **Szyfrowane są ciała żądań, nie nagłówki.** Ścieżka (`/search`) i token
|
||||
międzywarstwowy jadą czytelnie. Sam token nikomu nic nie daje — bez klucza łącza
|
||||
każde żądanie kończy się odmową — ale metadanych to nie ukrywa. Pełne ukrycie
|
||||
wymagałoby mTLS.
|
||||
- **Własne CA to nie publiczne zaufanie.** Każde nowe urządzenie wymaga importu
|
||||
korzenia. Gdyby aplikacja miała kiedyś wyjść na świat, właściwą drogą jest
|
||||
Let's Encrypt przez DNS-01.
|
||||
- **NFS z plikami baz** stoi obok aplikacji — kto ma dostęp do share'u, bierze
|
||||
pliki z pominięciem wszystkich powyższych zabezpieczeń. Do zamknięcia po stronie
|
||||
infrastruktury (eksport tylko dla IP węzłów, `root_squash`, najlepiej read-only).
|
||||
- **Sekrety w etcd** są tylko zakodowane base64. Docelowo: szyfrowanie etcd
|
||||
at-rest albo Sealed Secrets / SOPS.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Wdrożenie usługi `render` — raport PDF (PRE-24)
|
||||
|
||||
Instrukcja krok po kroku dla nowego komponentu. Dotyczy dwóch repozytoriów:
|
||||
|
||||
| Repo | Co wnosi |
|
||||
|---|---|
|
||||
| `gitea/astrololo` | usługa `services/render`, klient w prezentacji, przycisk „Pobierz PDF", wariant kosmogramu do druku |
|
||||
| `gitea/deploy` | `astrololo/render.yaml`, adres i klucz łącza w `presentation.yaml` |
|
||||
|
||||
---
|
||||
|
||||
## Dlaczego osobna usługa
|
||||
|
||||
TeX Live waży setki megabajtów. W obrazie prezentacji spowalniałby każdy build
|
||||
i deploy, a przy każdej poprawce w CSS trzeba by go ciągnąć od nowa. Osobno:
|
||||
obraz produktu zostaje mały, TeX aktualizuje się niezależnie, a **awaria renderu
|
||||
nie kładzie aplikacji** — przestaje działać wyłącznie przycisk „Pobierz PDF".
|
||||
|
||||
To ta sama zasada, co przy izolacji silnika swisseph (LOG-27).
|
||||
|
||||
---
|
||||
|
||||
## Krok 1 — klucz łącza (PRZED wdrożeniem)
|
||||
|
||||
Usługa dostaje **cały raport**: dane urodzeniowe i opisy z baz. Łącze jest
|
||||
szyfrowane AES-256-GCM, własnym, **trzecim** kluczem — przejęcie go nie może
|
||||
otwierać łącza do logiki ani do danych.
|
||||
|
||||
Sekret `astrololo-link` już istnieje (PRE-16); dokładamy do niego trzeci klucz,
|
||||
**zachowując dwa dotychczasowe**:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo create secret generic astrololo-link \
|
||||
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link \
|
||||
-o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
|
||||
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link \
|
||||
-o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
|
||||
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
```
|
||||
|
||||
Sprawdzenie — mają być **trzy** klucze:
|
||||
```bash
|
||||
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
|
||||
```
|
||||
|
||||
> **Uwaga:** `LINK_ENCRYPTION_REQUIRED=true` działa fail-closed. Bez klucza pod
|
||||
> `render` **nie wstanie** — i tak ma być. Usługa, która wstała i po cichu nie
|
||||
> szyfruje, jest gorsza niż CrashLoop, bo awarii nie widać.
|
||||
|
||||
---
|
||||
|
||||
## Krok 2 — obraz usługi
|
||||
|
||||
Obraz budowany jest osobnym workflow (jak silnik swisseph — nie wchodzi do
|
||||
głównego pipeline'u produktu). Pierwszy build trwa dłużej, bo ciągnie TeX Live.
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo get deploy render -o jsonpath='{.spec.template.spec.containers[0].image}'; echo
|
||||
```
|
||||
|
||||
Pakiety w obrazie dobrane **wąsko** (nie `texlive-full`, który ma kilka GB):
|
||||
|
||||
| Pakiet | Po co |
|
||||
|---|---|
|
||||
| `texlive-xetex` | silnik **XeLaTeX** — konieczny, bo raport ma polskie znaki i glify astrologiczne; `pdflatex` ich nie złoży |
|
||||
| `texlive-latex-recommended` | `geometry`, `graphicx` |
|
||||
| `fonts-dejavu-core` | jeden font na polskie znaki **i** symbole (♄ ♓ ☉) |
|
||||
| `librsvg2-bin` | `rsvg-convert` — SVG kosmogramu → PDF (LaTeX nie wstawia SVG wprost) |
|
||||
|
||||
Dockerfile sprawdza obecność obu narzędzi **przy budowie**, więc zepsuty obraz
|
||||
nie dojedzie na produkcję niezauważony.
|
||||
|
||||
---
|
||||
|
||||
## Krok 3 — merge manifestów
|
||||
|
||||
```bash
|
||||
tea pr merge --login gitea --repo gitea/deploy <numer>
|
||||
kubectl -n astrololo rollout status deploy/render deploy/presentation
|
||||
```
|
||||
|
||||
Wjeżdża naraz: `Deployment` + `Service` renderu (ClusterIP — **bez** Ingressu
|
||||
i NodePortu, nie ma powodu sięgać do niej z zewnątrz) oraz `RENDER_URL`
|
||||
i trzeci klucz w prezentacji.
|
||||
|
||||
---
|
||||
|
||||
## Krok 4 — sprawdzenie
|
||||
|
||||
### Narzędzia są na miejscu
|
||||
`/health` raportuje obecność `xelatex` i `rsvg-convert`, żeby zepsuty obraz
|
||||
było widać od razu, a nie dopiero przy pierwszym raporcie:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo exec deploy/presentation -- \
|
||||
python -c "import httpx; print(httpx.get('http://render:8004/health').text)"
|
||||
```
|
||||
Oczekiwane: `{"status":"ok","layer":"render","tools":{"xelatex":true,"rsvg-convert":true}}`.
|
||||
Gdy `status` to `degraded` — obraz zbudował się bez któregoś narzędzia.
|
||||
|
||||
### Łącze faktycznie szyfruje
|
||||
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **z poprawnym tokenem,
|
||||
ale bez szyfrowania**:
|
||||
|
||||
```bash
|
||||
kubectl -n astrololo exec deploy/presentation -- python -c "
|
||||
import httpx, os
|
||||
r = httpx.post('http://render:8004/pdf', json={'person':'test'},
|
||||
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
|
||||
print(r.status_code, r.text[:80])
|
||||
"
|
||||
```
|
||||
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Sam token już
|
||||
nie wystarcza — o to chodziło.
|
||||
|
||||
### Raport od końca do końca
|
||||
W przeglądarce: **Skompiluj** → wypełnij dane → *Złóż podsumowanie* → *Pobierz PDF*.
|
||||
PDF ma zacząć się od imienia i nazwiska, potem wprowadzone dane, **rysunek
|
||||
kosmogramu**, a po nim interpretacja natalna i predykcje okresowe.
|
||||
|
||||
---
|
||||
|
||||
## Czego ta wersja nie załatwia
|
||||
|
||||
- **Kompilacja PDF nie została sprawdzona end-to-end** w środowisku, w którym
|
||||
powstawała — nie było tam ani TeX Live, ani runtime'u kontenerów. Sprawdzone
|
||||
jest wszystko dookoła: generowanie źródła `.tex` (w tym ucieczka znaków
|
||||
specjalnych), szyfrowanie łącza, kontrakt API i samowystarczalność SVG.
|
||||
**Pierwsze uruchomienie na klastrze trzeba obejrzeć.**
|
||||
- **Dzielenie wyrazów** jest angielskie — nie wciągamy `polyglossia`, żeby nie
|
||||
puchł obraz. Tekst składa się poprawnie, tylko przenoszenie bywa nieoptymalne.
|
||||
- **Brak kolejki**. Długi raport blokuje jedno połączenie na czas kompilacji
|
||||
(timeout klienta: 180 s). Przy większym ruchu warto dołożyć zadania w tle.
|
||||
- **Glify w tekście od modelu**. Kosmogram idzie jako obraz (rysuje go
|
||||
`rsvg-convert` z DejaVu), ale gdyby model wplótł symbole w prozę, złoży je
|
||||
XeLaTeX — też z DejaVu. Jeśli któregoś zabraknie, LaTeX zgłasza
|
||||
„Missing character" w logu i **nie drukuje znaku**; warto zerknąć w log
|
||||
pierwszego raportu.
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Do obrazu wchodzi WYŁĄCZNIE kod aplikacji. `COPY . .` wnosiło też katalog
|
||||
# tests/ — a leży w nim lista słów, które w tej usłudze nie mają prawa paść,
|
||||
# wypisanych wprost. Obraz tej usługi się KOMUŚ ODDAJE, więc niósłby dokładnie
|
||||
# to, co ten test ma pilnować. Przy okazji obraz jest mniejszy.
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 8005
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8005"]
|
||||
@@ -0,0 +1,91 @@
|
||||
# astrodemo — wersja demonstracyjna (PRE-28)
|
||||
|
||||
Osobna warstwa prezentacji o **dwóch funkcjach**:
|
||||
|
||||
1. **dodanie pliku bazy** — wgranie i włączenie do użytku jedną czynnością,
|
||||
2. **zapytanie o interpretację urodzeniową** dla podanych danych wejściowych.
|
||||
|
||||
I na tym koniec. Nie ma tu kosmogramu, synastrii, kalendarza, promptów do modelu,
|
||||
eksportu ani kont — nie są ukryte, tylko **nie istnieją w tym obrazie**.
|
||||
|
||||
## Dlaczego osobna usługa, a nie konto z ograniczeniami
|
||||
|
||||
Mechanizm uprawnień z PRE-27 umiałby to ukryć w pełnej aplikacji. Ale **ukrycie
|
||||
a nieobecność to dwie różne rzeczy**: tutaj nie ma tras, nie ma szablonów, nie ma
|
||||
nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając przy
|
||||
okazji kodu reszty programu.
|
||||
|
||||
## Izolacja: własne dane, pula na konto
|
||||
|
||||
```
|
||||
astrodemo → logic-demo → data-demo → udział astrololo-demo (pusty na starcie)
|
||||
└── klientA/ ← pula konta
|
||||
└── klientB/ ← pula konta
|
||||
```
|
||||
|
||||
**Oryginalne bazy interpretacyjne są dla demo nieosiągalne.** Nie chodzi
|
||||
o uprawnienia: demo ma własną warstwę danych, pracującą na osobnym udziale.
|
||||
Osobna jest też warstwa logiczna, bo zna ona JEDEN adres warstwy danych — demo
|
||||
korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy.
|
||||
|
||||
**Każde konto ma własną pulę** plików: swój podkatalog na tym udziale. Konta nie
|
||||
widzą swoich baz nawzajem ani w liście, ani w wynikach wyszukiwania. Dlatego demo
|
||||
można rozdawać szeroko.
|
||||
|
||||
Pula bierze się **z loginu zalogowanej osoby** i z niczego innego. Gdyby
|
||||
przychodziła z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze
|
||||
bazy — osobny test wysyła `tenant`, `user` i `login` w formularzu i sprawdza, że
|
||||
nie mają na nią wpływu.
|
||||
|
||||
Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne
|
||||
w nazwie katalogu: `../..` czy ukośnik wyprowadziłyby zapytanie poza udział.
|
||||
|
||||
**Pula klienta to jeden katalog**, więc przejście na pełną wersję nie oznacza
|
||||
utraty wgrań — procedura importu jest w runbooku wdrożenia (repo `deploy`,
|
||||
`astrololo/README-astrodemo.md`).
|
||||
|
||||
## Konfiguracja
|
||||
|
||||
| zmienna | znaczenie |
|
||||
|---|---|
|
||||
| `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) |
|
||||
| `ASTRODEMO_USERS` | konta jako `login:sekret`, po przecinku. Sekret jawny albo hash `scrypt$sól$hash`. Każdy login = osobna pula plików |
|
||||
| `ASTRODEMO_USER` / `ASTRODEMO_PASSWORD` | pojedyncze konto — zgodność wstecz, działa gdy `ASTRODEMO_USERS` jest puste. **Brak obu = aplikacja stoi otwarta** i mówi o tym głośno przy starcie |
|
||||
| `INTERNAL_TOKEN` | token międzywarstwowy (LOG-32) — ten sam co reszta usług |
|
||||
| `LINK_KEY_PRESENTATION_LOGIC` | klucz szyfrowanego łącza (PRE-16) — ten sam co prezentacja |
|
||||
| `RATE_LIMIT_PER_MIN` | limit żądań na adres, domyślnie 60; `0` wyłącza |
|
||||
| `REPORT_LIMIT` | górny limit rekordów w jednej odpowiedzi, domyślnie 2000 |
|
||||
| `TRUST_PROXY` | `true` za Ingressem — inaczej limit liczy wszystkich do jednego wiadra |
|
||||
|
||||
Hash hasła zrobisz tym samym skryptem, co dla głównej aplikacji:
|
||||
|
||||
```bash
|
||||
cd services/presentation && python scripts/make_user.py demo
|
||||
```
|
||||
|
||||
## Uruchomienie lokalne
|
||||
|
||||
```bash
|
||||
cd services/astrodemo
|
||||
pip install -r requirements-dev.txt
|
||||
ASTRODEMO_PASSWORD=tajne LOGIC_URL=http://localhost:8001 \
|
||||
uvicorn app.main:app --port 8005
|
||||
```
|
||||
|
||||
Testy:
|
||||
|
||||
```bash
|
||||
cd services/astrodemo && PYTHONPATH=. pytest tests -q
|
||||
```
|
||||
|
||||
## Co pilnują testy
|
||||
|
||||
Poza zwykłą poprawnością — dwie własności, które łatwo zepsuć bez zauważenia:
|
||||
|
||||
* **powierzchnia**: zbiór tras aplikacji musi być dokładnie taki, jak dwie funkcje
|
||||
wymagają; klient warstwy logicznej ma tylko te metody, których używa. Trasa,
|
||||
o której nikt nie pamiętał, jest tu realnym ryzykiem, bo ta usługa rozmawia
|
||||
z produkcyjną warstwą danych.
|
||||
* **milczenie o regułach walidacji**: plik odrzucony daje komunikat bez powodu.
|
||||
Reguły zna wyłącznie administrator w pełnej aplikacji (DAN-27), a demo nie ma
|
||||
prawa ich zdradzić nawet mimochodem.
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Klient warstwy logicznej — CELOWO okrojony do dwóch funkcji (PRE-28).
|
||||
|
||||
Ma tu być wyłącznie to, czego ta usługa naprawdę używa. Metoda, której nie ma,
|
||||
nie zostanie przypadkiem zawołana ani nie podpowie, że po drugiej stronie jest
|
||||
coś więcej.
|
||||
|
||||
Rozmowa idzie tym samym szyfrowanym łączem co reszta (PRE-16) i pod tym samym
|
||||
tokenem międzywarstwowym (LOG-32) — demo nie jest furtką omijającą ochronę.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app import link_crypto
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _auth_headers() -> dict[str, str]:
|
||||
token = os.getenv("INTERNAL_TOKEN", "")
|
||||
return {"X-Astrololo-Token": token} if token else {}
|
||||
|
||||
|
||||
def _link() -> link_crypto.Link | None:
|
||||
"""Klucz łącza czytany przy KAŻDYM wywołaniu — pod przeżywa rotację sekretu
|
||||
bez restartu, a brak klucza przy wymaganym szyfrowaniu ma zatrzymać żądanie
|
||||
ZANIM cokolwiek wyjdzie jawnym tekstem."""
|
||||
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
|
||||
return link_crypto.Link(key) if key else None
|
||||
|
||||
|
||||
class LogicClient:
|
||||
"""Rozmówca warstwy logicznej ZWIĄZANY Z PULĄ KONTA (PRE-29).
|
||||
|
||||
Pulę podaje się przy tworzeniu klienta, a bierze się ją WYŁĄCZNIE z loginu
|
||||
zalogowanej osoby — nigdy z pola formularza. Gdyby przychodziła z żądania,
|
||||
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
|
||||
|
||||
def __init__(self, tenant: str = "", base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
||||
self.tenant = tenant or ""
|
||||
|
||||
def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]:
|
||||
"""Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach.
|
||||
|
||||
Jedyne pytanie, jakie astrodemo zadaje o treść — bez grupowania,
|
||||
bez grupowania i bez żadnej obróbki treści."""
|
||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
||||
"limit": settings.report_limit, "group": False,
|
||||
"tenant": self.tenant}
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
return link_crypto.call_json(client, "POST", f"{self.base_url}/chart/report",
|
||||
payload=payload, headers=_auth_headers(), link=_link())
|
||||
|
||||
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
||||
return link_crypto.call_json(
|
||||
client, "POST", f"{self.base_url}/files/upload",
|
||||
payload={"filename": filename, "content_b64": content_b64,
|
||||
"by": by, "tenant": self.tenant},
|
||||
headers=_auth_headers(), link=_link())
|
||||
|
||||
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
|
||||
"""Włączenie wgranego pliku do użytku. W demo wgranie i włączenie to JEDNA
|
||||
czynność: „dodać do bazy" ma znaczyć, że plik od razu bierze udział
|
||||
w wyszukiwaniu — inaczej demo wyglądałoby na zepsute."""
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
return link_crypto.call_json(
|
||||
client, "POST", f"{self.base_url}/files/status",
|
||||
payload={"path": path, "status": status, "by": by, "tenant": self.tenant},
|
||||
headers=_auth_headers(), link=_link())
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Konfiguracja astrodemo — wersji demonstracyjnej (PRE-28).
|
||||
|
||||
Zna WYŁĄCZNIE adres warstwy logicznej. Nie wie nic o bazach, Excelu ani SQL-u.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
|
||||
# Szukanie interpretacji potrafi zajrzeć do wszystkich baz naraz, więc limit
|
||||
# czasu jest wyższy niż domyślny dla zwykłego żądania.
|
||||
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "30")))
|
||||
# Górny limit rekordów w jednej odpowiedzi. Nie jest to optymalizacja, tylko
|
||||
# ograniczenie, ile treści baz może wyjść jednym zapytaniem.
|
||||
report_limit: int = field(default_factory=lambda: int(os.getenv("REPORT_LIMIT", "2000")))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,525 @@
|
||||
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
||||
|
||||
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
||||
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
||||
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
||||
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
||||
zrzut z węzła), miał je w całości.
|
||||
|
||||
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
||||
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
||||
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
||||
podatne na modyfikację".
|
||||
|
||||
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
||||
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
||||
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
||||
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
||||
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
||||
|
||||
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
||||
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
||||
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
||||
|
||||
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
||||
|
||||
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
||||
|
||||
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
||||
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
||||
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
||||
|
||||
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
||||
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
||||
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
||||
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from typing import Iterable, Iterator
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
log = logging.getLogger("astrololo.link")
|
||||
|
||||
MAGIC = b"AL1"
|
||||
VERSION = "v1"
|
||||
NONCE_BYTES = 12
|
||||
KEY_BYTES = 32 # AES-256
|
||||
LENGTH_PREFIX = 4
|
||||
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
||||
MAX_SKEW_SECONDS = 300.0
|
||||
|
||||
HEADER_ENC = "X-Astrololo-Enc"
|
||||
HEADER_TS = "X-Astrololo-Enc-Ts"
|
||||
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
||||
|
||||
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
||||
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
||||
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
||||
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
||||
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||
|
||||
REQUEST, RESPONSE = b"req", b"res"
|
||||
|
||||
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
||||
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
||||
PUBLIC_PATHS = frozenset({"/health"})
|
||||
|
||||
|
||||
class LinkError(Exception):
|
||||
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- klucze
|
||||
|
||||
def parse_key(raw: str) -> bytes:
|
||||
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
raise LinkError("pusty klucz łącza")
|
||||
try:
|
||||
key = bytes.fromhex(text)
|
||||
except ValueError:
|
||||
try:
|
||||
key = base64.b64decode(text, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
||||
if len(key) != KEY_BYTES:
|
||||
raise LinkError(
|
||||
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
||||
f"`openssl rand -hex 32`"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def key_from_env(env_name: str) -> bytes | None:
|
||||
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
||||
raw = os.getenv(env_name, "")
|
||||
return parse_key(raw) if raw.strip() else None
|
||||
|
||||
|
||||
def encryption_required() -> bool:
|
||||
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
||||
|
||||
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
||||
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
||||
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
||||
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
||||
"""
|
||||
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
||||
info=b"astrololo/link/" + direction,
|
||||
).derive(link_key)
|
||||
|
||||
|
||||
class Link:
|
||||
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
||||
|
||||
def __init__(self, link_key: bytes) -> None:
|
||||
self._by_direction = {
|
||||
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
||||
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------- pojedyncza ramka
|
||||
|
||||
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
||||
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
||||
stamp.encode("ascii"), str(seq).encode("ascii")])
|
||||
|
||||
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||
plaintext: bytes) -> bytes:
|
||||
nonce = os.urandom(NONCE_BYTES)
|
||||
sealed = self._by_direction[direction].encrypt(
|
||||
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
||||
return MAGIC + nonce + sealed
|
||||
|
||||
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||
frame: bytes) -> bytes:
|
||||
if not frame.startswith(MAGIC):
|
||||
raise LinkError("ramka bez znacznika protokołu")
|
||||
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,255 @@
|
||||
"""astrodemo — wersja DEMONSTRACYJNA (PRE-28).
|
||||
|
||||
DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ:
|
||||
1. dodanie pliku bazy (wgranie + włączenie do użytku),
|
||||
2. zapytanie o interpretację urodzeniową dla podanych danych wejściowych.
|
||||
|
||||
DLACZEGO OSOBNA USŁUGA, A NIE KONTO Z OGRANICZENIAMI. Ukrycie a nieobecność to
|
||||
dwie różne rzeczy. Tutaj niczego poza tymi dwiema funkcjami NIE MA W OBRAZIE: nie
|
||||
ma tras, nie ma szablonów, nie ma nawet metod w kliencie warstwy logicznej. Obraz
|
||||
tej usługi można komuś oddać i nie oddaje się przy tym niczego ponad to, co widać
|
||||
na ekranie — łącznie z komentarzami w tym pliku, bo one też jadą razem z obrazem.
|
||||
|
||||
PULE SĄ ROZDZIELONE (PRE-29). Każde konto ma własny, izolowany zbiór plików,
|
||||
niewidoczny dla pozostałych kont; pula bierze się WYŁĄCZNIE z loginu zalogowanej
|
||||
osoby, nigdy z pola formularza. Konta są osobne (ASTRODEMO_USERS) — żeby dało się
|
||||
odciąć tę usługę jedną zmienną, bez
|
||||
ruszania kont głównej aplikacji.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app import link_crypto, security, session
|
||||
from app.clients.logic_client import LogicClient
|
||||
|
||||
# Automatyczna dokumentacja WYŁĄCZONA. /docs wypisałoby komplet tras, a ta usługa
|
||||
# ma nie zdradzać nawet własnej powierzchni.
|
||||
app = FastAPI(title="astrodemo", docs_url=None, redoc_url=None, openapi_url=None)
|
||||
security.install(app)
|
||||
|
||||
# ── zasoby (PRE-27) ────────────────────────────────────────────────────────
|
||||
# Katalog NIE jest montowany. Montowanie serwuje wszystko, co w nim leży, każdemu
|
||||
# i bez śladu w dzienniku — a komentarz w arkuszu stylów mówił wprost, że istnieje
|
||||
# więcej, niż widać na ekranie. Tu jest jawna lista: arkusz logowania jest
|
||||
# publiczny, bo
|
||||
# potrzebuje go strona logowania, a cokolwiek innego jest nieznane i nie istnieje.
|
||||
ZASOBY_PUBLICZNE = frozenset({"styles.css"})
|
||||
_KATALOG_ZASOBOW = Path("app/static")
|
||||
|
||||
|
||||
def _bez_komentarzy(tresc: str) -> str:
|
||||
"""Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt.
|
||||
W repozytorium zostaje — do odpowiedzi nie ma po co trafiać."""
|
||||
tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
|
||||
return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _zasob(nazwa: str) -> bytes | None:
|
||||
"""Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
|
||||
if nazwa not in ZASOBY_PUBLICZNE:
|
||||
return None
|
||||
sciezka = (_KATALOG_ZASOBOW / nazwa).resolve()
|
||||
# Wyjście poza katalog: `..` w ścieżce albo dowiązanie na zewnątrz.
|
||||
if _KATALOG_ZASOBOW.resolve() not in sciezka.parents or not sciezka.is_file():
|
||||
return None
|
||||
if sciezka.suffix in (".css", ".js"):
|
||||
return _bez_komentarzy(sciezka.read_text(encoding="utf-8")).encode("utf-8")
|
||||
return sciezka.read_bytes()
|
||||
|
||||
|
||||
@app.get("/static/{nazwa:path}")
|
||||
def zasob(nazwa: str):
|
||||
tresc = _zasob(nazwa)
|
||||
if tresc is None:
|
||||
raise HTTPException(status_code=404)
|
||||
typ = "text/css; charset=utf-8" if nazwa.endswith(".css") else "application/octet-stream"
|
||||
return Response(tresc, media_type=typ,
|
||||
headers={"Cache-Control": "public, max-age=3600"})
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _blad).
|
||||
log = logging.getLogger("astrodemo")
|
||||
templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
|
||||
templates.env.globals["auth_on"] = security.auth_enabled
|
||||
|
||||
def _logic(request: Request) -> LogicClient:
|
||||
"""Klient związany z pulą ZALOGOWANEGO konta.
|
||||
|
||||
Budowany PER ŻĄDANIE, nie raz na moduł: pula musi wynikać z tego, kto się
|
||||
zalogował, a nie z globalnego stanu procesu. Login bierzemy z request.state,
|
||||
które ustawia warstwa logowania — nigdy z pola formularza, bo wtedy
|
||||
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
|
||||
return LogicClient(tenant=getattr(request.state, "user", "") or "")
|
||||
|
||||
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
|
||||
# tutaj wprost, bo astrodemo nie importuje tamtej warstwy; literówka objawiłaby
|
||||
# się dopiero na żywo, więc pilnuje jej test.
|
||||
ACTIVE = "active"
|
||||
|
||||
|
||||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
"""Data + godzina lokalna + offset → chwila w UTC.
|
||||
|
||||
Offset podaje się wprost, bez wyszukiwania strefy z współrzędnych — demo ma
|
||||
dwie funkcje, a strefy to trzecia."""
|
||||
try:
|
||||
local = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M")
|
||||
except ValueError as e:
|
||||
raise ValueError("Podaj datę jako RRRR-MM-DD i godzinę jako GG:MM.") from e
|
||||
moment = local.replace(tzinfo=timezone(timedelta(hours=tz_offset)))
|
||||
return moment.astimezone(timezone.utc).isoformat(), local.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
# Jedno zdanie dla wszystkich awarii i CELOWO żadnego szczegółu. Treść wyjątku
|
||||
# niosła nazwę usługi, jej port i nazwy jej tras — czyli mapę tego, co stoi za tym
|
||||
# ekranem, podaną komuś, kto ma widzieć wyłącznie dwa formularze.
|
||||
_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
|
||||
|
||||
|
||||
def _blad(e: Exception) -> str:
|
||||
"""Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran."""
|
||||
log.warning("operacja nieudana: %s: %s", type(e).__name__, e)
|
||||
return _AWARIA
|
||||
|
||||
|
||||
def _ile_rekordow(report: dict) -> int:
|
||||
return sum(len(s.get("samples") or []) for p in (report.get("positions") or [])
|
||||
for s in (p.get("significators") or []))
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def formularz(request: Request, error: str = "", done: str = ""):
|
||||
return templates.TemplateResponse(request, "index.html",
|
||||
{"result": None, "form": {}, "error": error, "done": done})
|
||||
|
||||
|
||||
@app.post("/", response_class=HTMLResponse)
|
||||
def interpretacja(request: Request, date: str = Form(...), time: str = Form(...),
|
||||
tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0)):
|
||||
"""Interpretacja urodzeniowa dla podanych danych. Jedyne pytanie o treść."""
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "done": "", "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = _logic(request).report(when_utc_iso=iso_utc, lat=lat, lon=lon)
|
||||
# Ile treści baz wyszło jednym zapytaniem — sama liczba, nigdy treść (PRE-17).
|
||||
security.audit_records(request, _ile_rekordow(ctx["result"]))
|
||||
except ValueError as e:
|
||||
ctx["error"] = str(e)
|
||||
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
||||
ctx["error"] = _blad(e)
|
||||
return templates.TemplateResponse(request, "index.html", ctx)
|
||||
|
||||
|
||||
@app.post("/plik")
|
||||
async def dodaj_plik(request: Request, upload: UploadFile = File(...)):
|
||||
"""Dodanie pliku bazy: wgranie ORAZ włączenie do użytku.
|
||||
|
||||
Jedna czynność, nie dwie: „dodać plik" ma tu znaczyć, że od razu bierze udział
|
||||
w wyszukiwaniu — inaczej po wgraniu nic by się nie zmieniło i wyglądałoby to
|
||||
na zepsute.
|
||||
|
||||
Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje
|
||||
zapisany i czeka."""
|
||||
raw = await upload.read()
|
||||
if not raw:
|
||||
return RedirectResponse("/?error=Pusty+plik.", status_code=303)
|
||||
|
||||
who = getattr(request.state, "user", "") or "demo"
|
||||
try:
|
||||
out = _logic(request).files_upload(upload.filename or "baza.xlsx",
|
||||
base64.b64encode(raw).decode("ascii"), by=who)
|
||||
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
||||
return _wroc(error=_blad(e))
|
||||
|
||||
nazwa = out.get("name") or "plik"
|
||||
if not out.get("accepted"):
|
||||
# Bez słowa o regułach — te zna wyłącznie administrator (DAN-27).
|
||||
return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się "
|
||||
f"do użycia w tej postaci.")
|
||||
try:
|
||||
_logic(request).files_status(out["path"], ACTIVE, by=who)
|
||||
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
||||
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć: {_blad(e)}")
|
||||
return _wroc(done=f"Dodano „{nazwa}”. Baza jest już używana przy wyszukiwaniu.")
|
||||
|
||||
|
||||
def _wroc(error: str = "", done: str = "") -> RedirectResponse:
|
||||
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
||||
wgrać pliku drugi raz."""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||||
return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303)
|
||||
|
||||
|
||||
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
|
||||
# Bez własnego wolumenu nie ma gdzie trzymać licznika pokolenia sesji, więc
|
||||
# „wyrzuć ze wszystkich urządzeń" robi się zmianą hasła w ASTRODEMO_USERS.
|
||||
# Wylogowanie działa natychmiast, bo polega na skasowaniu ciasteczka.
|
||||
|
||||
def _bezpieczne_dokad(dokad: str) -> str:
|
||||
"""Adres powrotu przepuszczony przez sito — bez tego `?dokad=https://obcy.pl`
|
||||
zamieniłby ekran logowania w narzędzie do wyłudzania haseł."""
|
||||
dokad = (dokad or "").strip()
|
||||
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
|
||||
return "/"
|
||||
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
|
||||
|
||||
|
||||
@app.get("/logowanie", response_class=HTMLResponse)
|
||||
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
|
||||
if security.auth_enabled() and security.principal(request):
|
||||
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
|
||||
return templates.TemplateResponse(request, "logowanie.html",
|
||||
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
|
||||
|
||||
|
||||
@app.post("/logowanie")
|
||||
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
|
||||
dokad: str = Form("/")):
|
||||
cel = _bezpieczne_dokad(dokad)
|
||||
kto = security.verify(login.strip(), haslo)
|
||||
if kto is None:
|
||||
security.audit_login(request, login.strip(), udane=False)
|
||||
return templates.TemplateResponse(
|
||||
request, "logowanie.html",
|
||||
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
|
||||
status_code=401)
|
||||
security.audit_login(request, kto, udane=True)
|
||||
odpowiedz = RedirectResponse(cel, status_code=303)
|
||||
odpowiedz.set_cookie(session.COOKIE, security.issue_session(kto),
|
||||
max_age=session.max_age(),
|
||||
**session.cookie_params(secure=security.cookies_secure()))
|
||||
return odpowiedz
|
||||
|
||||
|
||||
@app.post("/wyloguj")
|
||||
def wyloguj(request: Request):
|
||||
"""POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej stronie,
|
||||
żeby wylogować kogoś bez jego wiedzy."""
|
||||
security.audit_logout(request)
|
||||
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
|
||||
odpowiedz.delete_cookie(session.COOKIE,
|
||||
**session.cookie_params(secure=security.cookies_secure()))
|
||||
return odpowiedz
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
# Sama „ok". Nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa;
|
||||
# kubernetes i compose czytają wyłącznie kod odpowiedzi.
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Logowanie do astrodemo (PRE-28).
|
||||
|
||||
OSOBNE KONTO, NIE WSPÓŁDZIELONE Z GŁÓWNĄ APLIKACJĄ. Demo pracuje na tej samej
|
||||
warstwie danych co produkcja, więc kto ma do niego dostęp, czyta oryginalne bazy
|
||||
interpretacyjne. Własny login i hasło pozwalają odciąć demo jedną zmienną
|
||||
środowiskową, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek.
|
||||
|
||||
Mechanizm jest CELOWO prosty: jedno konto z konfiguracji, bez ekranu kont
|
||||
i bez uprawnień. Ta usługa ma dwie funkcje i obie są dla tego samego człowieka —
|
||||
model uprawnień z PRE-27 nie miałby tu czego dzielić.
|
||||
|
||||
Hasło może być jawne albo jako hash scrypt (`scrypt$sól$hash`), tym samym formatem
|
||||
co APP_USERS w głównej aplikacji — dzięki temu skrypt make_user.py działa na oba.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
log = logging.getLogger("astrodemo.security")
|
||||
audit_log = logging.getLogger("astrodemo.audit")
|
||||
|
||||
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
|
||||
_HASH_PREFIX = "scrypt$"
|
||||
|
||||
# /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być
|
||||
# zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji.
|
||||
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/styles.css"})
|
||||
# Pusto CELOWO. Prefiks "/static/" przepuszczał CAŁY katalog obok bramki: każdy
|
||||
# plik pobierał ktokolwiek, także niezalogowany, i nie zostawiało to śladu ani
|
||||
# w dzienniku, ani w limicie żądań. Publiczny jest jeden arkusz, wymieniony
|
||||
# z nazwy, bo potrzebuje go ekran logowania.
|
||||
PUBLIC_PREFIXES: tuple[str, ...] = ()
|
||||
|
||||
_hits: dict[str, deque[float]] = {}
|
||||
MAX_TRACKED_CLIENTS = 4096
|
||||
|
||||
|
||||
def accounts() -> dict[str, str]:
|
||||
"""Konta demo: `login:sekret`, po przecinku, ze zmiennej ASTRODEMO_USERS.
|
||||
|
||||
WIELE KONT, bo każde ma WŁASNĄ, niewidoczną dla pozostałych pulę baz (PRE-29).
|
||||
Wspólne konto oznaczałoby wspólną pulę, czyli klientów oglądających nawzajem
|
||||
swoje wgrania.
|
||||
|
||||
Sekret najlepiej jako hash scrypt (`scrypt$sól$hash`) — ten sam format i ten
|
||||
sam skrypt make_user.py, co w głównej aplikacji.
|
||||
|
||||
Zgodność wstecz: pojedyncze ASTRODEMO_USER/ASTRODEMO_PASSWORD działa, gdy ASTRODEMO_USERS
|
||||
jest puste."""
|
||||
out: dict[str, str] = {}
|
||||
for entry in os.getenv("ASTRODEMO_USERS", "").split(","):
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
user, sep, secret = entry.partition(":")
|
||||
if sep and user.strip() and secret.strip():
|
||||
out[user.strip()] = secret.strip()
|
||||
if out:
|
||||
return out
|
||||
single = os.getenv("ASTRODEMO_PASSWORD", "")
|
||||
return {os.getenv("ASTRODEMO_USER", "demo"): single} if single else {}
|
||||
|
||||
|
||||
def rate_limit_per_min() -> int:
|
||||
return int(os.getenv("RATE_LIMIT_PER_MIN", "60"))
|
||||
|
||||
|
||||
def trust_proxy() -> bool:
|
||||
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def auth_enabled() -> bool:
|
||||
return bool(accounts())
|
||||
|
||||
|
||||
def _secret_matches(spec: str, password: str) -> bool:
|
||||
if spec.startswith(_HASH_PREFIX):
|
||||
try:
|
||||
_, salt_hex, hash_hex = spec.split("$", 2)
|
||||
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
|
||||
except ValueError:
|
||||
return False
|
||||
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
|
||||
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
|
||||
return hmac.compare_digest(actual, expected)
|
||||
# BAJTY, nie tekst: compare_digest na stringach rzuca TypeError przy znakach
|
||||
# spoza ASCII, więc hasło z polskimi literami dawałoby 500 zamiast odmowy.
|
||||
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
|
||||
|
||||
|
||||
def verify(login: str, password: str) -> str | None:
|
||||
"""Sprawdza login i hasło; zwraca login albo None.
|
||||
|
||||
Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które
|
||||
konto istnieje, a przy demo rozdawanym szeroko to realna informacja."""
|
||||
spec = accounts().get(login)
|
||||
if spec is None:
|
||||
return None
|
||||
return login if _secret_matches(spec, password) else None
|
||||
|
||||
|
||||
def issue_session(login: str) -> str:
|
||||
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
|
||||
|
||||
W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie
|
||||
konta z ASTRODEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny
|
||||
sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu,
|
||||
bo nie ma własnego wolumenu — a stan sesji na udziale z bazami klientów
|
||||
byłby pomieszaniem dwóch zupełnie różnych rzeczy."""
|
||||
from app import session
|
||||
|
||||
return session.issue(login, session.fingerprint(accounts().get(login) or ""), 0)
|
||||
|
||||
|
||||
def principal(request: Request) -> str | None:
|
||||
"""Ciasteczko → login zalogowanej osoby albo None."""
|
||||
from app import session
|
||||
|
||||
dane = session.read(request.cookies.get(session.COOKIE))
|
||||
if dane is None:
|
||||
return None
|
||||
spec = accounts().get(dane["login"])
|
||||
if spec is None or session.fingerprint(spec) != dane["odcisk"]:
|
||||
return None
|
||||
return dane["login"]
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""Adres do rozliczania limitu. Nagłówkom wierzymy WYŁĄCZNIE za proxy —
|
||||
inaczej wystarczyłoby podstawić własny X-Forwarded-For, żeby ominąć limit."""
|
||||
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()
|
||||
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 _setup_audit_logging() -> None:
|
||||
"""Własny handler na stdout. Domyślna konfiguracja uvicorna nie obsługuje
|
||||
naszych loggerów, więc wpisy INFO ginęły — dziennik istniał w kodzie, ale był
|
||||
pusty. Niewidoczny dziennik jest gorszy niż jego brak (błąd zastany w PRE-17)."""
|
||||
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
|
||||
if not audit_log.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
audit_log.addHandler(handler)
|
||||
audit_log.propagate = False
|
||||
|
||||
|
||||
def audit_records(request: Request, count: int) -> None:
|
||||
try:
|
||||
request.state.audit_records = int(count)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def cookies_secure() -> bool:
|
||||
"""Ciasteczko tylko dla https. Wyłączane wyłącznie w pracy lokalnej po http —
|
||||
inaczej przeglądarka po prostu go nie zapamięta i logowanie „nie działa"
|
||||
bez żadnego komunikatu."""
|
||||
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def audit_login(request: Request, login: str, udane: bool) -> None:
|
||||
"""Zdarzenie logowania. Nieudane próby są ważniejsze od udanych: pojedyncza
|
||||
nic nie znaczy, seria pod jednym adresem to ślad zgadywania haseł. Login tak,
|
||||
hasło NIGDY."""
|
||||
audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s",
|
||||
"logowanie-ok" if udane else "logowanie-odmowa",
|
||||
login or "-", client_ip(request))
|
||||
|
||||
|
||||
def audit_logout(request: Request) -> None:
|
||||
audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s",
|
||||
getattr(request.state, "user", "") or "-", client_ip(request))
|
||||
|
||||
|
||||
def install(app) -> None:
|
||||
from app import session
|
||||
|
||||
_setup_audit_logging()
|
||||
|
||||
# Fail-closed: konta bez klucza podpisu to sesje nie do odróżnienia od
|
||||
# podrobionych. Lepiej, żeby usługa nie wstała.
|
||||
if auth_enabled() and not session.available():
|
||||
raise session.SessionError(
|
||||
"Konta demo są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
|
||||
"podpisu logowanie nie miałoby czym się bronić. openssl rand -hex 32")
|
||||
|
||||
if not auth_enabled():
|
||||
log.warning(
|
||||
"UWAGA: brak kont (ASTRODEMO_USERS / ASTRODEMO_PASSWORD) — astrodemo stoi OTWARTY. "
|
||||
"Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym "
|
||||
"zbiorze baz, widząc nawzajem swoje wgrania."
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def _guard(request: Request, call_next):
|
||||
if request.url.path in PUBLIC_PATHS or request.url.path.startswith(PUBLIC_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
started = time.monotonic()
|
||||
if _rate_limited(client_ip(request)):
|
||||
return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
|
||||
status_code=429, headers={"Retry-After": "60"})
|
||||
|
||||
user, odswiez = "", None
|
||||
if auth_enabled():
|
||||
user = principal(request) or ""
|
||||
if not user:
|
||||
from urllib.parse import quote
|
||||
|
||||
cel = request.url.path
|
||||
dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else ""
|
||||
return RedirectResponse(f"/logowanie{dokad}", status_code=303)
|
||||
from app import session as _s
|
||||
|
||||
dane = _s.read(request.cookies.get(_s.COOKIE))
|
||||
odswiez = _s.refresh(dane) if dane else None
|
||||
request.state.user = user
|
||||
|
||||
response = await call_next(request)
|
||||
if odswiez:
|
||||
from app import session as _s
|
||||
|
||||
response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(),
|
||||
**_s.cookie_params(secure=cookies_secure()))
|
||||
records = getattr(request.state, "audit_records", None)
|
||||
audit_log.info(
|
||||
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
|
||||
user or "-", client_ip(request), request.method, request.url.path,
|
||||
response.status_code, "-" if records is None else records,
|
||||
(time.monotonic() - started) * 1000)
|
||||
return response
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Sesje logowania — podpisane ciasteczko (LOG-34).
|
||||
|
||||
Kopia modułu z warstwy prezentacji, tak samo jak link_crypto: usługi są osobnymi
|
||||
obrazami i nie importują się nawzajem. Różni je WYŁĄCZNIE nazwa ciasteczka.
|
||||
|
||||
|
||||
CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje
|
||||
hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć.
|
||||
Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest
|
||||
w środku, i może tę decyzję cofnąć.
|
||||
|
||||
TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego:
|
||||
|
||||
1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu,
|
||||
działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj".
|
||||
2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować.
|
||||
Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania
|
||||
o tym — i też nie wymaga magazynu.
|
||||
3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji,
|
||||
który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja
|
||||
nie poda licznika, poziomy 1 i 2 nadal działają.
|
||||
|
||||
Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę
|
||||
hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie
|
||||
dostępu nie odbierałoby dostępu.
|
||||
|
||||
KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie
|
||||
byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie
|
||||
poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania.
|
||||
|
||||
Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt
|
||||
w PRE-27 i AES w PRE-16.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały
|
||||
# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby
|
||||
# z jednej, logując się do drugiej.
|
||||
COOKIE = "astrodemo_sesja"
|
||||
|
||||
# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji.
|
||||
DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować
|
||||
DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec
|
||||
|
||||
# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde
|
||||
# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów.
|
||||
REFRESH_AFTER = 300
|
||||
|
||||
|
||||
class SessionError(RuntimeError):
|
||||
"""Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia."""
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.getenv(name, "") or default)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def max_age() -> int:
|
||||
return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE)
|
||||
|
||||
|
||||
def idle_timeout() -> int:
|
||||
return _env_int("SESSION_IDLE", DEFAULT_IDLE)
|
||||
|
||||
|
||||
def secret() -> bytes:
|
||||
"""Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz.
|
||||
|
||||
Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji
|
||||
od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała."""
|
||||
raw = os.getenv("SESSION_SECRET", "").strip()
|
||||
if not raw:
|
||||
raise SessionError(
|
||||
"Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. "
|
||||
"Wygeneruj: openssl rand -hex 32")
|
||||
if len(raw) < 32:
|
||||
raise SessionError(
|
||||
f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; "
|
||||
f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.")
|
||||
return raw.encode("utf-8")
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
"""Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie)."""
|
||||
try:
|
||||
secret()
|
||||
except SessionError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def fingerprint(secret_spec: str) -> str:
|
||||
"""Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza).
|
||||
|
||||
NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót
|
||||
zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt.
|
||||
Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta
|
||||
unieważniły jego sesje same z siebie."""
|
||||
return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _sign(payload: bytes) -> bytes:
|
||||
return hmac.new(secret(), payload, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _b64(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _unb64(text: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
||||
|
||||
|
||||
def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str:
|
||||
"""Nowe ciasteczko sesji dla konta."""
|
||||
teraz = int(now if now is not None else time.time())
|
||||
payload = json.dumps(
|
||||
{"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz},
|
||||
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
||||
|
||||
|
||||
def read(token: str | None, *, now: float | None = None) -> dict | None:
|
||||
"""Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza.
|
||||
|
||||
None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości
|
||||
dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga
|
||||
zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania."""
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
surowy, _, podpis = token.rpartition(".")
|
||||
try:
|
||||
payload = _unb64(surowy)
|
||||
oczekiwany = _sign(payload)
|
||||
podany = _unb64(podpis)
|
||||
except (ValueError, SessionError):
|
||||
return None
|
||||
# Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile
|
||||
# pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia.
|
||||
if not hmac.compare_digest(oczekiwany, podany):
|
||||
return None
|
||||
try:
|
||||
dane = json.loads(payload.decode("utf-8"))
|
||||
login, odcisk = str(dane["u"]), str(dane["f"])
|
||||
wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
teraz = int(now if now is not None else time.time())
|
||||
# Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie —
|
||||
# w obu wypadkach nie ma powodu mu ufać.
|
||||
if wyd > teraz + 60:
|
||||
return None
|
||||
if teraz - wyd > max_age() or teraz - akt > idle_timeout():
|
||||
return None
|
||||
return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt}
|
||||
|
||||
|
||||
def refresh(dane: dict, *, now: float | None = None) -> str | None:
|
||||
"""Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć.
|
||||
|
||||
None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby
|
||||
Set-Cookie — także po arkusze stylów i obrazki."""
|
||||
teraz = int(now if now is not None else time.time())
|
||||
if teraz - dane["aktywnosc"] < REFRESH_AFTER:
|
||||
return None
|
||||
payload = json.dumps(
|
||||
{"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"],
|
||||
"wyd": dane["wydano"], "akt": teraz},
|
||||
separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
return f"{_b64(payload)}.{_b64(_sign(payload))}"
|
||||
|
||||
|
||||
def cookie_params(secure: bool = True) -> dict:
|
||||
"""Ustawienia ciasteczka wspólne dla ustawiania i kasowania.
|
||||
|
||||
httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty
|
||||
skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających
|
||||
na tym, że obca strona wysyła żądanie w imieniu zalogowanego."""
|
||||
return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"}
|
||||
@@ -0,0 +1,63 @@
|
||||
/* Arkusz CELOWO minimalny: dwa formularze i tabela wyników. Każda klasa ponad to
|
||||
niosłaby nazwę funkcji, której tu nie ma. */
|
||||
:root {
|
||||
--bg: #11121b; --panel: #191b26; --line: #2b2e3d;
|
||||
--fg: #e8e9f0; --muted: #9aa0b4; --accent: #8ab4ff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--fg);
|
||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
main { max-width: 880px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
|
||||
.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
|
||||
.kto { display: flex; align-items: center; gap: .5rem; }
|
||||
.whoami { color: var(--muted); font-size: .85rem; }
|
||||
.wyloguj-form { display: inline; margin: 0; }
|
||||
button.wyloguj {
|
||||
background: none; border: 1px solid var(--line); color: var(--muted);
|
||||
font: inherit; font-weight: 400; padding: .35rem .8rem; border-radius: 8px; cursor: pointer;
|
||||
}
|
||||
button.wyloguj:hover { color: var(--fg); }
|
||||
|
||||
/* Ekran logowania — wąski, bez niczego poza formularzem. */
|
||||
.login-card label { display: block; margin-top: .75rem; }
|
||||
.login-card input { width: 100%; }
|
||||
h1 { margin: 0; font-size: 1.4rem; letter-spacing: .5px; }
|
||||
h2 { margin: 0 0 .3rem; font-size: 1.05rem; }
|
||||
h3 { margin: 0 0 .5rem; font-size: 1rem; }
|
||||
|
||||
.card {
|
||||
margin-top: 1.25rem; padding: 1rem 1.25rem;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
}
|
||||
.row { display: flex; gap: .75rem; flex-wrap: wrap; align-items: flex-end; margin-top: .6rem; }
|
||||
label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 10rem; }
|
||||
input {
|
||||
width: 100%; padding: .45rem .6rem; background: #0e0f18; color: var(--fg);
|
||||
border: 1px solid var(--line); border-radius: 8px; font: inherit;
|
||||
}
|
||||
button {
|
||||
padding: .5rem 1.25rem; background: var(--accent); color: #10121a;
|
||||
border: none; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin-top: .3rem; }
|
||||
td { padding: .35rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; font-size: .92rem; }
|
||||
td.key { color: var(--accent); white-space: nowrap; padding-right: 1rem; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
.sig { margin-top: .75rem; }
|
||||
.sig-head { color: var(--muted); font-size: .85rem; }
|
||||
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: .85rem; }
|
||||
.foot { margin-top: 2.5rem; color: var(--muted); font-size: .8rem; }
|
||||
|
||||
.alert, .ok {
|
||||
margin: 1rem 0; padding: .6rem .9rem; border-radius: 8px; font-size: .92rem;
|
||||
border: 1px solid var(--line); border-left-width: 4px;
|
||||
}
|
||||
.alert { border-left-color: #b8860b; background: rgba(184, 134, 11, .1); }
|
||||
.ok { border-left-color: var(--accent); background: rgba(138, 180, 255, .08); }
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>astrodemo · {% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<h1>astrodemo</h1>
|
||||
{# Kto jest zalogowany i wyjście. Demo bywa używane na cudzych komputerach,
|
||||
więc bez tego pierwszy klient zostawia otwartą sesję drugiemu. #}
|
||||
{% if auth_on() %}
|
||||
<div class="kto">
|
||||
<span class="whoami">{{ current_user(request) }}</span>
|
||||
<form method="post" action="/wyloguj" class="wyloguj-form">
|
||||
<button type="submit" class="wyloguj">Wyloguj</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie,
|
||||
a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #}
|
||||
</header>
|
||||
{% block content %}{% endblock %}
|
||||
<footer class="foot">wersja demonstracyjna</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Interpretacja{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if error %}<p class="alert">{{ error }}</p>{% endif %}
|
||||
{% if done %}<p class="ok">{{ done }}</p>{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2>Dodaj bazę</h2>
|
||||
<p class="muted small">
|
||||
Wgrany plik zaczyna być używany od razu przy wyszukiwaniu interpretacji.
|
||||
</p>
|
||||
<form method="post" action="/plik" enctype="multipart/form-data">
|
||||
<div class="row">
|
||||
<label>Plik <input type="file" name="upload" required></label>
|
||||
<button type="submit">Dodaj</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Zapytaj o interpretację urodzeniową</h2>
|
||||
<form method="post" action="/">
|
||||
<div class="row">
|
||||
<label>Data urodzenia
|
||||
<input type="date" name="date" value="{{ form.date or '' }}" required></label>
|
||||
<label>Godzina (lokalna)
|
||||
<input type="time" name="time" value="{{ form.time or '' }}" required></label>
|
||||
<label>Offset względem GMT (h)
|
||||
<input type="number" name="tz_offset" step="0.5" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}"></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Szerokość (lat, + N)
|
||||
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}" required></label>
|
||||
<label>Długość (lon, + E)
|
||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}" required></label>
|
||||
<button type="submit">Szukaj interpretacji</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if result %}
|
||||
<p class="meta">
|
||||
Moment: <strong>{{ moment }}</strong>
|
||||
{% if result.engine %}· silnik: {{ result.engine }}{% endif %}
|
||||
{% if result.objects %}· obiektów: {{ result.objects | length }}{% endif %}
|
||||
</p>
|
||||
|
||||
{# Kształt odpowiedzi: objects[] → facets[] → samples[]. Pytamy bez grupowania,
|
||||
więc `groups` tu nie wystąpi i nie ma czego obsługiwać. #}
|
||||
{% set znaleziono = namespace(ile=0) %}
|
||||
{% for o in result.objects or [] %}
|
||||
{% set trafienia = o.facets | selectattr('samples') | list %}
|
||||
{% if trafienia %}
|
||||
{% set znaleziono.ile = znaleziono.ile + trafienia | length %}
|
||||
<section class="card">
|
||||
<h3>{{ o.object }}
|
||||
<span class="muted">w {{ o.sign }}{% if o.house %}, {{ o.house }}. dom{% endif %}
|
||||
{%- if o.direction == 'Rx' %} · Rx{% endif %}</span>
|
||||
</h3>
|
||||
{% for f in trafienia %}
|
||||
<div class="sig">
|
||||
<div class="sig-head">{{ f.label }} <span class="muted small">— {{ f.count }} dopasowań</span></div>
|
||||
<table>
|
||||
<tbody>
|
||||
{% for s in f.samples %}
|
||||
<tr>
|
||||
<td class="key" title="{{ s.significator }}">{{ s.expanded }}</td>
|
||||
<td>{{ s.effect }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if znaleziono.ile == 0 %}
|
||||
<p class="muted">
|
||||
Nie znaleziono interpretacji dla tych danych. Sprawdź, czy dodano bazę
|
||||
zawierającą pasujące opisy.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>astrodemo · logowanie</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-main">
|
||||
<header class="topbar"><h1>astrodemo</h1></header>
|
||||
|
||||
{% if request.query_params.get('wylogowano') %}
|
||||
<p class="ok">Wylogowano.</p>
|
||||
{% endif %}
|
||||
{% if blad %}<p class="alert">{{ blad }}</p>{% endif %}
|
||||
|
||||
<form method="post" action="/logowanie" class="card login-card">
|
||||
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
|
||||
po stronie serwera — pole formularza jest wejściem od użytkownika. #}
|
||||
<input type="hidden" name="dokad" value="{{ dokad }}">
|
||||
<label>Login
|
||||
<input type="text" name="login" value="{{ login or '' }}" required
|
||||
autocomplete="username" autofocus></label>
|
||||
<label>Hasło
|
||||
<input type="password" name="haslo" required autocomplete="current-password"></label>
|
||||
<div class="actions"><button type="submit">Zaloguj</button></div>
|
||||
</form>
|
||||
|
||||
<p class="muted small login-note">
|
||||
Nie masz konta albo nie pamiętasz hasła? Zgłoś się do osoby, która zakładała
|
||||
Ci dostęp — kont nie da się odzyskać samodzielnie.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
@@ -0,0 +1,11 @@
|
||||
# CELOWO KRÓTSZA niż w warstwie prezentacji. Astroklient ma dwie funkcje, więc nie
|
||||
# potrzebuje ani Excela (brak eksportu), ani stref czasowych z lokalizacji (offset
|
||||
# podaje się wprost), ani niczego pod kosmogram — a każda zbędna zależność w obrazie
|
||||
# demo to kolejna rzecz do pilnowania i kolejna droga do środka.
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
httpx>=0.28
|
||||
jinja2>=3.1
|
||||
python-multipart>=0.0.20
|
||||
# Szyfrowanie łącza do warstwy logicznej (PRE-16): AES-256-GCM + HKDF
|
||||
cryptography>=44.0
|
||||
@@ -0,0 +1,301 @@
|
||||
"""astrodemo — wersja demonstracyjna (PRE-28).
|
||||
|
||||
DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK
|
||||
|
||||
1. Demo ma DWIE funkcje i ani jednej więcej. Sprawdzamy to na trasach aplikacji,
|
||||
nie w szablonie: trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem,
|
||||
bo ta usługa rozmawia z warstwą danych produkcji.
|
||||
|
||||
2. „Dodać plik do bazy" znaczy, że plik ZACZYNA BYĆ UŻYWANY. W pełnej aplikacji
|
||||
wgranie i włączenie to dwie decyzje (DAN-27); tutaj muszą być jedną, inaczej
|
||||
po wgraniu nic się nie zmienia i demo wygląda na zepsute.
|
||||
"""
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from app import security
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo,klientA:hasloA,klientB:hasloB")
|
||||
monkeypatch.delenv("ASTRODEMO_USER", raising=False)
|
||||
monkeypatch.delenv("ASTRODEMO_PASSWORD", raising=False)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||
# Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34).
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http
|
||||
|
||||
|
||||
def _auth(user="demo", password=""):
|
||||
"""Nagłówek z WAŻNĄ SESJĄ — odpowiednik bycia zalogowanym.
|
||||
|
||||
Po przejściu z Basic na sesje (LOG-34) „zalogowany" znaczy „ma podpisane
|
||||
ciasteczko", nie „ma nagłówek z hasłem". Hasło przyjmowane tylko po to, żeby
|
||||
nie przepisywać wszystkich wywołań — sprawdzanie poświadczeń ma własne testy."""
|
||||
from app import security, session
|
||||
|
||||
return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"}
|
||||
|
||||
|
||||
REPORT = {
|
||||
"engine": "test",
|
||||
"objects": [{
|
||||
"object": "Sun", "sign": "Taurus", "house": 11, "direction": "D",
|
||||
"planet_token": "Su", "planet_total": 3,
|
||||
"facets": [{"label": "w znaku Taurus", "token": "Su Tau", "count": 1, "score": 5.0,
|
||||
"samples": [{"significator": "Su Tau", "expanded": "Sun in Taurus",
|
||||
"effect": "wytrwały, praktyczny"}]}],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def _client(monkeypatch, calls=None, upload=None):
|
||||
"""Podmieniamy METODY KLASY, nie obiekt modułowy: klient jest budowany per
|
||||
żądanie, żeby pula wynikała z zalogowanego konta. Dzięki temu podglądamy
|
||||
też `self.tenant`, czyli to, do której puli faktycznie poszło zapytanie."""
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.clients.logic_client import LogicClient
|
||||
from app.main import app
|
||||
|
||||
calls = calls if calls is not None else []
|
||||
domyslny = {"path": "nowa.xlsx", "name": "nowa.xlsx", "accepted": True}
|
||||
|
||||
monkeypatch.setattr(LogicClient, "report",
|
||||
lambda self, **kw: (calls.append(("report", self.tenant, kw)), REPORT)[1])
|
||||
monkeypatch.setattr(LogicClient, "files_upload",
|
||||
lambda self, *a, **kw: (calls.append(("upload", self.tenant, a)),
|
||||
upload or domyslny)[1])
|
||||
monkeypatch.setattr(LogicClient, "files_status",
|
||||
lambda self, *a, **kw: (calls.append(("status", self.tenant, a)), {})[1])
|
||||
return TestClient(app), calls
|
||||
|
||||
|
||||
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||
"lat": "50.06", "lon": "19.94"}
|
||||
|
||||
|
||||
# ── powierzchnia: dokładnie dwie funkcje ────────────────────────────────
|
||||
|
||||
def test_the_service_exposes_nothing_beyond_its_two_functions():
|
||||
"""Trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem — ta usługa
|
||||
rozmawia z warstwą danych PRODUKCJI."""
|
||||
from app.main import app
|
||||
|
||||
trasy = {(m, r.path) for r in app.routes for m in (getattr(r, "methods", None) or set())
|
||||
if not r.path.startswith("/static") and m in {"GET", "POST"}}
|
||||
assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health"),
|
||||
("GET", "/logowanie"), ("POST", "/logowanie"), ("POST", "/wyloguj")}
|
||||
|
||||
|
||||
def test_the_client_can_only_ask_for_what_the_demo_needs():
|
||||
"""Klient warstwy logicznej jest okrojony CELOWO: metody, której nie ma, nikt
|
||||
przypadkiem nie zawoła i nie podpowie ona, że po drugiej stronie jest więcej."""
|
||||
from app.clients.logic_client import LogicClient
|
||||
|
||||
publiczne = {n for n in dir(LogicClient) if not n.startswith("_")}
|
||||
assert publiczne == {"report", "files_upload", "files_status", "health"}
|
||||
|
||||
|
||||
def test_api_docs_are_off():
|
||||
"""/docs wypisałoby komplet tras — demo ma nie zdradzać własnej powierzchni."""
|
||||
from app.main import app
|
||||
|
||||
assert app.docs_url is None and app.openapi_url is None and app.redoc_url is None
|
||||
|
||||
|
||||
# ── logowanie ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_without_credentials_nothing_is_served(env, monkeypatch):
|
||||
c, _ = _client(monkeypatch)
|
||||
for method, path in (("get", "/"), ("post", "/"), ("post", "/plik")):
|
||||
r = getattr(c, method)(path, follow_redirects=False)
|
||||
assert r.status_code == 303, path
|
||||
assert "/logowanie" in r.headers["location"], path
|
||||
assert c.get("/health").status_code == 200, "sonda musi działać bez hasła"
|
||||
|
||||
|
||||
def test_demo_account_is_separate_from_the_main_application(env, monkeypatch):
|
||||
"""Konto astrodemo MUSI być własne: demo pracuje na tej samej warstwie
|
||||
danych co produkcja, więc odcięcie go nie może wymagać zmiany haseł
|
||||
w głównej aplikacji."""
|
||||
monkeypatch.setenv("APP_USER", "astrololo")
|
||||
monkeypatch.setenv("APP_PASSWORD", "haslo-glownej-aplikacji")
|
||||
c, _ = _client(monkeypatch)
|
||||
assert security.verify("astrololo", "haslo-glownej-aplikacji") is None
|
||||
assert security.verify("demo", "tajne-demo") == "demo"
|
||||
|
||||
|
||||
def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch):
|
||||
monkeypatch.setenv("ASTRODEMO_USERS", "demo:zażółć-gęślą")
|
||||
c, _ = _client(monkeypatch)
|
||||
|
||||
|
||||
|
||||
# ── funkcja 1: dodanie pliku ─────────────────────────────────────────────
|
||||
|
||||
def test_adding_a_file_uploads_AND_switches_it_on(env, monkeypatch):
|
||||
"""Sedno wymagania: jedna czynność, nie dwie."""
|
||||
c, calls = _client(monkeypatch)
|
||||
r = c.post("/plik", headers=_auth(), follow_redirects=False,
|
||||
files={"upload": ("baza.xlsx", b"zawartosc", "application/vnd.ms-excel")})
|
||||
assert r.status_code == 303
|
||||
rodzaje = [k[0] for k in calls]
|
||||
assert rodzaje == ["upload", "status"], f"oczekiwano wgrania i włączenia, było: {rodzaje}"
|
||||
assert calls[1][2][1] == "active", "plik nie został włączony do użytku"
|
||||
|
||||
|
||||
def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, monkeypatch):
|
||||
"""Walidacja zostaje, ale jej REGUŁY zna wyłącznie administrator (DAN-27) —
|
||||
demo nie ma prawa ich zdradzić, nawet mimochodem."""
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
c, calls = _client(monkeypatch, upload={"path": "zla.xlsx", "name": "zla.xlsx",
|
||||
"accepted": False})
|
||||
r = c.post("/plik", headers=_auth(), follow_redirects=False,
|
||||
files={"upload": ("zla.xlsx", b"x", "text/plain")})
|
||||
assert r.status_code == 303
|
||||
assert [k[0] for k in calls] == ["upload"], "odrzucony plik został włączony do użytku"
|
||||
komunikat = unquote_plus(r.headers["location"])
|
||||
for slowo in ("walidacj", "reguł", "kolumn", "rozszerzeni", "rozmiar"):
|
||||
assert slowo not in komunikat, f"komunikat zdradza mechanizm: „{slowo}”"
|
||||
|
||||
|
||||
def test_an_empty_file_is_refused_before_anything_is_sent(env, monkeypatch):
|
||||
c, calls = _client(monkeypatch)
|
||||
r = c.post("/plik", headers=_auth(), follow_redirects=False,
|
||||
files={"upload": ("pusty.xlsx", b"", "application/vnd.ms-excel")})
|
||||
assert r.status_code == 303 and calls == []
|
||||
|
||||
|
||||
# ── funkcja 2: interpretacja urodzeniowa ────────────────────────────────
|
||||
|
||||
def test_asking_for_an_interpretation_returns_the_matches(env, monkeypatch):
|
||||
c, calls = _client(monkeypatch)
|
||||
r = c.post("/", headers=_auth(), data=FORM)
|
||||
assert r.status_code == 200
|
||||
assert "Sun in Taurus" in r.text and "wytrwały, praktyczny" in r.text
|
||||
assert [k[0] for k in calls] == ["report"]
|
||||
|
||||
|
||||
def test_local_time_is_converted_to_utc_before_asking(env, monkeypatch):
|
||||
"""11:20 przy offsecie +2 to 09:20 UTC. Pomyłka tutaj przesuwa CAŁY horoskop,
|
||||
a wynik nadal wygląda wiarygodnie — dlatego sprawdzamy wprost."""
|
||||
c, calls = _client(monkeypatch)
|
||||
c.post("/", headers=_auth(), data=FORM)
|
||||
assert calls[0][2]["when_utc_iso"].startswith("1984-04-30T09:20")
|
||||
|
||||
|
||||
def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch):
|
||||
c, calls = _client(monkeypatch)
|
||||
r = c.post("/", headers=_auth(), data={**FORM, "date": "30 kwietnia"})
|
||||
assert r.status_code == 200 and "RRRR-MM-DD" in r.text
|
||||
assert calls == [], "poszło zapytanie mimo błędnych danych"
|
||||
|
||||
|
||||
def test_a_broken_logic_layer_is_reported_not_crashed(env, monkeypatch):
|
||||
import httpx
|
||||
|
||||
from app.clients.logic_client import LogicClient
|
||||
|
||||
c, _ = _client(monkeypatch)
|
||||
monkeypatch.setattr(LogicClient, "report", lambda self, **kw: (_ for _ in ()).throw(
|
||||
httpx.ConnectError("brak połączenia")))
|
||||
r = c.post("/", headers=_auth(), data=FORM)
|
||||
# Komunikat jest CELOWO nieinformacyjny: treść wyjątku niosła nazwę usługi,
|
||||
# jej port i nazwy tras, czyli mapę tego, co stoi za tym ekranem.
|
||||
assert r.status_code == 200
|
||||
assert "Nie udało się teraz wykonać tej operacji" in r.text
|
||||
assert "Warstwa logiczna" not in r.text and "8001" not in r.text
|
||||
|
||||
|
||||
def test_the_page_never_offers_anything_it_cannot_do(env, monkeypatch):
|
||||
"""Demo ma wyglądać na KOMPLETNE w swojej postaci — żadnych śladów funkcji,
|
||||
których nie ma (ta sama zasada, co przy uprawnieniach w PRE-27)."""
|
||||
c, _ = _client(monkeypatch)
|
||||
html = c.get("/", headers=_auth()).text
|
||||
for slowo in ("Kosmogram", "Synastria", "Kalendarz", "Skompiluj", "Ustawienia",
|
||||
"Konta", "PDF", "Excel", "prompt", "model"):
|
||||
assert slowo not in html, f"strona wspomina o „{slowo}”, czego demo nie umie"
|
||||
|
||||
|
||||
# ── pule kont: izolacja (PRE-29) ─────────────────────────────────────────
|
||||
# Demo jest udostępniane szeroko i różnym osobom, więc pula musi wynikać
|
||||
# z tego, KTO SIĘ ZALOGOWAŁ — i z niczego innego. Wyciek byłby cichy: klient
|
||||
# zobaczyłby cudze bazy jako własne.
|
||||
|
||||
def test_the_pool_is_taken_from_the_login(env, monkeypatch):
|
||||
c, calls = _client(monkeypatch)
|
||||
c.post("/", headers=_auth("klientA", "hasloA"), data=FORM)
|
||||
c.post("/", headers=_auth("klientB", "hasloB"), data=FORM)
|
||||
assert [k[1] for k in calls] == ["klientA", "klientB"]
|
||||
|
||||
|
||||
def test_uploads_go_to_the_pool_of_the_account_that_sent_them(env, monkeypatch):
|
||||
c, calls = _client(monkeypatch)
|
||||
c.post("/plik", headers=_auth("klientA", "hasloA"), follow_redirects=False,
|
||||
files={"upload": ("baza.xlsx", b"x", "application/vnd.ms-excel")})
|
||||
assert {k[1] for k in calls} == {"klientA"}, "wgranie trafiło do cudzej puli"
|
||||
|
||||
|
||||
def test_the_pool_cannot_be_chosen_from_the_form(env, monkeypatch):
|
||||
"""Gdyby pula przychodziła z żądania, wystarczyłoby podstawić cudzy login."""
|
||||
c, calls = _client(monkeypatch)
|
||||
c.post("/", headers=_auth("klientA", "hasloA"),
|
||||
data={**FORM, "tenant": "klientB", "user": "klientB", "login": "klientB"})
|
||||
assert [k[1] for k in calls] == ["klientA"], "pula dała się podmienić z formularza"
|
||||
|
||||
|
||||
def test_every_account_has_its_own_pool_by_construction(env, monkeypatch):
|
||||
"""Każdy login = inna pula. Dwa konta nie mogą dzielić zbioru baz."""
|
||||
c, calls = _client(monkeypatch)
|
||||
for login, haslo in (("demo", "tajne-demo"), ("klientA", "hasloA"), ("klientB", "hasloB")):
|
||||
c.post("/", headers=_auth(login, haslo), data=FORM)
|
||||
pule = [k[1] for k in calls]
|
||||
assert len(set(pule)) == len(pule) == 3
|
||||
|
||||
|
||||
def test_an_unknown_account_gets_nothing(env, monkeypatch):
|
||||
"""Bez ważnej sesji żądanie nie schodzi niżej — nie wystarczy, że skończy
|
||||
się przekierowaniem: nie może po drodze dotknąć warstwy logicznej."""
|
||||
c, calls = _client(monkeypatch)
|
||||
r = c.post("/", data=FORM, follow_redirects=False)
|
||||
assert r.status_code == 303 and "/logowanie" in r.headers["location"]
|
||||
assert calls == [], "poszło zapytanie mimo braku sesji"
|
||||
|
||||
|
||||
def test_logging_in_and_out_of_the_demo(env, monkeypatch):
|
||||
"""Pełny obieg: demo bywa używane na cudzych komputerach, więc wyjście musi
|
||||
naprawdę kończyć sesję, a nie tylko tak wyglądać."""
|
||||
from app import session
|
||||
|
||||
c, _ = _client(monkeypatch)
|
||||
r = c.post("/logowanie", follow_redirects=False,
|
||||
data={"login": "klientA", "haslo": "hasloA", "dokad": "/"})
|
||||
assert r.status_code == 303 and session.COOKIE in r.cookies
|
||||
|
||||
wyjscie = c.post("/wyloguj", headers=_auth("klientA"), follow_redirects=False)
|
||||
assert wyjscie.status_code == 303
|
||||
ustawione = wyjscie.headers.get("set-cookie", "")
|
||||
assert session.COOKIE in ustawione and "Max-Age=0" in ustawione
|
||||
|
||||
|
||||
def test_removing_an_account_kills_its_open_session(env, monkeypatch):
|
||||
"""Demo nie ma gdzie trwale zapisywać stanu, więc zdalne unieważnienie robi
|
||||
się przez ASTRODEMO_USERS — usunięcie albo zmiana hasła ubija sesję odciskiem."""
|
||||
c, _ = _client(monkeypatch)
|
||||
ciasteczko = _auth("klientA")
|
||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200
|
||||
|
||||
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo,klientB:hasloB") # bez klientA
|
||||
assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303
|
||||
|
||||
|
||||
def test_the_demo_login_cannot_be_turned_into_a_redirector(env, monkeypatch):
|
||||
c, _ = _client(monkeypatch)
|
||||
for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x"):
|
||||
r = c.post("/logowanie", follow_redirects=False,
|
||||
data={"login": "demo", "haslo": "tajne-demo", "dokad": zly})
|
||||
assert r.headers["location"] == "/", f"przepuszczono: {zly}"
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Zapora słownikowa astrodemo: czego użytkownik demo nie może zobaczyć.
|
||||
|
||||
astrodemo stoi najniżej w drabinie trzech produktów (astrodemo → astroklient →
|
||||
astrololo) i jest dawane najszerzej. Ma dwie funkcje i ma wyglądać, jakby to
|
||||
było wszystko, co program potrafi.
|
||||
|
||||
Test nie sprawdza pojedynczych miejsc, tylko przechodzi WSZYSTKO, co da się
|
||||
pobrać, i szuka słów, które nie mają prawa paść. Sprawdza też DRZEWO PLIKÓW
|
||||
usługi — bo „nie ma śladu w kodzie" znaczy, że `grep` po obrazie ma nic nie
|
||||
znaleźć, a nie tylko że odpowiedź jest czysta.
|
||||
|
||||
Powód, dla którego to powstało: komentarz w pierwszej linii styles.css mówił
|
||||
„Nie kopiujemy stylów pełnej aplikacji", a arkusz pobierał anonim.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||
|
||||
# (a) że istnieje wersja obszerniejsza i jak się nazywa
|
||||
WIEKSZY_BRAT = ["pełna aplikacja", "pełnej aplikacji", "pełna wersja", "pełnej wersji",
|
||||
"astrololo", "astroklient", "wersji podstawowej", "upgrade", "rozszerzon"]
|
||||
# (b) że tekst pisze model językowy
|
||||
# Bez samego „token": w tej usłudze znaczy token sesji i token międzywarstwowy,
|
||||
# a nie jednostkę rozliczeniową modelu. Zamiast tego zwroty jednoznaczne.
|
||||
MODEL = ["LLM", "anthropic", "openai", "gpt", "claude", "prompt",
|
||||
"generowanie tekstu", "sztuczn", "okno kontekstu", "model językow"]
|
||||
# (c) funkcje, których astrodemo nie ma
|
||||
INNE_FUNKCJE = ["kosmogram", "aspektarian", "synastri", "kalendarz", "predykcj",
|
||||
"skompiluj", "system domów", "systemy domów", "deklinacj", "antyscj"]
|
||||
# (d) że jest ktoś, kto widzi więcej, i że produkt jest w budowie
|
||||
MECHANIZMY = ["administrator", "uprawnieni", "walidacj", "kwarantann", "roadmap",
|
||||
"w budowie", "widok testowy", "warstwa logiczna", "layer"]
|
||||
# Dwa poziomy, bo dwie różne drogi wycieku.
|
||||
#
|
||||
# WSZĘDZIE — także w kodzie serwera, którego przeglądarka nigdy nie zobaczy.
|
||||
# Obraz tej usługi się KOMUŚ ODDAJE, więc kto go dostanie, przeczyta też
|
||||
# komentarze i docstringi. Zdanie „w pełnej aplikacji to działa inaczej" mówi mu
|
||||
# wprost, że istnieje pełna aplikacja — a to jest dokładnie to, czego ma nie
|
||||
# wiedzieć. Komentarz ma tłumaczyć TĘ usługę, bez powoływania się na rodzeństwo.
|
||||
WSZEDZIE = WIEKSZY_BRAT + MODEL + INNE_FUNKCJE
|
||||
# TYLKO DO PRZEGLĄDARKI — słownictwo inżynierskie opisujące własne działanie tej
|
||||
# usługi. W komentarzu serwera jest na miejscu; w odpowiedzi HTTP nie ma czego
|
||||
# szukać, bo mówi o mechanizmach, o których użytkownik nie ma wiedzieć.
|
||||
DO_PRZEGLADARKI = WSZEDZIE + MECHANIZMY
|
||||
ZAKAZANE = DO_PRZEGLADARKI
|
||||
WZORCE = [(r"(?i)\b(PRE|LOG|DAN)-\d+\b", "identyfikator wymagania"),
|
||||
(r"(?i)\bfeat/[\w-]+", "nazwa gałęzi rozwojowej"),
|
||||
(r"(?i)\bAI\b", "model językowy")]
|
||||
|
||||
|
||||
def _grzechy(tresc: str, gdzie: str, slownik: list[str] | None = None) -> list[str]:
|
||||
out = []
|
||||
for igla in (slownik if slownik is not None else ZAKAZANE):
|
||||
if re.search(rf"(?i)(?<![\w-]){re.escape(igla)}", tresc):
|
||||
out.append(f"{gdzie}: „{igla}”")
|
||||
for wzor, powod in (WZORCE if slownik is None else WZORCE[-1:]):
|
||||
m = re.search(wzor, tresc)
|
||||
if m:
|
||||
out.append(f"{gdzie}: „{m.group(0)}” — {powod}")
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def klient(monkeypatch):
|
||||
monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo")
|
||||
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
return TestClient(app, follow_redirects=False)
|
||||
|
||||
|
||||
def test_nothing_forbidden_reaches_an_anonymous_visitor(klient):
|
||||
grzechy, obejrzane = [], 0
|
||||
for adres in ("/logowanie", "/health", "/static/styles.css"):
|
||||
r = klient.get(adres)
|
||||
assert r.status_code == 200, f"{adres}: {r.status_code}"
|
||||
obejrzane += 1
|
||||
grzechy += _grzechy(r.text, adres)
|
||||
assert obejrzane == 3
|
||||
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||
|
||||
|
||||
def test_nothing_forbidden_reaches_a_logged_in_visitor(klient, monkeypatch):
|
||||
from app import security, session
|
||||
|
||||
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
|
||||
# Warstwa logiczna niedostępna CELOWO: ekran idzie wtedy ścieżką błędu,
|
||||
# a to komunikaty o awarii wypisywały nazwy warstw i tras.
|
||||
import httpx
|
||||
|
||||
from app.main import LogicClient
|
||||
|
||||
monkeypatch.setattr(LogicClient, "report",
|
||||
lambda *a, **k: (_ for _ in ()).throw(httpx.ConnectError("nie da rady")))
|
||||
grzechy = []
|
||||
for adres in ("/", "/static/styles.css"):
|
||||
r = klient.get(adres, headers=naglowki)
|
||||
assert r.status_code == 200, f"{adres}: {r.status_code}"
|
||||
grzechy += _grzechy(r.text, adres)
|
||||
r = klient.post("/", headers=naglowki,
|
||||
data={"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||
"lat": "50.06", "lon": "19.94"})
|
||||
grzechy += _grzechy(r.text, "POST /")
|
||||
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||
|
||||
|
||||
# Stałe protokołu łącza (nagłówki, typ treści, etykieta HKDF) niosą nazwę rodziny
|
||||
# produktów, ale są WSPÓLNE z warstwą logiczną: zmiana wymaga jednoczesnej zmiany
|
||||
# we wszystkich usługach i rotacji, więc to osobna decyzja. Nie idą do przeglądarki.
|
||||
BEZ_KONTROLI = {"app/link_crypto.py"}
|
||||
|
||||
|
||||
def test_the_source_tree_itself_carries_no_trace():
|
||||
"""„Bez śladu w kodzie" znaczy: grep po obrazie ma nic nie znaleźć.
|
||||
|
||||
Odpowiedź może być czysta, a plik i tak nieść komentarz — dokładnie tak
|
||||
wyciekał arkusz stylów, zanim komentarze zaczęły być zdejmowane."""
|
||||
# Identyfikatory wymagań (PRE-28, LOG-34) zostają: to notatki inżynierskie
|
||||
# o tej usłudze, nie informacja o istnieniu czegoś większego.
|
||||
grzechy = []
|
||||
for f in APP.rglob("*"):
|
||||
wzgledna = str(f.relative_to(APP.parent))
|
||||
if (not f.is_file() or "__pycache__" in str(f) or f.suffix == ".pyc"
|
||||
or wzgledna in BEZ_KONTROLI):
|
||||
continue
|
||||
try:
|
||||
grzechy += _grzechy(f.read_text(encoding="utf-8"),
|
||||
str(f.relative_to(APP.parent)), WSZEDZIE)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||
|
||||
|
||||
def test_an_unknown_asset_is_indistinguishable_from_one_that_never_existed(klient):
|
||||
from app import security, session
|
||||
|
||||
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('demo')}"}
|
||||
nieistniejacy = klient.get("/static/nie-ma-takiego.js", headers=naglowki)
|
||||
for adres in ("/static/main.py", "/static/../app/config.py", "/static/vendor/cokolwiek.js"):
|
||||
r = klient.get(adres, headers=naglowki)
|
||||
assert r.status_code == nieistniejacy.status_code == 404, f"{adres}: {r.status_code}"
|
||||
assert r.text == nieistniejacy.text
|
||||
|
||||
|
||||
def test_the_protocol_constants_are_the_one_known_exception():
|
||||
"""Świadomie zostawiona dziura — opisana, żeby nie była przypadkiem.
|
||||
|
||||
Stałe łącza międzywarstwowego (`X-Astrololo-Token`, `X-Astrololo-Enc`, typ
|
||||
treści, etykieta HKDF) niosą nazwę rodziny produktów. Są WSPÓLNE z warstwą
|
||||
logiczną, więc zmiana wymaga jednoczesnej podmiany we wszystkich usługach
|
||||
i rotacji sekretów — to osobna decyzja, nie efekt uboczny tej zmiany.
|
||||
|
||||
Warunek, pod jakim to zostaje: te stałe nie mają prawa dotrzeć do
|
||||
przeglądarki. Idą wyłącznie w nagłówkach żądań DO warstwy logicznej.
|
||||
Ten test pilnuje właśnie tego warunku, a nie samego istnienia stałych.
|
||||
"""
|
||||
rdzen = "astrololo"
|
||||
protokol = [f for f in APP.rglob("*.py")
|
||||
if f.is_file() and rdzen in f.read_text(encoding="utf-8").lower()]
|
||||
assert protokol, "stałe zniknęły — jeśli je przemianowano, ten wyjątek jest zbędny"
|
||||
for f in protokol:
|
||||
tresc = f.read_text(encoding="utf-8").lower()
|
||||
for linia in tresc.splitlines():
|
||||
if rdzen not in linia:
|
||||
continue
|
||||
# Każde wystąpienie musi być stałą protokołu albo nazwą kanału logów —
|
||||
# nigdy tekstem, który mógłby trafić do szablonu.
|
||||
assert any(z in linia for z in ("x-astrololo", "vnd.astrololo",
|
||||
"astrololo/link/", 'getlogger("astrololo')), \
|
||||
f"{f.name}: „{linia.strip()[:70]}” to nie jest stała protokołu"
|
||||
|
||||
|
||||
def test_only_the_application_code_ships_in_the_image():
|
||||
"""Obraz nie może wieźć tego testu.
|
||||
|
||||
Ironia, którą łatwo przeoczyć: plik pilnujący, żeby pewne słowa nie padły,
|
||||
sam je wypisuje — więc gdy trafi do obrazu, staje się tym wyciekiem, przed
|
||||
którym broni. `COPY . .` wnosiło cały katalog usługi, razem z tests/.
|
||||
"""
|
||||
# Same instrukcje, bez komentarzy: komentarz obok tej linii CYTUJE dawną
|
||||
# postać, więc szukanie po całym pliku zgłaszałoby własne wyjaśnienie.
|
||||
instrukcje = [l for l in (APP.parent / "Dockerfile").read_text(encoding="utf-8").splitlines()
|
||||
if l.strip() and not l.lstrip().startswith("#")]
|
||||
assert not any(l.strip() == "COPY . ." for l in instrukcje), "obraz wnosi wszystko, w tym testy"
|
||||
assert any(l.strip() == "COPY app ./app" for l in instrukcje)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Przegląd baz na udziale i ich globalne włączanie/wyłączanie (DAN-15 / PRE-09).
|
||||
|
||||
Wymaganie przedefiniowane pod model serwerowy: pliki baz leżą na stałym NFS, więc
|
||||
nie wybiera się folderu — potrzeba za to WIDZIEĆ, jakie bazy są dostępne (nazwa +
|
||||
metaopis) i móc globalnie zdecydować, które biorą udział w interpretacji.
|
||||
|
||||
Stan przełączników trzymamy DEKLARATYWNIE w zmiennej `DISABLED_BASES`, a nie w
|
||||
pliku, bo warstwa danych nie ma gdzie trwale zapisywać: udział z bazami jest
|
||||
montowany read-only, a katalog cache to `emptyDir` (ginie przy restarcie poda).
|
||||
Zapis do pliku po cichu wracałby więc do stanu sprzed restartu — a ciche
|
||||
przywrócenie wyłączonej bazy jest gorsze niż konieczność edycji konfiguracji.
|
||||
|
||||
Dopasowanie jest tolerancyjne: wpis pasuje po nazwie pliku ALBO po ścieżce
|
||||
względnej — żeby dało się wyłączyć zarówno „stara_baza.xlsx", jak i
|
||||
„archiwum/stara_baza.xlsx".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def disabled_entries() -> list[str]:
|
||||
return [e.strip() for e in os.getenv("DISABLED_BASES", "").split(",") if e.strip()]
|
||||
|
||||
|
||||
def _relative(path: Path, root: Path) -> str:
|
||||
try:
|
||||
return str(path.relative_to(root))
|
||||
except ValueError:
|
||||
return path.name
|
||||
|
||||
|
||||
def is_enabled(path: str | Path, root: str | Path, entries: list[str] | None = None) -> bool:
|
||||
"""Czy baza bierze udział w wyszukiwaniu interpretacji."""
|
||||
entries = disabled_entries() if entries is None else entries
|
||||
if not entries:
|
||||
return True
|
||||
p = Path(path)
|
||||
rel = _relative(p, Path(root))
|
||||
return not any(e == p.name or e == rel for e in entries)
|
||||
|
||||
|
||||
def list_bases(root: str | Path, paths: list[str]) -> list[dict]:
|
||||
"""Bazy dostępne na udziale + metaopis. Celowo TANI opis (dane z systemu
|
||||
plików): przy setkach plików liczenie rekordów oznaczałoby wczytanie każdego."""
|
||||
root = Path(root)
|
||||
entries = disabled_entries()
|
||||
out: list[dict] = []
|
||||
for raw in paths:
|
||||
p = Path(raw)
|
||||
try:
|
||||
st = p.stat()
|
||||
size_mb, modified = round(st.st_size / (1024 * 1024), 2), st.st_mtime
|
||||
except OSError:
|
||||
size_mb, modified = None, None
|
||||
out.append({
|
||||
"name": p.name,
|
||||
"path": _relative(p, root),
|
||||
"size_mb": size_mb,
|
||||
"modified": (datetime.fromtimestamp(modified, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
if modified else None),
|
||||
"enabled": is_enabled(p, root, entries),
|
||||
})
|
||||
return out
|
||||
+7
-2
@@ -31,5 +31,10 @@ class FrameCache:
|
||||
return None
|
||||
|
||||
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
|
||||
# astype(str) na kolumnach object zapewnia stabilny zapis Parquet
|
||||
frame.to_parquet(self._path(fp, sheet), index=False)
|
||||
# Kolumny z heterogenicznych plików Excela bywają mieszane (int + str +
|
||||
# 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,70 @@
|
||||
"""Rekordy-pułapki (canary) — wykrywanie wycieku baz (DAN-26).
|
||||
|
||||
Zabezpieczenie DETEKCYJNE, nie prewencyjne. Kilka unikalnych, wiarygodnie
|
||||
wyglądających rekordów wplecionych w bazy: nie zmieniają interpretacji (są
|
||||
ODSIEWANE z wyników, więc nie trafiają ani do użytkownika, ani do promptu LLM —
|
||||
wymóg LOG-30), ale jeśli kiedyś pojawią się w cudzej kopii, są dowodem pochodzenia.
|
||||
|
||||
Pułapkę rozpoznajemy po MARKERZE: unikalny ciąg, który nie występuje w realnych
|
||||
danych (wpleciony np. w pole znaczące). Markery i WARIANT tego wdrożenia biorą się
|
||||
z konfiguracji (ENV `CANARY_MARKERS`, `CANARY_VARIANT`) — każda kopia może dostać
|
||||
swój zestaw. Który wariant trafił do którego wdrożenia trzyma rejestr po stronie
|
||||
ops (osobno, poza kodem — patrz docs/canary-registry.md).
|
||||
|
||||
Dwa sygnały:
|
||||
* ODSIEWANIE — pułapka w wynikach znika, zanim opuści warstwę danych (log info);
|
||||
* TRIPWIRE — zapytanie CELUJE wprost w marker (ktoś enumeruje bazę, a nie liczy
|
||||
realny horoskop) → log warning, bo to podejrzane zachowanie.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
log = logging.getLogger("astrololo.data.canary")
|
||||
|
||||
|
||||
def _load_markers() -> list[str]:
|
||||
return [m.strip() for m in os.getenv("CANARY_MARKERS", "").split(",") if m.strip()]
|
||||
|
||||
|
||||
MARKERS = _load_markers()
|
||||
VARIANT = os.getenv("CANARY_VARIANT", "")
|
||||
|
||||
|
||||
def _row_is_canary(row: dict, markers: list[str]) -> bool:
|
||||
"""Czy KTÓRAKOLWIEK tekstowa wartość wiersza zawiera marker pułapki."""
|
||||
for v in row.values():
|
||||
if isinstance(v, str):
|
||||
for m in markers:
|
||||
if m in v:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def screen(
|
||||
rows: list[dict], query_value: str = "",
|
||||
markers: list[str] | None = None, variant: str | None = None,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""Zwraca (widoczne_wiersze, raport).
|
||||
|
||||
Odsiewa pułapki z wyników (nie opuszczą warstwy danych). Gdy zapytanie celuje
|
||||
wprost w marker — podnosi TRIPWIRE (możliwa enumeracja bazy). Bez skonfigurowanych
|
||||
markerów: przezroczyste (`active=False`), zero kosztu dla normalnego ruchu.
|
||||
"""
|
||||
markers = MARKERS if markers is None else markers
|
||||
variant = VARIANT if variant is None else variant
|
||||
if not markers:
|
||||
return rows, {"active": False, "removed": 0, "tripwire": False}
|
||||
|
||||
visible = [r for r in rows if not _row_is_canary(r, markers)]
|
||||
removed = len(rows) - len(visible)
|
||||
tripwire = any(m in (query_value or "") for m in markers)
|
||||
|
||||
if tripwire:
|
||||
log.warning(
|
||||
"CANARY TRIPWIRE: zapytanie celuje wprost w rekord-pułapkę (wariant %s) "
|
||||
"— możliwa enumeracja bazy", variant or "?")
|
||||
elif removed:
|
||||
log.info("Odsiano %d rekord(ów)-pułapek z wyników (wariant %s)", removed, variant or "?")
|
||||
return visible, {"active": True, "removed": removed, "tripwire": tripwire}
|
||||
@@ -37,6 +37,9 @@ class Settings:
|
||||
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
|
||||
|
||||
# Warstwa SQL (po migracji)
|
||||
# Domyślnie SQLite w cache — do testów i pracy lokalnej, bez stawiania bazy.
|
||||
# Na klastrze SQL_URL wskazuje Postgresa i przychodzi z SEKRETU, bo niesie
|
||||
# hasło (patrz deploy: astrololo/README-postgres.md).
|
||||
sql_url: str = field(
|
||||
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Rejestr plików baz: stan użycia, wgrywanie, archiwizacja, walidacja (DAN-27).
|
||||
|
||||
CO SIĘ ZMIENIA WZGLĘDEM DAN-15. Dotąd włączanie i wyłączanie baz szło przez
|
||||
zmienną `DISABLED_BASES` — deklaratywnie, bo warstwa danych nie miała gdzie
|
||||
zapisywać stanu (udział read-only, cache jako emptyDir). Teraz stan jest KLIKANY,
|
||||
więc musi być trwały: udział jest zapisywalny, a stan leży w pliku obok baz.
|
||||
|
||||
STANY PLIKU
|
||||
active — bierze udział w wyszukiwaniu,
|
||||
ready — sprawny, ale świadomie odstawiony; można włączyć jednym kliknięciem,
|
||||
archived — ZAMROŻONY: nie bierze udziału, ma znacznik czasu archiwizacji,
|
||||
sam plik zostaje nietknięty. To jedyna forma „usuwania" dostępna
|
||||
osobie wgrywającej dane,
|
||||
quarantine — wgrany, ale nie przeszedł walidacji. NIE JEST TRACONY; decyzję,
|
||||
czy go skasować, podejmuje wyłącznie administrator.
|
||||
|
||||
DLACZEGO KWARANTANNA JEST NIEWIDOCZNA POZA ADMINISTRATOREM. Zasada z PRE-27 mówi,
|
||||
że konto ograniczone nie ma skąd wiedzieć o mechanizmach, których nie obsługuje.
|
||||
Gdyby plik w kwarantannie był widoczny z powodem odrzucenia, każdy wgrywający
|
||||
poznałby reguły walidacji — a te są narzędziem administratora. Osoba wgrywająca
|
||||
widzi więc plik jako „oczekuje na zatwierdzenie", bez powodu i bez reguł.
|
||||
|
||||
REGUŁY WALIDACJI są danymi, nie kodem: administrator ustawia je z ekranu. Trzymamy
|
||||
je w tym samym pliku stanu, bo stan i reguły zmieniają się razem i muszą przetrwać
|
||||
restart tak samo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ACTIVE, READY, ARCHIVED, QUARANTINE = "active", "ready", "archived", "quarantine"
|
||||
|
||||
# Nazwa puli może przyjść z zewnątrz (login konta w wersji demo), więc przechodzi
|
||||
# przez sito: wyłącznie znaki bezpieczne w nazwie katalogu. Bez tego „../..” albo
|
||||
# ukośnik wyprowadziłyby zapytanie poza udział — czyli wprost do cudzych baz.
|
||||
_TENANT_OK = re.compile(r"[^A-Za-z0-9._-]")
|
||||
|
||||
|
||||
def tenant_root(root: Path | str, tenant: str = "") -> Path:
|
||||
"""Katalog, w którym mieszka pula danego konta.
|
||||
|
||||
Puste `tenant` = cały udział, czyli zachowanie produkcji. Nazwa jest
|
||||
OCZYSZCZANA, a nie sprawdzana i odrzucana: sito zamienia wszystko podejrzane
|
||||
na podkreślenie, więc żadne wejście nie wyprowadzi ścieżki wyżej."""
|
||||
root = Path(root)
|
||||
name = _TENANT_OK.sub("_", (tenant or "").strip()).strip("._")
|
||||
return root / name if name else root
|
||||
USABLE = frozenset({ACTIVE})
|
||||
|
||||
# Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych.
|
||||
# Kwarantanna świadomie poza listą — patrz nagłówek modułu.
|
||||
VISIBLE_TO_EVERYONE = frozenset({ACTIVE, READY, ARCHIVED})
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
DEFAULT_RULES: dict = {
|
||||
"extensions": [".xlsx"],
|
||||
"max_size_mb": 50,
|
||||
"min_rows": 1,
|
||||
"required_columns": [], # puste = bez wymagań co do nagłówków
|
||||
"reject_duplicate_content": True,
|
||||
}
|
||||
|
||||
|
||||
def state_path(root: Path | str) -> Path:
|
||||
"""Plik stanu — obok baz, chyba że wskazano inaczej.
|
||||
|
||||
Sprawdzamy NAPIS ze środowiska, nie Path(napis): Path("") to Path("."),
|
||||
czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna` zawsze
|
||||
wybierało pustą zmienną i zapisywało stan do katalogu bieżącego."""
|
||||
override = os.getenv("FILES_STATE", "").strip()
|
||||
return Path(override) if override else Path(root) / ".files-state.json"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def sha256_of(path: Path | str) -> str:
|
||||
"""Skrót treści pliku — tożsamość pliku niezależna od nazwy.
|
||||
|
||||
Przyda się też krokowi drugiemu (lustro w SQL): to po nim poznamy, że plik
|
||||
na dysku rozjechał się z tym, co wczytano do bazy."""
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# ── stan ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _read_state(root: Path) -> dict:
|
||||
try:
|
||||
with open(state_path(root), encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = {}
|
||||
files = data.get("files")
|
||||
rules = data.get("rules")
|
||||
return {
|
||||
"files": files if isinstance(files, dict) else {},
|
||||
"rules": {**DEFAULT_RULES, **(rules if isinstance(rules, dict) else {})},
|
||||
}
|
||||
|
||||
|
||||
def _write_state(root: Path, data: dict) -> None:
|
||||
path = state_path(root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Atomowo: plik stanu opisuje CAŁY zbiór baz, więc obcięcie go w połowie
|
||||
# zapisu skasowałoby wiedzę o wszystkich naraz.
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def rules(root: Path | str) -> dict:
|
||||
return _read_state(Path(root))["rules"]
|
||||
|
||||
|
||||
def set_rules(root: Path | str, new: dict) -> dict:
|
||||
root = Path(root)
|
||||
with _lock:
|
||||
data = _read_state(root)
|
||||
merged = {**data["rules"]}
|
||||
for key, value in (new or {}).items():
|
||||
if key in DEFAULT_RULES:
|
||||
merged[key] = value
|
||||
data["rules"] = merged
|
||||
_write_state(root, data)
|
||||
return merged
|
||||
|
||||
|
||||
# ── walidacja ────────────────────────────────────────────────────────────
|
||||
|
||||
def validate(path: Path | str, root: Path | str, *, digest: str = "",
|
||||
known_digests: dict[str, str] | None = None) -> list[str]:
|
||||
"""Lista POWODÓW odrzucenia. Pusta lista = plik nadaje się do użytku.
|
||||
|
||||
Zwracamy powody, a nie samo „tak/nie", bo administrator ma zobaczyć, CZEGO
|
||||
plikowi brakuje — inaczej poprawianie bazy byłoby zgadywanką. Poza konto
|
||||
administracyjne ta lista nie wychodzi."""
|
||||
p, rs = Path(path), rules(root)
|
||||
why: list[str] = []
|
||||
|
||||
exts = [str(e).lower() for e in rs.get("extensions") or []]
|
||||
if exts and p.suffix.lower() not in exts:
|
||||
why.append(f"rozszerzenie {p.suffix or '(brak)'} spoza dozwolonych: {', '.join(exts)}")
|
||||
|
||||
try:
|
||||
size_mb = p.stat().st_size / (1024 * 1024)
|
||||
except OSError:
|
||||
return why + ["pliku nie da się odczytać"]
|
||||
cap = float(rs.get("max_size_mb") or 0)
|
||||
if cap and size_mb > cap:
|
||||
why.append(f"rozmiar {size_mb:.1f} MB przekracza limit {cap:g} MB")
|
||||
|
||||
if rs.get("reject_duplicate_content") and known_digests:
|
||||
digest = digest or sha256_of(p)
|
||||
twin = next((name for name, d in known_digests.items()
|
||||
if d == digest and name != p.name), None)
|
||||
if twin:
|
||||
why.append(f"treść identyczna z plikiem „{twin}”")
|
||||
|
||||
required = [str(c).strip() for c in (rs.get("required_columns") or []) if str(c).strip()]
|
||||
min_rows = int(rs.get("min_rows") or 0)
|
||||
if required or min_rows:
|
||||
why += _inspect_workbook(p, required, min_rows)
|
||||
return why
|
||||
|
||||
|
||||
def _inspect_workbook(path: Path, required: list[str], min_rows: int) -> list[str]:
|
||||
"""Zagląda do arkusza: nagłówki i liczba wierszy.
|
||||
|
||||
read_only + tylko pierwszy arkusz — plik bazy potrafi mieć kilkadziesiąt MB,
|
||||
a wczytanie go w całości przy każdym wgraniu zatkałoby usługę."""
|
||||
try:
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
except Exception as e: # noqa: BLE001 — każdy błąd = powód
|
||||
return [f"nie udało się otworzyć arkusza ({type(e).__name__})"]
|
||||
why: list[str] = []
|
||||
try:
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = [str(c).strip().lower() for c in (next(rows, ()) or ()) if c is not None]
|
||||
missing = [c for c in required if c.strip().lower() not in header]
|
||||
if missing:
|
||||
why.append(f"brak wymaganych kolumn: {', '.join(missing)}")
|
||||
if min_rows:
|
||||
seen = sum(1 for i, _ in enumerate(rows) if i < min_rows)
|
||||
if seen < min_rows:
|
||||
why.append(f"za mało wierszy danych ({seen} < {min_rows})")
|
||||
finally:
|
||||
wb.close()
|
||||
return why
|
||||
|
||||
|
||||
# ── rejestr ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _scan(root: Path) -> list[Path]:
|
||||
"""Pliki na udziale, bez śmieci technicznych.
|
||||
|
||||
Pomijamy nie tylko ukryte PLIKI, ale i wszystko, co leży w ukrytym KATALOGU:
|
||||
filtr po samej nazwie pliku wciągał do rejestru zawartość `.cache`, bo pliki
|
||||
w środku nie zaczynają się od kropki. Efekt: cache podawany jako baza, a przy
|
||||
pierwszym uruchomieniu jeszcze przyjmowany jako aktywny."""
|
||||
def ukryta_sciezka(p: Path) -> bool:
|
||||
return any(part.startswith(".") for part in p.relative_to(root).parts[:-1])
|
||||
|
||||
return [p for p in sorted(root.glob("**/*"))
|
||||
if p.is_file() and not p.name.startswith((".", "~$"))
|
||||
and not ukryta_sciezka(p)]
|
||||
|
||||
|
||||
def _adopt_existing(root: Path) -> dict:
|
||||
"""Pierwsze uruchomienie: bazy zastane na udziale są OD RAZU w użyciu.
|
||||
|
||||
Bez tego wdrożenie DAN-27 wyłączyłoby wyszukiwanie. Dotąd bazy działały
|
||||
domyślnie (wyłączało się je jawnie przez DISABLED_BASES); po przejściu na
|
||||
rejestr plik bez wpisu dostaje `ready`, czyli NIE w użyciu — więc pusty stan
|
||||
po wdrożeniu oznaczałby, że program nagle niczego nie znajduje. Ta cicha
|
||||
zmiana zachowania byłaby gorsza od awarii, bo wygląda jak pusta baza.
|
||||
|
||||
Rozróżnienie jest celowe: `ready` dotyczy plików WGRANYCH przez ekran (te
|
||||
ktoś musi świadomie włączyć), a nie zastanych przy przejściu na rejestr.
|
||||
|
||||
Zapis stanu może się nie udać (udział read-only) — wtedy trudno, przy każdym
|
||||
uruchomieniu przyjmiemy je na nowo. Zachowanie jest to samo, koszt żaden."""
|
||||
files = {str(p.relative_to(root)): {"status": ACTIVE, "adopted_at": _now()}
|
||||
for p in _scan(root)}
|
||||
data = {"files": files, "rules": {**DEFAULT_RULES}}
|
||||
try:
|
||||
_write_state(root, data)
|
||||
except OSError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def registry(root: Path | str, *, for_admin: bool = False) -> list[dict]:
|
||||
"""Pliki na udziale wraz ze stanem. `for_admin` odsłania kwarantannę i powody.
|
||||
|
||||
Filtrowanie siedzi TUTAJ, a nie w szablonie: gdyby pliki w kwarantannie
|
||||
dochodziły do przeglądarki i były tylko ukrywane stylem, wystarczyłby podgląd
|
||||
źródła strony, żeby poznać reguły walidacji."""
|
||||
root = Path(root)
|
||||
# Brak PLIKU stanu = pierwsze uruchomienie. Pusty słownik przy istniejącym
|
||||
# pliku to co innego: ktoś świadomie wszystko odstawił, więc nie wskrzeszamy.
|
||||
data = _read_state(root) if state_path(root).exists() else _adopt_existing(root)
|
||||
out: list[dict] = []
|
||||
for p in _scan(root):
|
||||
rel = str(p.relative_to(root))
|
||||
row = data["files"].get(rel, {})
|
||||
status = row.get("status") or READY
|
||||
if status == QUARANTINE and not for_admin:
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
size_mb = round(st.st_size / (1024 * 1024), 2)
|
||||
modified = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
except OSError:
|
||||
size_mb, modified = None, None
|
||||
entry = {
|
||||
"name": p.name, "path": rel, "size_mb": size_mb, "modified": modified,
|
||||
"status": status, "in_use": status in USABLE,
|
||||
# `enabled` to TA SAMA informacja pod nazwą, której używa reszta
|
||||
# świata: endpoint /bases, warstwa logiczna i ekran „Ustawienia"
|
||||
# (DAN-15/PRE-09). Rejestr wszedł w miejsce starej listy baz, więc
|
||||
# musi mówić jej językiem — inaczej każdy odbiorca dostaje KeyError,
|
||||
# a to była właśnie awaria /bases po wdrożeniu DAN-27.
|
||||
"enabled": status in USABLE,
|
||||
"archived_at": row.get("archived_at") or "",
|
||||
"uploaded_at": row.get("uploaded_at") or "",
|
||||
"uploaded_by": row.get("uploaded_by") or "",
|
||||
"sha256": row.get("sha256") or "",
|
||||
}
|
||||
if for_admin:
|
||||
entry["rejected_for"] = list(row.get("rejected_for") or [])
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def usable_paths(root: Path | str) -> list[str]:
|
||||
"""Ścieżki baz, które FAKTYCZNIE biorą udział w wyszukiwaniu."""
|
||||
root = Path(root)
|
||||
return [str(root / e["path"]) for e in registry(root, for_admin=True) if e["in_use"]]
|
||||
|
||||
|
||||
def _touch(root: Path, rel: str, **fields) -> dict:
|
||||
with _lock:
|
||||
data = _read_state(root)
|
||||
row = {**data["files"].get(rel, {}), **fields}
|
||||
data["files"][rel] = row
|
||||
_write_state(root, data)
|
||||
return row
|
||||
|
||||
|
||||
def set_status(root: Path | str, rel: str, status: str, *, by: str = "") -> dict:
|
||||
"""Zmienia stan pliku. Włączyć do użytku można TYLKO plik, który przeszedł
|
||||
walidację — to jest właśnie ta bramka, o której mowa w wymaganiu."""
|
||||
root = Path(root)
|
||||
target = root / rel
|
||||
if not target.is_file():
|
||||
raise ValueError(f"Nie ma pliku „{rel}”.")
|
||||
if status not in {ACTIVE, READY, ARCHIVED, QUARANTINE}:
|
||||
raise ValueError(f"Nieznany stan: {status}")
|
||||
|
||||
data = _read_state(root)
|
||||
current = (data["files"].get(rel) or {}).get("status") or READY
|
||||
if status == ACTIVE:
|
||||
if current == QUARANTINE:
|
||||
raise ValueError("Plik nie może trafić do użytku.")
|
||||
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True) if e["sha256"]}
|
||||
why = validate(target, root, known_digests=known)
|
||||
if why:
|
||||
_touch(root, rel, status=QUARANTINE, rejected_for=why, checked_at=_now())
|
||||
raise ValueError("Plik nie może trafić do użytku.")
|
||||
|
||||
fields = {"status": status, "changed_at": _now(), "changed_by": by}
|
||||
if status == ARCHIVED:
|
||||
# Znacznik czasu archiwizacji to wymóg: „zamrożona forma z timestampem".
|
||||
fields["archived_at"] = _now()
|
||||
elif status == ACTIVE:
|
||||
fields["archived_at"] = ""
|
||||
fields["rejected_for"] = []
|
||||
return _touch(root, rel, **fields)
|
||||
|
||||
|
||||
def store_upload(root: Path | str, filename: str, content: bytes, *, by: str = "") -> dict:
|
||||
"""Zapisuje wgrany plik i od razu go sprawdza.
|
||||
|
||||
Plik zostaje NIEZALEŻNIE od wyniku walidacji — nie tracimy niczego, co ktoś
|
||||
wgrał. Zmienia się tylko to, czy da się go włączyć do użytku."""
|
||||
root = Path(root)
|
||||
safe = re.sub(r"[^A-Za-z0-9._ -]", "_", Path(filename or "").name).strip() or "plik"
|
||||
target = root / safe
|
||||
stem, suffix, n = Path(safe).stem, Path(safe).suffix, 1
|
||||
while target.exists(): # nie nadpisujemy cudzej bazy
|
||||
target = root / f"{stem}-{n}{suffix}"
|
||||
n += 1
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
|
||||
rel = str(target.relative_to(root))
|
||||
digest = sha256_of(target)
|
||||
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True)
|
||||
if e["sha256"] and e["path"] != rel}
|
||||
why = validate(target, root, digest=digest, known_digests=known)
|
||||
_touch(root, rel, status=QUARANTINE if why else READY, rejected_for=why,
|
||||
sha256=digest, uploaded_at=_now(), uploaded_by=by, checked_at=_now())
|
||||
return {"path": rel, "name": target.name, "accepted": not why}
|
||||
|
||||
|
||||
def delete(root: Path | str, rel: str) -> None:
|
||||
"""Nieodwracalne skasowanie pliku — wyłącznie dla administratora."""
|
||||
root = Path(root)
|
||||
target = root / rel
|
||||
if not target.is_file():
|
||||
raise ValueError(f"Nie ma pliku „{rel}”.")
|
||||
target.unlink()
|
||||
with _lock:
|
||||
data = _read_state(root)
|
||||
data["files"].pop(rel, None)
|
||||
_write_state(root, data)
|
||||
@@ -0,0 +1,525 @@
|
||||
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
||||
|
||||
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
||||
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
||||
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
||||
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
||||
zrzut z węzła), miał je w całości.
|
||||
|
||||
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
||||
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
||||
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
||||
podatne na modyfikację".
|
||||
|
||||
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
||||
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
||||
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
||||
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
||||
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
||||
|
||||
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
||||
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
||||
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
||||
|
||||
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
||||
|
||||
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
||||
|
||||
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
||||
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
||||
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
||||
|
||||
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
||||
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
||||
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
||||
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from typing import Iterable, Iterator
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
log = logging.getLogger("astrololo.link")
|
||||
|
||||
MAGIC = b"AL1"
|
||||
VERSION = "v1"
|
||||
NONCE_BYTES = 12
|
||||
KEY_BYTES = 32 # AES-256
|
||||
LENGTH_PREFIX = 4
|
||||
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
||||
MAX_SKEW_SECONDS = 300.0
|
||||
|
||||
HEADER_ENC = "X-Astrololo-Enc"
|
||||
HEADER_TS = "X-Astrololo-Enc-Ts"
|
||||
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
||||
|
||||
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
||||
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
||||
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
||||
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
||||
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||
|
||||
REQUEST, RESPONSE = b"req", b"res"
|
||||
|
||||
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
||||
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
||||
PUBLIC_PATHS = frozenset({"/health"})
|
||||
|
||||
|
||||
class LinkError(Exception):
|
||||
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- klucze
|
||||
|
||||
def parse_key(raw: str) -> bytes:
|
||||
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
raise LinkError("pusty klucz łącza")
|
||||
try:
|
||||
key = bytes.fromhex(text)
|
||||
except ValueError:
|
||||
try:
|
||||
key = base64.b64decode(text, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
||||
if len(key) != KEY_BYTES:
|
||||
raise LinkError(
|
||||
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
||||
f"`openssl rand -hex 32`"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def key_from_env(env_name: str) -> bytes | None:
|
||||
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
||||
raw = os.getenv(env_name, "")
|
||||
return parse_key(raw) if raw.strip() else None
|
||||
|
||||
|
||||
def encryption_required() -> bool:
|
||||
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
||||
|
||||
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
||||
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
||||
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
||||
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
||||
"""
|
||||
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
||||
info=b"astrololo/link/" + direction,
|
||||
).derive(link_key)
|
||||
|
||||
|
||||
class Link:
|
||||
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
||||
|
||||
def __init__(self, link_key: bytes) -> None:
|
||||
self._by_direction = {
|
||||
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
||||
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------- pojedyncza ramka
|
||||
|
||||
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
||||
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
||||
stamp.encode("ascii"), str(seq).encode("ascii")])
|
||||
|
||||
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||
plaintext: bytes) -> bytes:
|
||||
nonce = os.urandom(NONCE_BYTES)
|
||||
sealed = self._by_direction[direction].encrypt(
|
||||
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
||||
return MAGIC + nonce + sealed
|
||||
|
||||
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||
frame: bytes) -> bytes:
|
||||
if not frame.startswith(MAGIC):
|
||||
raise LinkError("ramka bez znacznika protokołu")
|
||||
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")
|
||||
+114
-3
@@ -4,14 +4,19 @@ Jedyne zadanie: przyjąć znormalizowane zapytanie z warstwy logicznej, wyszuka
|
||||
dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
|
||||
ani prezentacji.
|
||||
"""
|
||||
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
|
||||
from app import canary, files, link_crypto, security
|
||||
from app.config import settings
|
||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||
from pydantic import BaseModel
|
||||
from app.providers.factory import build_provider
|
||||
|
||||
provider = build_provider(settings)
|
||||
@@ -23,12 +28,118 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
||||
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan,
|
||||
docs_url=None, redoc_url=None, openapi_url=None)
|
||||
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
||||
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
|
||||
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||
link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych")
|
||||
|
||||
|
||||
@app.post("/search", response_model=SearchResult)
|
||||
def search(query: SearchQuery) -> SearchResult:
|
||||
return provider.search(query)
|
||||
result = provider.search(query)
|
||||
# Rekordy-pułapki (DAN-26) odsiewamy TU, na wyjściu z warstwy danych — dzięki
|
||||
# temu nie dotrą ani wyżej, ani do promptu LLM (LOG-30), niezależnie od dostawcy.
|
||||
visible, report = canary.screen(result.rows, query.value)
|
||||
if report["active"]:
|
||||
result.rows = visible
|
||||
result.total = len(visible)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/bases")
|
||||
def bases() -> dict:
|
||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||
|
||||
Same METADANE — nazwy plików, rozmiar, data. Żadnej treści baz, więc podgląd
|
||||
listy nie jest kolejną drogą do ich wyniesienia."""
|
||||
items = provider.list_bases()
|
||||
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
|
||||
|
||||
|
||||
# ── zarządzanie plikami baz (DAN-27) ─────────────────────────────────────
|
||||
# Warstwa danych jest właścicielem plików, więc to ona nimi zarządza. Uprawnienia
|
||||
# rozstrzyga PREZENTACJA (PRE-27) i przekazuje tu wynik jako `for_admin` / `by` —
|
||||
# ta warstwa nie zna kont i nie ma jak ich znać. Nie jest to dziura: warstwa
|
||||
# danych stoi za tokenem międzywarstwowym i szyfrowanym łączem, więc rozmawia
|
||||
# z nią wyłącznie warstwa logiczna.
|
||||
|
||||
class FilesQuery(BaseModel):
|
||||
for_admin: bool = False
|
||||
# Pula konta (PRE-29). Puste = cały udział — tak woła produkcja, która o pulach
|
||||
# nic nie wie. Wersja demo podaje login, przez co widzi wyłącznie swój katalog.
|
||||
tenant: str = ""
|
||||
|
||||
|
||||
class FileAction(BaseModel):
|
||||
path: str
|
||||
status: str = ""
|
||||
by: str = ""
|
||||
tenant: str = ""
|
||||
|
||||
|
||||
class FileUpload(BaseModel):
|
||||
filename: str
|
||||
content_b64: str
|
||||
by: str = ""
|
||||
tenant: str = ""
|
||||
|
||||
|
||||
class RulesUpdate(BaseModel):
|
||||
rules: dict
|
||||
tenant: str = ""
|
||||
|
||||
|
||||
@app.post("/files")
|
||||
def files_list(q: FilesQuery) -> dict:
|
||||
"""Rejestr plików. Kwarantanna WYCHODZI stąd tylko przy for_admin — filtrujemy
|
||||
u źródła, żeby nie dało się jej odczytać z podglądu źródła strony."""
|
||||
root = files.tenant_root(settings.excel_dir, q.tenant)
|
||||
return {"files": files.registry(root, for_admin=q.for_admin),
|
||||
"rules": files.rules(root) if q.for_admin else {},
|
||||
"root": str(root)}
|
||||
|
||||
|
||||
@app.post("/files/status")
|
||||
def files_status(a: FileAction) -> dict:
|
||||
try:
|
||||
row = files.set_status(files.tenant_root(settings.excel_dir, a.tenant),
|
||||
a.path, a.status, by=a.by)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
return {"path": a.path, "status": row.get("status")}
|
||||
|
||||
|
||||
@app.post("/files/upload")
|
||||
def files_upload(u: FileUpload) -> dict:
|
||||
"""Plik wędruje w base64 wewnątrz zaszyfrowanego łącza — tym samym kanałem,
|
||||
co reszta ruchu międzywarstwowego. Osobny, nieszyfrowany kanał na pliki
|
||||
byłby obejściem PRE-16."""
|
||||
try:
|
||||
raw = base64.b64decode(u.content_b64, validate=True)
|
||||
except (binascii.Error, ValueError) as e:
|
||||
raise HTTPException(422, "Nieczytelna zawartość pliku.") from e
|
||||
return files.store_upload(files.tenant_root(settings.excel_dir, u.tenant),
|
||||
u.filename, raw, by=u.by)
|
||||
|
||||
|
||||
@app.post("/files/delete")
|
||||
def files_delete(a: FileAction) -> dict:
|
||||
try:
|
||||
files.delete(files.tenant_root(settings.excel_dir, a.tenant), a.path)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
return {"deleted": a.path}
|
||||
|
||||
|
||||
@app.post("/files/rules")
|
||||
def files_rules(u: RulesUpdate) -> dict:
|
||||
return {"rules": files.set_rules(files.tenant_root(settings.excel_dir, u.tenant), u.rules)}
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthInfo)
|
||||
|
||||
@@ -18,10 +18,18 @@ class SearchQuery(BaseModel):
|
||||
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
||||
value: str = Field(..., description="Szukana wartość.")
|
||||
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
||||
limit: int = Field(50, ge=1, le=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(
|
||||
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
||||
)
|
||||
# PULA KONTA (PRE-29). Puste = cały udział, czyli zachowanie produkcji, która
|
||||
# o pulach nic nie wie. Ustawione = wyszukiwanie widzi WYŁĄCZNIE podkatalog
|
||||
# tego konta. Używa tego wersja demo, gdzie każde konto ma własny, niewidoczny
|
||||
# dla pozostałych zbiór baz.
|
||||
tenant: str = Field("", description="Pula plików konta; puste = cały udział.")
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
|
||||
@@ -25,3 +25,10 @@ class DataProvider(ABC):
|
||||
def warmup(self) -> None:
|
||||
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
|
||||
return None
|
||||
|
||||
def list_bases(self) -> list[dict]:
|
||||
"""Bazy widoczne dla dostawcy + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||
|
||||
Opcjonalne: dostawca SQL nie operuje na plikach, więc domyślnie pusto —
|
||||
UI pokaże wtedy, że nie ma czego przełączać, zamiast się wywrócić."""
|
||||
return []
|
||||
|
||||
@@ -82,16 +82,57 @@ class ExcelDataProvider(DataProvider):
|
||||
|
||||
def warmup(self) -> None:
|
||||
for path in self._excel_files():
|
||||
self._ensure_indexed(path)
|
||||
try:
|
||||
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]:
|
||||
base = Path(self.s.excel_dir)
|
||||
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
|
||||
|
||||
def _enabled_files(self, paths: list[str], tenant: str = "") -> list[str]:
|
||||
"""Bazy biorące udział w wyszukiwaniu.
|
||||
|
||||
Źródłem prawdy jest REJESTR PLIKÓW (DAN-27) — stan klikany z ekranu,
|
||||
trwały na udziale. Zmienna DISABLED_BASES z DAN-15 zostaje jako awaryjne
|
||||
wyłączenie z konfiguracji: gdy jest ustawiona, odsiewa DODATKOWO. Nie
|
||||
odwrotnie — inaczej ktoś z dostępem do ekranu mógłby włączyć bazę
|
||||
wyłączoną świadomie na poziomie wdrożenia.
|
||||
"""
|
||||
# `bases` MUSI być zaimportowane tutaj — modułowego importu nie ma,
|
||||
# a przepisując tę funkcję pod rejestr usunąłem lokalny. Efekt: NameError
|
||||
# przy KAŻDYM wyszukiwaniu, czyli 500 z warstwy danych.
|
||||
from app import bases, files
|
||||
|
||||
usable = set(files.usable_paths(files.tenant_root(self.s.excel_dir, tenant)))
|
||||
out = [p for p in paths if p in usable]
|
||||
entries = bases.disabled_entries()
|
||||
if entries:
|
||||
out = [p for p in out if bases.is_enabled(p, self.s.excel_dir, entries)]
|
||||
return out
|
||||
|
||||
def list_bases(self) -> list[dict]:
|
||||
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
|
||||
from app import bases
|
||||
|
||||
from app import files
|
||||
|
||||
return files.registry(self.s.excel_dir, for_admin=True)
|
||||
|
||||
# ---- publiczne API ----
|
||||
def search(self, query: SearchQuery) -> SearchResult:
|
||||
t0 = time.perf_counter()
|
||||
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
|
||||
# Lista wyłączonych baz wchodzi do klucza cache: bez tego zmiana ustawień
|
||||
# oddawałaby wynik sprzed zmiany, czyli treść bazy uznanej za wyłączoną.
|
||||
from app import bases
|
||||
|
||||
disabled = ",".join(bases.disabled_entries())
|
||||
# PULA MUSI BYĆ W KLUCZU. Bez niej wynik policzony dla jednego konta
|
||||
# trafiłby z cache do drugiego — czyli cicha wymiana treści baz między
|
||||
# kontami, niewidoczna w logach i nie do wykrycia z zewnątrz.
|
||||
cache_key = (f"{query.key}|{query.value}|{query.exact}|{query.limit}"
|
||||
f"|{query.fields}|{disabled}|{query.tenant}")
|
||||
|
||||
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
|
||||
if hit is not None:
|
||||
@@ -102,6 +143,9 @@ class ExcelDataProvider(DataProvider):
|
||||
if not candidates:
|
||||
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
|
||||
candidates = [(p, "0") for p in self._excel_files()]
|
||||
# bazy wyłączone globalnie (DAN-15) pomijamy niezależnie od źródła kandydatów
|
||||
allowed = set(self._enabled_files([p for p, _ in candidates], query.tenant))
|
||||
candidates = [(p, s) for p, s in candidates if p in allowed]
|
||||
|
||||
rows: list[dict] = []
|
||||
for path, _sheet in candidates:
|
||||
@@ -112,7 +156,9 @@ class ExcelDataProvider(DataProvider):
|
||||
if query.exact:
|
||||
mask = col.str.lower() == query.value.lower()
|
||||
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]
|
||||
if query.fields:
|
||||
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)
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
@@ -6,4 +6,11 @@ pandas>=2.2
|
||||
openpyxl>=3.1
|
||||
pyarrow>=18.0
|
||||
SQLAlchemy>=2.0
|
||||
# Sterownik Postgresa (DAN-28). Sam SQLAlchemy nie rozmawia z bazą — bez tego
|
||||
# `postgresql+psycopg://…` wywala się dopiero przy PIERWSZYM połączeniu, już na
|
||||
# klastrze, komunikatem o braku modułu. [binary] = gotowe koło, bez kompilacji
|
||||
# libpq w obrazie.
|
||||
psycopg[binary]>=3.2
|
||||
pydantic>=2.10
|
||||
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||
cryptography>=44.0
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Przegląd baz na udziale i ich globalne wyłączanie (DAN-15 / PRE-09)."""
|
||||
import pytest
|
||||
|
||||
from app import bases
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
monkeypatch.delenv("DISABLED_BASES", raising=False)
|
||||
|
||||
|
||||
ROOT = "/dane"
|
||||
PATHS = [f"{ROOT}/main_base.xlsx", f"{ROOT}/archiwum/stara.xlsx"]
|
||||
|
||||
|
||||
def test_everything_enabled_by_default():
|
||||
for b in bases.list_bases(ROOT, PATHS):
|
||||
assert b["enabled"] is True
|
||||
|
||||
|
||||
def test_disable_by_file_name(monkeypatch):
|
||||
monkeypatch.setenv("DISABLED_BASES", "stara.xlsx")
|
||||
state = {b["name"]: b["enabled"] for b in bases.list_bases(ROOT, PATHS)}
|
||||
assert state == {"main_base.xlsx": True, "stara.xlsx": False}
|
||||
|
||||
|
||||
def test_disable_by_relative_path(monkeypatch):
|
||||
"""Wpis może wskazywać ścieżkę względną, nie tylko samą nazwę."""
|
||||
monkeypatch.setenv("DISABLED_BASES", "archiwum/stara.xlsx")
|
||||
assert bases.is_enabled(f"{ROOT}/archiwum/stara.xlsx", ROOT) is False
|
||||
assert bases.is_enabled(f"{ROOT}/main_base.xlsx", ROOT) is True
|
||||
|
||||
|
||||
def test_entries_are_trimmed_and_multiple(monkeypatch):
|
||||
monkeypatch.setenv("DISABLED_BASES", " stara.xlsx , main_base.xlsx ")
|
||||
assert [b["enabled"] for b in bases.list_bases(ROOT, PATHS)] == [False, False]
|
||||
|
||||
|
||||
def test_listing_carries_metadata_not_content(tmp_path):
|
||||
"""Metaopis: nazwa, ścieżka, rozmiar, data — ŻADNEJ treści bazy."""
|
||||
f = tmp_path / "baza.xlsx"
|
||||
f.write_bytes(b"x" * 2048)
|
||||
item = bases.list_bases(tmp_path, [str(f)])[0]
|
||||
assert set(item) == {"name", "path", "size_mb", "modified", "enabled"}
|
||||
assert item["name"] == "baza.xlsx" and item["size_mb"] is not None and item["modified"]
|
||||
|
||||
|
||||
def test_missing_file_does_not_crash_the_listing():
|
||||
item = bases.list_bases(ROOT, [f"{ROOT}/nie-ma.xlsx"])[0]
|
||||
assert item["size_mb"] is None and item["modified"] is None
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Rekordy-pułapki (canary) — DAN-26.
|
||||
|
||||
Testujemy sam MECHANIZM na syntetycznych pułapkach: prawdziwe markery i injekcja
|
||||
do baz przychodzą od właściciela produktu. Regresja byłaby CICHA i podwójnie zła:
|
||||
albo pułapka wycieka do interpretacji/LLM (zdradza się i psuje wynik), albo
|
||||
przestaje odsiewać i nie wiadomo o tym.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
from app import canary
|
||||
|
||||
MAIN = (pathlib.Path(__file__).resolve().parents[1] / "app" / "main.py").read_text(encoding="utf-8")
|
||||
|
||||
MARK = "ASTROLOLO-CANARY-7f3a9" # unikalny — nie wystąpi w realnych danych
|
||||
ROWS = [
|
||||
{"significator": "Ma Ari", "effect": "odważny, impulsywny"},
|
||||
{"significator": "Ve Tau " + MARK, "effect": "pułapka — nie dotknie interpretacji"},
|
||||
{"significator": "Su Leo", "effect": "dumny, twórczy"},
|
||||
]
|
||||
|
||||
|
||||
def test_no_markers_is_transparent():
|
||||
"""Bez skonfigurowanych markerów — zero ingerencji, zero kosztu."""
|
||||
out, rep = canary.screen(ROWS, "cokolwiek", markers=[])
|
||||
assert out == ROWS and rep["active"] is False
|
||||
|
||||
|
||||
def test_canary_row_is_fenced_from_results():
|
||||
"""Pułapka znika z wyników — nie opuści warstwy danych (a więc i promptu LLM)."""
|
||||
out, rep = canary.screen(ROWS, "Ve Tau", markers=[MARK])
|
||||
assert rep["active"] and rep["removed"] == 1
|
||||
assert all(MARK not in str(r) for r in out) # nigdzie nie ma markera
|
||||
assert len(out) == 2 and {"significator": "Su Leo", "effect": "dumny, twórczy"} in out
|
||||
|
||||
|
||||
def test_real_results_pass_through_untouched():
|
||||
out, _ = canary.screen(ROWS, "Ari", markers=[MARK])
|
||||
assert {"significator": "Ma Ari", "effect": "odważny, impulsywny"} in out
|
||||
|
||||
|
||||
def test_tripwire_when_query_targets_a_marker():
|
||||
"""Zapytanie CELUJĄCE w marker = ktoś enumeruje bazę, nie liczy horoskopu."""
|
||||
_, rep = canary.screen(ROWS, "Ve Tau " + MARK, markers=[MARK])
|
||||
assert rep["tripwire"] is True
|
||||
|
||||
|
||||
def test_normal_query_does_not_trip():
|
||||
_, rep = canary.screen(ROWS, "Ma Ari", markers=[MARK])
|
||||
assert rep["tripwire"] is False
|
||||
|
||||
|
||||
def test_marker_matched_in_any_string_field():
|
||||
rows = [{"significator": "X", "effect": "opis " + MARK, "extra": 5}]
|
||||
out, rep = canary.screen(rows, "X", markers=[MARK])
|
||||
assert out == [] and rep["removed"] == 1 # marker w polu 'effect' też łapiemy
|
||||
|
||||
|
||||
def test_endpoint_fences_before_returning():
|
||||
"""/search odsiewa pułapki na wyjściu z warstwy danych (niezależnie od dostawcy)."""
|
||||
assert "canary.screen(result.rows, query.value)" in MAIN
|
||||
assert "result.rows = visible" in MAIN
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Rejestr plików baz: stany, walidacja, archiwizacja (DAN-27).
|
||||
|
||||
Testujemy tu RDZEŃ — bez HTTP i bez uprawnień, bo uprawnienia rozstrzyga
|
||||
prezentacja (patrz services/presentation/tests/test_pliki.py). Tutaj chodzi
|
||||
o to, żeby żadna operacja nie gubiła pliku i żeby bramka „do użytku tylko po
|
||||
walidacji" faktycznie trzymała.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from app import files
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def root(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("FILES_STATE", raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _xlsx(path, rows=3, header=("id", "opis")):
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(list(header))
|
||||
for i in range(rows):
|
||||
ws.append([i, f"treść {i}"])
|
||||
wb.save(path)
|
||||
return path
|
||||
|
||||
|
||||
# ── rejestr i stany ──────────────────────────────────────────────────────
|
||||
|
||||
def test_a_file_dropped_on_the_share_is_adopted_as_active(root):
|
||||
"""Baza położona na udziale poza aplikacją (np. przez NFS) ma działać —
|
||||
tak było przed DAN-27 i tak ma zostać. Plik WGRANY EKRANEM to inna sprawa:
|
||||
ten wymaga świadomego włączenia (patrz test niżej)."""
|
||||
_xlsx(root / "baza.xlsx")
|
||||
entry = files.registry(root)[0]
|
||||
assert entry["status"] == files.ACTIVE
|
||||
assert entry["in_use"] is True
|
||||
|
||||
|
||||
def test_only_active_files_reach_the_search(root):
|
||||
_xlsx(root / "a.xlsx")
|
||||
_xlsx(root / "b.xlsx")
|
||||
files.registry(root) # przyjęcie zastanych
|
||||
files.set_status(root, "b.xlsx", files.READY) # świadome odstawienie
|
||||
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["a.xlsx"]
|
||||
files.set_status(root, "b.xlsx", files.ACTIVE)
|
||||
assert len(files.usable_paths(root)) == 2
|
||||
|
||||
|
||||
def test_state_survives_a_restart(root):
|
||||
"""Stan jest KLIKANY, więc musi być trwały — inaczej restart poda po cichu
|
||||
przywracałby bazy wyłączone świadomie."""
|
||||
_xlsx(root / "a.xlsx")
|
||||
files.set_status(root, "a.xlsx", files.ACTIVE)
|
||||
assert files.state_path(root).exists()
|
||||
assert files.registry(root)[0]["in_use"] is True
|
||||
|
||||
|
||||
# ── archiwizacja ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_archiving_freezes_the_file_but_never_removes_it(root):
|
||||
"""To jest najdalej idąca operacja osoby wgrywającej dane: plik ZOSTAJE."""
|
||||
p = _xlsx(root / "stara.xlsx")
|
||||
files.set_status(root, "stara.xlsx", files.ACTIVE)
|
||||
files.set_status(root, "stara.xlsx", files.ARCHIVED, by="dane")
|
||||
entry = files.registry(root)[0]
|
||||
assert p.exists(), "plik zniknął z dysku — archiwizacja ma go zachować"
|
||||
assert entry["status"] == files.ARCHIVED
|
||||
assert entry["in_use"] is False
|
||||
assert entry["archived_at"], "brak znacznika czasu archiwizacji"
|
||||
|
||||
|
||||
def test_archived_file_cannot_slip_back_into_use_by_itself(root):
|
||||
_xlsx(root / "stara.xlsx")
|
||||
files.set_status(root, "stara.xlsx", files.ARCHIVED)
|
||||
assert files.usable_paths(root) == []
|
||||
|
||||
|
||||
# ── walidacja: bramka do użytku ──────────────────────────────────────────
|
||||
|
||||
def test_upload_keeps_a_file_that_fails_validation(root):
|
||||
"""Rzecz najważniejsza: wgranego pliku NIE TRACIMY, choćby nie przeszedł."""
|
||||
files.set_rules(root, {"extensions": [".xlsx"]})
|
||||
out = files.store_upload(root, "notatka.txt", "to nie jest baza".encode("utf-8"), by="dane")
|
||||
assert out["accepted"] is False
|
||||
assert (root / out["path"]).exists(), "plik odrzucony zniknął z dysku"
|
||||
admin_view = files.registry(root, for_admin=True)[0]
|
||||
assert admin_view["status"] == files.QUARANTINE
|
||||
assert admin_view["rejected_for"], "administrator ma widzieć powód"
|
||||
|
||||
|
||||
def test_a_held_file_is_invisible_without_admin_rights(root):
|
||||
"""Gdyby plik wstrzymany był widoczny z powodem odrzucenia, każdy wgrywający
|
||||
poznałby reguły walidacji — a te są narzędziem administratora."""
|
||||
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
|
||||
assert files.registry(root, for_admin=False) == []
|
||||
assert len(files.registry(root, for_admin=True)) == 1
|
||||
|
||||
|
||||
def test_a_held_file_cannot_be_switched_into_use(root):
|
||||
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
|
||||
rel = files.registry(root, for_admin=True)[0]["path"]
|
||||
with pytest.raises(ValueError):
|
||||
files.set_status(root, rel, files.ACTIVE)
|
||||
|
||||
|
||||
def test_activation_revalidates_and_holds_a_file_that_stopped_qualifying(root):
|
||||
"""Reguły mogą się zmienić PO wgraniu — bramka sprawdza w chwili włączania,
|
||||
a nie tylko przy wgrywaniu."""
|
||||
_xlsx(root / "mala.xlsx", rows=2)
|
||||
files.set_status(root, "mala.xlsx", files.ACTIVE)
|
||||
files.set_rules(root, {"min_rows": 500})
|
||||
files.set_status(root, "mala.xlsx", files.READY)
|
||||
with pytest.raises(ValueError):
|
||||
files.set_status(root, "mala.xlsx", files.ACTIVE)
|
||||
assert (root / "mala.xlsx").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rule,value,bad", [
|
||||
("extensions", [".xlsx"], "plik.csv"),
|
||||
("max_size_mb", 0.000001, "plik.xlsx"),
|
||||
])
|
||||
def test_rules_reject_what_they_are_meant_to(root, rule, value, bad):
|
||||
files.set_rules(root, {rule: value})
|
||||
out = files.store_upload(root, bad, b"x" * 2048)
|
||||
assert out["accepted"] is False
|
||||
|
||||
|
||||
def test_required_columns_are_checked_inside_the_workbook(root):
|
||||
files.set_rules(root, {"required_columns": ["id", "znaczenie"]})
|
||||
_xlsx(root / "tmp.xlsx", header=("id", "opis"))
|
||||
why = files.validate(root / "tmp.xlsx", root)
|
||||
assert why and "znaczenie" in why[0]
|
||||
|
||||
|
||||
def test_duplicate_content_is_rejected_by_hash_not_by_name(root):
|
||||
files.set_rules(root, {"reject_duplicate_content": True})
|
||||
data = _xlsx(root / "wzor.xlsx").read_bytes()
|
||||
first = files.store_upload(root, "pierwsza.xlsx", data)
|
||||
assert first["accepted"] is True
|
||||
second = files.store_upload(root, "inna-nazwa.xlsx", data)
|
||||
assert second["accepted"] is False
|
||||
|
||||
|
||||
def test_upload_never_overwrites_someone_elses_base(root):
|
||||
files.store_upload(root, "baza.xlsx", _xlsx(root / "w.xlsx").read_bytes())
|
||||
(root / "w.xlsx").unlink()
|
||||
files.set_rules(root, {"reject_duplicate_content": False})
|
||||
out = files.store_upload(root, "baza.xlsx", "inna treść".encode("utf-8"))
|
||||
assert out["name"] != "baza.xlsx"
|
||||
assert (root / "baza.xlsx").exists() and (root / out["path"]).exists()
|
||||
|
||||
|
||||
# ── kasowanie ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_delete_removes_the_file_and_its_entry(root):
|
||||
_xlsx(root / "a.xlsx")
|
||||
files.set_status(root, "a.xlsx", files.ACTIVE)
|
||||
files.delete(root, "a.xlsx")
|
||||
assert not (root / "a.xlsx").exists()
|
||||
assert files.registry(root, for_admin=True) == []
|
||||
assert files.usable_paths(root) == []
|
||||
|
||||
|
||||
# ── przejście na rejestr nie może wyłączyć wyszukiwania ─────────────────
|
||||
|
||||
def test_bases_already_on_the_share_stay_in_use_after_the_switch(root):
|
||||
"""Dotąd bazy działały domyślnie (wyłączało się je przez DISABLED_BASES).
|
||||
Po przejściu na rejestr pusty stan oznaczałby, że program nagle niczego nie
|
||||
znajduje — cicha zmiana zachowania gorsza od awarii, bo wygląda jak pusta baza."""
|
||||
_xlsx(root / "main_base.xlsx")
|
||||
_xlsx(root / "zodiac_pl.xlsx")
|
||||
assert len(files.usable_paths(root)) == 2, "zastane bazy wypadły z wyszukiwania"
|
||||
assert all(e["in_use"] for e in files.registry(root))
|
||||
|
||||
|
||||
def test_adoption_happens_once_and_respects_later_decisions(root):
|
||||
"""Po przyjęciu stan jest zapisany, więc świadome odstawienie bazy ZOSTAJE —
|
||||
kolejny odczyt nie może jej wskrzesić."""
|
||||
_xlsx(root / "a.xlsx")
|
||||
_xlsx(root / "b.xlsx")
|
||||
files.registry(root) # przyjęcie
|
||||
files.set_status(root, "a.xlsx", files.READY) # świadome odstawienie
|
||||
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["b.xlsx"]
|
||||
files.set_status(root, "b.xlsx", files.READY) # odstawiamy wszystko
|
||||
assert files.usable_paths(root) == [], "pusty wybór został wskrzeszony"
|
||||
|
||||
|
||||
def test_uploaded_files_still_need_an_explicit_switch_on(root):
|
||||
"""Przyjęcie dotyczy TYLKO baz zastanych. Plik wgrany ekranem ktoś musi
|
||||
świadomie włączyć — inaczej nowa baza wchodziłaby do wyników sama."""
|
||||
_xlsx(root / "zastana.xlsx")
|
||||
files.registry(root)
|
||||
out = files.store_upload(root, "nowa.xlsx", _xlsx(root / "tmp.xlsx").read_bytes())
|
||||
assert out["accepted"] is True
|
||||
names = [pathlib.Path(p).name for p in files.usable_paths(root)]
|
||||
assert "nowa.xlsx" not in names, "wgrana baza weszła do wyników bez decyzji"
|
||||
|
||||
|
||||
def test_adoption_survives_a_read_only_share(root, monkeypatch):
|
||||
"""Na udziale tylko do odczytu stanu nie da się zapisać — zachowanie ma
|
||||
zostać to samo, tylko przyjęcie powtórzy się przy każdym uruchomieniu."""
|
||||
_xlsx(root / "a.xlsx")
|
||||
|
||||
def boom(*a, **kw):
|
||||
raise OSError("read-only file system")
|
||||
|
||||
monkeypatch.setattr(files, "_write_state", boom)
|
||||
assert len(files.usable_paths(root)) == 1
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Rejestr plików a RESZTA warstwy danych — punkty styku (DAN-27).
|
||||
|
||||
DLACZEGO OSOBNY PLIK. test_files.py sprawdza sam rejestr w izolacji i przechodził
|
||||
na zielono, podczas gdy na produkcji leżało wyszukiwanie (500) i lista baz (502).
|
||||
Rejestr wszedł w miejsce starego mechanizmu włączania baz, więc groźne jest nie
|
||||
to, co robi w środku, tylko czy MÓWI TYM SAMYM JĘZYKIEM, co jego odbiorcy.
|
||||
|
||||
Oba tamte błędy były jednolinijkowe i oba niewidoczne dla testów jednostkowych:
|
||||
* NameError, bo przepisując `_enabled_files` usunąłem lokalny import `bases`,
|
||||
* KeyError, bo rejestr oddawał `in_use`, a endpoint /bases czytał `enabled`.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def excel_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("EXCEL_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("CACHE_DIR", str(tmp_path / ".cache"))
|
||||
monkeypatch.delenv("DISABLED_BASES", raising=False)
|
||||
monkeypatch.delenv("FILES_STATE", raising=False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _xlsx(path, rows=(("Ma Ari", "odważny"), ("Ve Tau", "zgodny"))):
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["significator", "effect"])
|
||||
for r in rows:
|
||||
ws.append(list(r))
|
||||
wb.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def _provider(excel_dir):
|
||||
from app.config import Settings
|
||||
from app.providers.excel_provider import ExcelDataProvider
|
||||
|
||||
return ExcelDataProvider(Settings())
|
||||
|
||||
|
||||
def test_search_does_not_explode_on_the_registry(excel_dir):
|
||||
"""Regresja: `_enabled_files` wołało bases.disabled_entries() bez importu,
|
||||
więc KAŻDE wyszukiwanie kończyło się NameError → 500 z warstwy danych."""
|
||||
from app.models import SearchQuery
|
||||
|
||||
_xlsx(excel_dir / "baza.xlsx")
|
||||
p = _provider(excel_dir)
|
||||
p.warmup()
|
||||
out = p.search(SearchQuery(key="significator", value="Ma Ari", exact=False, limit=10))
|
||||
assert out.total >= 1, "zastana baza nie weszła do wyszukiwania"
|
||||
|
||||
|
||||
def test_search_still_works_with_disabled_bases_set(excel_dir, monkeypatch):
|
||||
"""DISABLED_BASES zostaje jako awaryjne wyłączenie i ma odsiewać DODATKOWO —
|
||||
to właśnie ta gałąź kodu wywalała się na braku importu."""
|
||||
from app.models import SearchQuery
|
||||
|
||||
_xlsx(excel_dir / "a.xlsx")
|
||||
_xlsx(excel_dir / "b.xlsx")
|
||||
monkeypatch.setenv("DISABLED_BASES", "b.xlsx")
|
||||
p = _provider(excel_dir)
|
||||
p.warmup()
|
||||
out = p.search(SearchQuery(key="significator", value="Ma Ari", exact=False, limit=10))
|
||||
assert out.total >= 1
|
||||
|
||||
|
||||
def test_registry_speaks_the_language_the_bases_endpoint_reads(excel_dir):
|
||||
"""Regresja: endpoint /bases liczy `b["enabled"]`, rejestr oddawał `in_use`.
|
||||
KeyError → 500 z danych → 502 z logiki → „Warstwa logiczna niedostępna"
|
||||
na ekranie Ustawienia."""
|
||||
_xlsx(excel_dir / "baza.xlsx")
|
||||
items = _provider(excel_dir).list_bases()
|
||||
assert items, "lista baz jest pusta"
|
||||
for row in items:
|
||||
for key in ("name", "path", "enabled", "in_use", "size_mb", "modified"):
|
||||
assert key in row, f"brak pola `{key}` — odbiorca dostanie KeyError"
|
||||
assert row["enabled"] == row["in_use"], "dwa pola, jedna prawda"
|
||||
|
||||
|
||||
def test_bases_endpoint_answers_end_to_end(excel_dir):
|
||||
"""Przez TRASĘ, nie przez dostawcę: to ona wywalała się na produkcji."""
|
||||
_xlsx(excel_dir / "baza.xlsx")
|
||||
import importlib
|
||||
|
||||
from app import main as data_main
|
||||
|
||||
importlib.reload(data_main)
|
||||
body = data_main.bases()
|
||||
assert body["total"] == 1
|
||||
assert body["enabled"] == 1, "zastana baza powinna być włączona po adopcji"
|
||||
assert body["bases"][0]["name"] == "baza.xlsx"
|
||||
|
||||
|
||||
def test_switching_a_base_off_is_visible_in_both_places(excel_dir):
|
||||
"""Odstawienie bazy ma zniknąć i z wyszukiwania, i z licznika na Ustawieniach."""
|
||||
from app import files
|
||||
from app.models import SearchQuery
|
||||
|
||||
_xlsx(excel_dir / "baza.xlsx")
|
||||
p = _provider(excel_dir)
|
||||
p.warmup()
|
||||
assert p.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total >= 1
|
||||
|
||||
files.set_status(excel_dir, "baza.xlsx", files.READY)
|
||||
p2 = _provider(excel_dir)
|
||||
p2.warmup()
|
||||
assert p2.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total == 0
|
||||
assert [b["enabled"] for b in p2.list_bases()] == [False]
|
||||
|
||||
|
||||
# ── pule kont: izolacja (PRE-29) ─────────────────────────────────────────
|
||||
# Najgroźniejsza rzecz w tej zmianie. Wyciek między pulami jest CICHY: klient
|
||||
# zobaczyłby cudze opisy jako własny wynik i nikt by się nie zorientował.
|
||||
|
||||
def _pool(root, name, plik, tresc):
|
||||
from app import files
|
||||
|
||||
pula = files.tenant_root(root, name)
|
||||
pula.mkdir(parents=True, exist_ok=True)
|
||||
_xlsx(pula / plik, rows=[(tresc, f"opis {tresc}")])
|
||||
files.registry(pula) # przyjęcie zastanych
|
||||
return pula
|
||||
|
||||
|
||||
def test_each_account_sees_only_its_own_files(excel_dir):
|
||||
from app import files
|
||||
|
||||
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
|
||||
_pool(excel_dir, "klientB", "b.xlsx", "Ve Tau")
|
||||
|
||||
a = [e["name"] for e in files.registry(files.tenant_root(excel_dir, "klientA"))]
|
||||
b = [e["name"] for e in files.registry(files.tenant_root(excel_dir, "klientB"))]
|
||||
assert a == ["a.xlsx"] and b == ["b.xlsx"]
|
||||
|
||||
|
||||
def test_search_never_reaches_another_account_pool(excel_dir):
|
||||
from app.models import SearchQuery
|
||||
|
||||
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
|
||||
_pool(excel_dir, "klientB", "b.xlsx", "Ve Tau")
|
||||
p = _provider(excel_dir)
|
||||
p.warmup()
|
||||
|
||||
swoje = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientA"))
|
||||
cudze = p.search(SearchQuery(key="significator", value="Ve Tau", limit=10, tenant="klientA"))
|
||||
assert swoje.total >= 1, "konto nie widzi własnej bazy"
|
||||
assert cudze.total == 0, "konto sięgnęło do puli innego konta"
|
||||
|
||||
|
||||
def test_the_query_cache_cannot_serve_one_account_the_answer_of_another(excel_dir):
|
||||
"""Bez puli w kluczu cache wynik policzony dla jednego konta trafiłby do
|
||||
drugiego — cicha wymiana treści baz, niewidoczna w logach."""
|
||||
from app.models import SearchQuery
|
||||
|
||||
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
|
||||
_pool(excel_dir, "klientB", "b.xlsx", "Ma Ari") # TA SAMA szukana wartość
|
||||
p = _provider(excel_dir)
|
||||
p.warmup()
|
||||
|
||||
a = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientA"))
|
||||
b = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientB"))
|
||||
assert a.total >= 1 and b.total >= 1
|
||||
assert b.cache != "hit", "odpowiedź dla jednego konta wyszła z cache drugiego"
|
||||
|
||||
|
||||
def test_a_crafted_pool_name_cannot_escape_the_share(excel_dir):
|
||||
"""Nazwa puli przychodzi z zewnątrz (login), więc „../..” albo ukośnik
|
||||
wyprowadziłyby zapytanie wprost do cudzych baz."""
|
||||
from app import files
|
||||
|
||||
for zlosliwa in ("../..", "../klientB", "/etc", "a/../../b", "..", "."):
|
||||
wynik = files.tenant_root(excel_dir, zlosliwa)
|
||||
assert excel_dir in wynik.parents or wynik == excel_dir, f"ucieczka: {zlosliwa} → {wynik}"
|
||||
|
||||
|
||||
def test_no_pool_means_the_whole_share_so_production_is_untouched(excel_dir):
|
||||
"""Produkcja nie wie o pulach i ma działać dokładnie jak dotąd."""
|
||||
from app import files
|
||||
from app.models import SearchQuery
|
||||
|
||||
_xlsx(excel_dir / "produkcyjna.xlsx")
|
||||
p = _provider(excel_dir)
|
||||
p.warmup()
|
||||
assert files.tenant_root(excel_dir, "") == excel_dir
|
||||
assert p.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total >= 1
|
||||
@@ -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,140 @@
|
||||
"""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, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
import swisseph as swe
|
||||
|
||||
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)",
|
||||
docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
# 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}
|
||||
|
||||
|
||||
# Kody systemów domów w Swiss Ephemeris. Nazwy po LEWEJ są nasze — te same,
|
||||
# których używa houses.SYSTEMS w warstwie logicznej — żeby wołający nie musiał
|
||||
# znać liter swissepha. Lista celowo pokrywa się 1:1 z naszą: rozjazd oznaczałby,
|
||||
# że kontrakt parzystości (LOG-28) przestał obejmować część systemów.
|
||||
_HOUSE_CODES = {
|
||||
"whole_sign": b"W", "whole_sign_aries": b"N",
|
||||
"equal": b"E", "equal_mc": b"D",
|
||||
"porphyry": b"O", "vehlow": b"V", "morinus": b"M",
|
||||
"regiomontanus": b"R", "campanus": b"C", "alcabitus": b"B",
|
||||
"topocentric": b"T", "placidus": b"P", "koch": b"K",
|
||||
}
|
||||
|
||||
# Kolejność, w jakiej swe_houses zwraca punkty w tablicy ascmc.
|
||||
_ASCMC = ("Asc", "MC", "ARMC", "Vertex", "equatorial_asc",
|
||||
"co_asc_koch", "co_asc_munkasey", "polar_asc")
|
||||
|
||||
|
||||
class HousesRequest(BaseModel):
|
||||
when_utc: datetime
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
system: str = "whole_sign"
|
||||
|
||||
|
||||
@app.post("/houses")
|
||||
def houses(req: HousesRequest) -> dict:
|
||||
"""Cuspy domów i osie policzone przez silnik B — do porównania z własnym.
|
||||
|
||||
Domyka kontrakt parzystości (LOG-28) po stronie domów: dotąd obejmował
|
||||
wyłącznie pozycje obiektów, więc błąd w podziale na domy przechodził przez
|
||||
porównanie silników niezauważony. Błąd w domach jest CICHY — wykres wygląda
|
||||
poprawnie, tylko planety siedzą gdzie indziej — więc akurat tu warto mieć
|
||||
drugie zdanie.
|
||||
|
||||
Placidus i Koch nie istnieją powyżej koła podbiegunowego i swisseph zgłasza
|
||||
tam wyjątek. Oddajemy to jako 422 z czytelnym powodem, a NIE podstawiamy po
|
||||
cichu innego systemu: cicha podmiana jest nie do wykrycia po stronie
|
||||
wołającego, a to on ma zdecydować, co z tym zrobić.
|
||||
"""
|
||||
code = _HOUSE_CODES.get(req.system)
|
||||
if code is None:
|
||||
raise HTTPException(422, f"nieznany system domów: {req.system!r} "
|
||||
f"(znane: {', '.join(sorted(_HOUSE_CODES))})")
|
||||
d = req.when_utc
|
||||
ut_hours = d.hour + d.minute / 60.0 + d.second / 3600.0
|
||||
jd = swe.julday(d.year, d.month, d.day, ut_hours)
|
||||
try:
|
||||
cusps, ascmc = swe.houses(jd, req.lat, req.lon, code)
|
||||
except Exception as e: # poza dziedziną systemu
|
||||
raise HTTPException(
|
||||
422, f"system {req.system!r} nie ma definicji dla φ={req.lat}: {e}") from e
|
||||
|
||||
return {
|
||||
"engine": "swisseph",
|
||||
"system": req.system,
|
||||
"cusps": [{"house": i + 1, "longitude": c % 360.0} for i, c in enumerate(cusps)],
|
||||
"angles": {name: ascmc[i] % 360.0 for i, name in enumerate(_ASCMC)
|
||||
if i < len(ascmc) and name in ("Asc", "MC", "ARMC", "Vertex")},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
|
||||
@@ -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
|
||||
- `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ą)
|
||||
|
||||
## 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ół
|
||||
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
||||
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,94 @@ Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app import link_crypto
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class DataClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||
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 search(self, key: str, value: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit}
|
||||
|
||||
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:
|
||||
"""Rozmówca warstwy danych, opcjonalnie ZWIĄZANY Z PULĄ KONTA (PRE-29).
|
||||
|
||||
Pula siedzi w INSTANCJI, nie w sygnaturach metod. Gdyby jechała argumentem,
|
||||
trzeba by ją przeprowadzić przez protokół DataSource i przez build_report —
|
||||
czyli przez kod, który o kontach nie ma prawa nic wiedzieć. A przy okazji
|
||||
każde nowe wywołanie byłoby okazją, żeby o nią zapomnieć i sięgnąć nie tam.
|
||||
|
||||
Puste = cały udział, czyli zachowanie produkcji."""
|
||||
|
||||
def __init__(self, base_url: str | None = None, tenant: str = "") -> None:
|
||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||
self.tenant = tenant or ""
|
||||
|
||||
def search(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
exact: bool,
|
||||
limit: int,
|
||||
fields: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit,
|
||||
"fields": fields, "tenant": self.tenant}
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||
return link_crypto.call_json(client, "POST", f"{self.base_url}/search",
|
||||
payload=payload, headers=_auth_headers(),
|
||||
link=_link())
|
||||
|
||||
def bases(self) -> dict[str, Any]:
|
||||
"""Lista baz na udziale + metaopis (DAN-15) — same metadane, bez treści."""
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/search", json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
||||
headers=_auth_headers(), link=_link())
|
||||
|
||||
|
||||
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
|
||||
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
|
||||
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
|
||||
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
|
||||
|
||||
def files_list(self, for_admin: bool = False) -> dict[str, Any]:
|
||||
return self._files_post("/files", {"for_admin": for_admin, "tenant": self.tenant})
|
||||
|
||||
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
|
||||
return self._files_post("/files/status", {"path": path, "status": status, "by": by, "tenant": self.tenant})
|
||||
|
||||
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
|
||||
return self._files_post("/files/upload",
|
||||
{"filename": filename, "content_b64": content_b64, "by": by, "tenant": self.tenant})
|
||||
|
||||
def files_delete(self, path: str) -> dict[str, Any]:
|
||||
return self._files_post("/files/delete", {"path": path, "tenant": self.tenant})
|
||||
|
||||
def files_rules(self, rules: dict) -> dict[str, Any]:
|
||||
return self._files_post("/files/rules", {"rules": rules, "tenant": self.tenant})
|
||||
|
||||
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
|
||||
payload=payload, headers=_auth_headers(), link=_link())
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
||||
# nie mają, a nie przechodzi tędy nic z baz.
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.get(f"{self.base_url}/health")
|
||||
r = client.get(f"{self.base_url}/health", headers=_auth_headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""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,
|
||||
}
|
||||
# Aspekty POBOCZNE (opcjonalne, PRE-06). Tylko te trzy — bo mają już glify i barwy
|
||||
# w warstwie prezentacji (chartwheel) oraz w engine/glyphs.py, więc dokładają się
|
||||
# bez ruszania czegokolwiek poza silnikiem. Bazy interpretacyjne zwykle ich nie
|
||||
# opisują (brak tokenu w DB_TOKEN), więc trafiają na kosmogram i do tabeli, ale
|
||||
# NIE generują faset sygnifikatorów — most po cichu je pomija (significators.py).
|
||||
MINOR = {
|
||||
"semisextile": 30.0,
|
||||
"semisquare": 45.0,
|
||||
"quincunx": 150.0,
|
||||
}
|
||||
DB_TOKEN = {
|
||||
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
|
||||
"trine": "[tri", "opposition": "[opp",
|
||||
}
|
||||
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 _first_aspect(sep: float, allowed: float, checks: dict) -> tuple[str, float] | None:
|
||||
"""Pierwszy aspekt z `checks`, w którego orbie mieści się separacja `sep`."""
|
||||
for asp, angle in checks.items():
|
||||
dev = abs(sep - angle)
|
||||
if dev <= allowed:
|
||||
return asp, round(dev, 2)
|
||||
return None
|
||||
|
||||
|
||||
def find_cross_aspects(
|
||||
a_positions: list[dict], b_positions: list[dict],
|
||||
orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS, minor: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Aspekty MIĘDZY dwoma horoskopami (synastria, PRE-04): każdy obiekt z A vs
|
||||
każdy obiekt z B (`obj1` = osoba A, `obj2` = osoba B). Statyczne — dwa natale,
|
||||
brak wspólnego czasu, więc bez applying/separating. RIGID_PAIRS nie dotyczy
|
||||
(NN osoby A vs SN osoby B to realny aspekt, nie artefakt definicji)."""
|
||||
checks = {**MAJOR, **MINOR} if minor else MAJOR
|
||||
out: list[dict] = []
|
||||
for a in a_positions:
|
||||
la = a.get("decimal")
|
||||
if la is None:
|
||||
continue
|
||||
for b in b_positions:
|
||||
lb = b.get("decimal")
|
||||
if lb is None:
|
||||
continue
|
||||
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
|
||||
m = _first_aspect(separation(float(la), float(lb)), allowed, checks)
|
||||
if m:
|
||||
out.append({"obj1": a["name"], "obj2": b["name"],
|
||||
"aspect": m[0], "orb": m[1], "allowed": round(allowed, 2)})
|
||||
return out
|
||||
|
||||
|
||||
def find_aspects(
|
||||
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS,
|
||||
minor: bool = False,
|
||||
) -> list[dict]:
|
||||
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
|
||||
|
||||
Zwraca listę aspektów; gdy znane są prędkości, każdy aspekt ma applying (bool)
|
||||
i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny). Orb i bonus dla świateł są
|
||||
KONFIGUROWALNE (PRE-06); `minor=True` dokłada aspekty poboczne (30/45/150°).
|
||||
|
||||
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
|
||||
"""
|
||||
checks = {**MAJOR, **MINOR} if minor else MAJOR
|
||||
out: list[dict] = []
|
||||
n = len(positions)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = positions[i], positions[j]
|
||||
if _is_rigid(a["name"], b["name"]):
|
||||
continue
|
||||
la, lb = a.get("decimal"), b.get("decimal")
|
||||
if la is None or lb is None:
|
||||
continue
|
||||
sep = separation(float(la), float(lb))
|
||||
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
|
||||
for asp, angle in checks.items():
|
||||
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,179 @@
|
||||
"""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,
|
||||
house_systems: list[str] | None = None,
|
||||
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0,
|
||||
aspect_minor: bool = False) -> dict:
|
||||
from app.engine import glyphs as GL
|
||||
from app.engine.aspects import find_aspects
|
||||
from app.engine.houses import mean_obliquity
|
||||
from app.engine.out_of_zodiac import (
|
||||
declination,
|
||||
find_antiscia,
|
||||
find_declination_aspects,
|
||||
is_out_of_bounds,
|
||||
)
|
||||
|
||||
positions = engine.positions(moment)
|
||||
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
||||
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
|
||||
result["aspects"] = find_aspects( # aspekty (LOG-06), konfigurowalne (PRE-06)
|
||||
result["positions"], orb=aspect_orb,
|
||||
luminary_bonus=aspect_luminary_bonus, minor=aspect_minor)
|
||||
|
||||
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
|
||||
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
|
||||
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
|
||||
# z surowych długości/szerokości silnika.
|
||||
# ε PRAWDZIWE — to samo, którym liczymy domy, bo pozycje ze Skyfielda są
|
||||
# POZORNE (uwzględniają nutację). Silnik bez sidereal() nutacji nie poda;
|
||||
# wtedy zostaje średnie, z jawnym oznaczeniem w wyniku.
|
||||
if hasattr(engine, "sidereal"):
|
||||
eps = engine.sidereal(moment)[1]
|
||||
result["obliquity_kind"] = "true"
|
||||
else:
|
||||
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
||||
result["obliquity_kind"] = "mean"
|
||||
result["obliquity"] = round(eps, 6)
|
||||
for pdict, obj in zip(result["positions"], positions):
|
||||
dec = declination(obj.longitude, obj.latitude, eps)
|
||||
pdict["declination"] = round(dec, 4)
|
||||
if is_out_of_bounds(dec, eps):
|
||||
pdict["out_of_bounds"] = True
|
||||
result["parallels"] = find_declination_aspects(result["positions"])
|
||||
result["antiscia"] = find_antiscia(result["positions"])
|
||||
|
||||
# glify aspektów (LOG-22) — symbol aspektu nie zależy od zodiaku
|
||||
for a in result["aspects"]:
|
||||
a["glyph"] = GL.aspect_glyph(a["aspect"])
|
||||
|
||||
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
||||
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
||||
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
||||
result["zodiac"] = zodiac
|
||||
if zodiac in Z.SIDEREAL:
|
||||
result["ayanamsha"] = round(off, 6)
|
||||
for pdict in result["positions"]:
|
||||
_shift_pos(pdict, off)
|
||||
|
||||
# glify obiektów (LOG-22): symbol planety jest niezmienniczy, symbol ZNAKU
|
||||
# zależy od zodiaku, więc po przesunięciu. Pierścień 12 znaków pod kosmogram.
|
||||
for pdict in result["positions"]:
|
||||
pdict["glyph"] = GL.glyph_for(pdict["name"])
|
||||
pdict["sign_glyph"] = GL.sign_glyph(pdict["sign"])
|
||||
result["sign_glyphs"] = [{"sign": s, "glyph": GL.sign_glyph(s)} for s in SIGNS]
|
||||
|
||||
if not hasattr(engine, "sidereal"):
|
||||
return result
|
||||
|
||||
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
|
||||
# cusps_detailed, nie cusps_for: Placidus i Koch nie istnieją powyżej koła
|
||||
# podbiegunowego, a astrolog z Tromsø ma dostać wynik ZE ŚLADEM, czym go
|
||||
# policzyliśmy. Ten ślad musi dojść aż do raportu i PDF-a.
|
||||
primary = H.cusps_detailed(ramc, eps, moment.lat, system)
|
||||
cusp_list = primary.cusps # tropikalne — geometria domów jest niezmiennicza
|
||||
|
||||
def _cusps_out(cl: list[float]) -> list[dict]:
|
||||
"""Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif."""
|
||||
return [
|
||||
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||
"in_sign": in_sign(norm360(c - off)),
|
||||
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
|
||||
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
|
||||
for i, c in enumerate(cl)
|
||||
]
|
||||
|
||||
result["house_system"] = primary.system # FAKTYCZNIE użyty
|
||||
result["house_system_requested"] = primary.requested
|
||||
# Lista ostrzeżeń dla całego horoskopu — prezentacja, raport i PDF czytają
|
||||
# jedno miejsce, więc żaden z nich nie może „zapomnieć" o fallbacku.
|
||||
result["house_warnings"] = [primary.notice] if primary.notice else []
|
||||
result["angles"] = {
|
||||
"Asc": _fmt("Asc", asc, off),
|
||||
"MC": _fmt("MC", mc, off),
|
||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
||||
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
||||
}
|
||||
for a in result["angles"].values(): # glif znaku osi (LOG-22)
|
||||
a["sign_glyph"] = GL.sign_glyph(a["sign"])
|
||||
result["cusps"] = _cusps_out(cusp_list) # PRYMARNY system — pod kosmogram i wstecz
|
||||
for pdict, obj in zip(result["positions"], positions):
|
||||
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
|
||||
|
||||
# Wiele systemów domów NARAZ (PRE-05/LOG-05) — do porównania obok siebie.
|
||||
# Osie (Asc/MC) są wspólne; różni się tylko PODZIAŁ na domy. Prymarny zostaje
|
||||
# w `cusps`/`house_system` (kosmogram i wstecz), a `house_systems` niesie pełen
|
||||
# zestaw; `positions[].houses[system]` mówi, w którym domu obiekt siedzi wg
|
||||
# danego systemu. Dokładamy tylko gdy poproszono o więcej niż jeden.
|
||||
requested = [system] + [s for s in (house_systems or []) if s in H.SYSTEMS]
|
||||
ordered = list(dict.fromkeys(requested)) # prymarny pierwszy, bez duplikatów
|
||||
if len(ordered) > 1:
|
||||
result["house_systems"] = []
|
||||
for s in ordered:
|
||||
cs = primary if s == system else H.cusps_detailed(ramc, eps, moment.lat, s)
|
||||
block = {"system": s, "used_system": cs.system, "cusps": _cusps_out(cs.cusps)}
|
||||
if cs.notice:
|
||||
block["notice"] = cs.notice
|
||||
if cs.notice not in result["house_warnings"]:
|
||||
result["house_warnings"].append(cs.notice)
|
||||
result["house_systems"].append(block)
|
||||
for pdict, obj in zip(result["positions"], positions):
|
||||
pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cs.cusps)
|
||||
|
||||
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
|
||||
from app.engine.firdaria import is_day_birth
|
||||
from app.engine.lots import compute_lots
|
||||
|
||||
pts = {p.name: p.longitude for p in positions}
|
||||
pts["Asc"] = asc
|
||||
day = is_day_birth(pts["Sun"], asc, mc) if "Sun" in pts else True
|
||||
result["sect"] = "day" if day else "night"
|
||||
result["lots"] = [
|
||||
{**lot,
|
||||
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
||||
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
||||
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
||||
"house": H.assign_house(lot["longitude"], cusp_list), # dom po długości tropikalnej
|
||||
"glyph": GL.glyph_for(lot["name"]), # ⊗ dla Fortuny; reszta None (LOG-22)
|
||||
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(lot["longitude"] - off))])}
|
||||
for lot in compute_lots(pts, day, lots_method)
|
||||
]
|
||||
return result
|
||||
@@ -0,0 +1,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,177 @@
|
||||
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
|
||||
|
||||
Litery i słowa (Sa Pis 26°08' conj Fortune) ↔ glify (♄ ♓ 26°08' ☌ ⊗). Potrzebne
|
||||
pod rysowanie kosmogramu (PRE-12) — planety i znaki na kole rysujemy symbolami.
|
||||
|
||||
DWIE zasady z bazy wymagań, obie krytyczne:
|
||||
* DAN-18: TYLKO tekstowy Unicode, NIGDY emoji. Część znaków (zodiak, ♀, ♂) ma
|
||||
domyślnie prezentację emoji — kolorowy kwadrat zamiast czarno-białego glifu,
|
||||
nieczytelny i niesterowalny przez CSS. Wymuszamy prezentację tekstową
|
||||
selektorem wariantu U+FE0E (NIE U+FE0F, który robi odwrotnie).
|
||||
* DAN-17: łańcuch musi znosić dowolny Unicode. Reverse (symbol→tekst)
|
||||
normalizuje wejście, zdejmując selektory wariantu, żeby glif z FE0E i bez
|
||||
dawał ten sam wynik.
|
||||
|
||||
Glify zapisane przez \\u — jednoznaczne code-pointy, odporne na zniekształcenia
|
||||
edytorów i samodokumentujące (widać, który to znak Unicode).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# selektory wariantu prezentacji
|
||||
_TEXT = "︎" # VS15 — wymusza glif tekstowy (czarno-biały)
|
||||
_EMOJI = "️" # VS16 — prezentacja emoji; NIGDY nie emitujemy, zdejmujemy przy reverse
|
||||
|
||||
|
||||
def _t(cp: str) -> str:
|
||||
"""Znak z wymuszoną prezentacją tekstową (dokleja VS15)."""
|
||||
return cp + _TEXT
|
||||
|
||||
|
||||
# ── planety i światła ────────────────────────────────────────────────────
|
||||
# ♀ i ♂ mają wariant emoji → wymuszamy tekst; reszta jest tekstowa domyślnie.
|
||||
PLANET = {
|
||||
"Sun": "☉", # ☉
|
||||
"Moon": "☽", # ☽
|
||||
"Mercury": "☿", # ☿
|
||||
"Venus": _t("♀"), # ♀︎
|
||||
"Mars": _t("♂"), # ♂︎
|
||||
"Jupiter": "♃", # ♃
|
||||
"Saturn": "♄", # ♄
|
||||
"Uranus": "♅", # ♅
|
||||
"Neptune": "♆", # ♆
|
||||
"Pluto": "♇", # ♇
|
||||
}
|
||||
|
||||
# ── punkty wirtualne ─────────────────────────────────────────────────────
|
||||
POINT = {
|
||||
"North Node": "☊", # ☊
|
||||
"South Node": "☋", # ☋
|
||||
"Lilith": "⚸", # ⚸ (Black Moon Lilith)
|
||||
"Chiron": "⚷", # ⚷
|
||||
}
|
||||
|
||||
# ── Lots (punkty arabskie) ───────────────────────────────────────────────
|
||||
# Standardowy glif ma tylko Fortuna (⊗). Reszta Lotów nie ma powszechnie
|
||||
# wspieranego symbolu → zwracamy None i UI pokazuje nazwę.
|
||||
LOT = {
|
||||
"Fortune": "⊗", # ⊗
|
||||
"Part of Fortune": "⊗",
|
||||
}
|
||||
|
||||
# ── znaki zodiaku (wszystkie mają wariant emoji → wszystkie z VS15) ──────
|
||||
_SIGN_CP = {
|
||||
"Aries": "♈", "Taurus": "♉", "Gemini": "♊", "Cancer": "♋",
|
||||
"Leo": "♌", "Virgo": "♍", "Libra": "♎", "Scorpio": "♏",
|
||||
"Sagittarius": "♐", "Capricorn": "♑", "Aquarius": "♒", "Pisces": "♓",
|
||||
}
|
||||
SIGN = {name: _t(cp) for name, cp in _SIGN_CP.items()}
|
||||
|
||||
# ── aspekty (nazwy jak w aspects.py + minor z abbreviations) ─────────────
|
||||
ASPECT = {
|
||||
"conjunction": "☌", # ☌
|
||||
"opposition": "☍", # ☍
|
||||
"trine": "△", # △
|
||||
"square": "□", # □
|
||||
"sextile": "⚹", # ⚹
|
||||
"semisextile": "⚺", # ⚺
|
||||
"quincunx": "⚻", # ⚻
|
||||
"semisquare": "∠", # ∠
|
||||
}
|
||||
|
||||
# ── ruch ─────────────────────────────────────────────────────────────────
|
||||
RETROGRADE = "℞" # ℞
|
||||
DIRECT = "D" # zwykłe „D" — brak dedykowanego glifu prostego ruchu
|
||||
|
||||
|
||||
# ── forward: nazwa → glif ────────────────────────────────────────────────
|
||||
|
||||
def glyph_for(name: str) -> str | None:
|
||||
"""Glif obiektu/punktu/Lota po nazwie (jak w silniku). None, gdy brak."""
|
||||
return PLANET.get(name) or POINT.get(name) or LOT.get(name)
|
||||
|
||||
|
||||
def sign_glyph(sign: str) -> str | None:
|
||||
return SIGN.get(sign)
|
||||
|
||||
|
||||
def aspect_glyph(aspect: str) -> str | None:
|
||||
return ASPECT.get(aspect)
|
||||
|
||||
|
||||
# ── reverse: glif → nazwa ────────────────────────────────────────────────
|
||||
|
||||
def _strip_variants(g: str) -> str:
|
||||
"""Zdejmuje selektory wariantu — glif z FE0E i bez daje ten sam klucz."""
|
||||
return g.replace(_TEXT, "").replace(_EMOJI, "")
|
||||
|
||||
|
||||
def _reverse(mapping: dict[str, str]) -> dict[str, str]:
|
||||
# pierwsze wystąpienie wygrywa (Fortune vs Part of Fortune → 'Fortune')
|
||||
out: dict[str, str] = {}
|
||||
for name, g in mapping.items():
|
||||
out.setdefault(_strip_variants(g), name)
|
||||
return out
|
||||
|
||||
|
||||
_PLANET_POINT_REV = _reverse({**PLANET, **POINT, **{"Fortune": LOT["Fortune"]}})
|
||||
_SIGN_REV = _reverse(SIGN)
|
||||
_ASPECT_REV = _reverse(ASPECT)
|
||||
|
||||
|
||||
def name_for_glyph(g: str) -> str | None:
|
||||
"""Obiekt/punkt po glifie (znosi obecność lub brak selektora wariantu)."""
|
||||
return _PLANET_POINT_REV.get(_strip_variants(g))
|
||||
|
||||
|
||||
def sign_for_glyph(g: str) -> str | None:
|
||||
return _SIGN_REV.get(_strip_variants(g))
|
||||
|
||||
|
||||
def aspect_for_glyph(g: str) -> str | None:
|
||||
return _ASPECT_REV.get(_strip_variants(g))
|
||||
|
||||
|
||||
# ── glifikacja tekstu sygnifikatora (składnia [XX z bazy) ────────────────
|
||||
# Nasze sygnifikatory zapisane są tokenami z prefiksem [ (np. „[Sa [conj [PF").
|
||||
# glyphify zamienia znane tokeny na glify, resztę zostawia. To druga strona
|
||||
# abbreviations.expand: tam token → słowo, tu token → symbol.
|
||||
import re as _re
|
||||
|
||||
# token po nawiasie (np. „Su", „Tau", „conj", „PF") → glif
|
||||
_TOKEN_GLYPH: dict[str, str] = {}
|
||||
_ABBR_TO_NAME = {
|
||||
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus", "Ma": "Mars",
|
||||
"Ju": "Jupiter", "Sa": "Saturn", "Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
|
||||
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||
"PF": "Fortune", "Fortune": "Fortune",
|
||||
}
|
||||
for _abbr, _name in _ABBR_TO_NAME.items():
|
||||
_g = glyph_for(_name)
|
||||
if _g:
|
||||
_TOKEN_GLYPH[_abbr] = _g
|
||||
_SIGN_ABBR_NAME = {
|
||||
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer", "Leo": "Leo",
|
||||
"Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio", "Sag": "Sagittarius",
|
||||
"Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
|
||||
}
|
||||
for _abbr, _name in _SIGN_ABBR_NAME.items():
|
||||
_TOKEN_GLYPH[_abbr] = SIGN[_name]
|
||||
_ASP_ABBR_NAME = {
|
||||
"conj": "conjunction", "opp": "opposition", "tri": "trine", "sq": "square",
|
||||
"sex": "sextile", "semisex": "semisextile", "quincunx": "quincunx", "semisq": "semisquare",
|
||||
}
|
||||
for _abbr, _name in _ASP_ABBR_NAME.items():
|
||||
if _name in ASPECT:
|
||||
_TOKEN_GLYPH[_abbr] = ASPECT[_name]
|
||||
|
||||
_TOKEN_RE = _re.compile(r"\[([A-Za-z]+)")
|
||||
|
||||
|
||||
def glyphify(text: str) -> str:
|
||||
"""Zamienia tokeny [XX na glify; nieznane zostawia bez zmiany. Dodatkowo
|
||||
»Rx«/»R« → ℞. Nie parsuje stopni — te i tak są czytelne (26°08')."""
|
||||
if not text:
|
||||
return text
|
||||
out = _TOKEN_RE.sub(lambda m: _TOKEN_GLYPH.get(m.group(1), m.group(0)), text)
|
||||
out = _re.sub(r"\bR[x]?\b", RETROGRADE, out)
|
||||
return out
|
||||
@@ -0,0 +1,587 @@
|
||||
"""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 dataclasses import dataclass
|
||||
|
||||
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
||||
|
||||
WHOLE_SIGN = "whole_sign"
|
||||
EQUAL = "equal"
|
||||
EQUAL_MC = "equal_mc" # równe domy zakotwiczone na MC, nie na Asc
|
||||
WHOLE_SIGN_ARIES = "whole_sign_aries" # znaki jako domy, ale dom I to ZAWSZE Baran
|
||||
PORPHYRY = "porphyry"
|
||||
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania
|
||||
# iteracyjnego i dochodzą osobno.
|
||||
VEHLOW = "vehlow"
|
||||
MORINUS = "morinus"
|
||||
REGIOMONTANUS = "regiomontanus"
|
||||
CAMPANUS = "campanus"
|
||||
ALCABITUS = "alcabitus"
|
||||
TOPOCENTRIC = "topocentric"
|
||||
PLACIDUS = "placidus"
|
||||
KOCH = "koch"
|
||||
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris
|
||||
# (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny:
|
||||
# powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi.
|
||||
SYSTEMS = (WHOLE_SIGN, WHOLE_SIGN_ARIES, EQUAL, EQUAL_MC, PORPHYRY, VEHLOW,
|
||||
MORINUS, REGIOMONTANUS, CAMPANUS, ALCABITUS, TOPOCENTRIC,
|
||||
PLACIDUS, KOCH)
|
||||
|
||||
|
||||
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:
|
||||
"""Ascendent — punkt ekliptyki wschodzący na horyzoncie.
|
||||
|
||||
KOREKTA GAŁĘZI (błąd wykryty przez porównanie z wyrocznią, tests/oracle):
|
||||
ekliptyka przecina horyzont w DWÓCH punktach — wschodzącym (Asc) i zachodzącym
|
||||
(Dsc). `atan2` wybiera jeden z nich, ale powyżej koła podbiegunowego potrafi
|
||||
wskazać ten NIEWŁAŚCIWY: dla szerokości 67°+ i szerokiego zakresu RAMC
|
||||
zwracaliśmy Descendent, czyli Ascendent przesunięty o 180°. Skutek nie był
|
||||
subtelny — planety lądowały w PRZECIWNYCH domach dla całej Skandynawii
|
||||
północnej (Tromsø, Rovaniemi, Murmańsk).
|
||||
|
||||
Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze w półkolu
|
||||
(0°, 180°) na wschód od MC. Reguła zweryfikowana na 46 800 przypadkach wobec
|
||||
Swiss Ephemeris — zero rozbieżności.
|
||||
"""
|
||||
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
||||
asc = norm360(math.degrees(math.atan2(
|
||||
math.cos(r),
|
||||
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
||||
)))
|
||||
mc = compute_mc(ramc_deg, eps_deg)
|
||||
return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else asc
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Poniżej tej odległości od granicy znaku traktujemy Ascendent jak leżący DOKŁADNIE
|
||||
# na niej. 1e-9° to 3,6 mikrosekundy łuku — o rzędy wielkości poniżej jakiejkolwiek
|
||||
# realnej dokładności danych urodzeniowych, więc nie zmienia to żadnego horoskopu.
|
||||
_SIGN_SNAP_DEG = 1e-9
|
||||
|
||||
|
||||
def _snap_to_sign_boundary(lon: float) -> float:
|
||||
"""Przyciąga długość do granicy znaku, gdy jest od niej o włos.
|
||||
|
||||
Whole sign jest NIECIĄGŁY na granicach znaków: różnica 10⁻¹¹° w Ascendencie
|
||||
przerzuca cały dom I o 30°. Bez tego przyciągania ten sam horoskop policzony
|
||||
na innej maszynie mógłby dać inny wynik (wykryte przez porównanie z wyrocznią:
|
||||
nasz Asc = 359,999999999976, swissepha = 1e-10 — ta sama wartość po dwóch
|
||||
stronach granicy Ryby/Baran). Determinizm jest tu ważniejszy niż dosłowność
|
||||
zmiennoprzecinkowa."""
|
||||
nearest = round(lon / 30.0) * 30.0
|
||||
return norm360(nearest) if abs(lon - nearest) < _SIGN_SNAP_DEG else lon
|
||||
|
||||
|
||||
def cusps(asc: float, mc: float, system: str) -> list[float]:
|
||||
"""Zwraca 12 cusps (długości) domów 1..12."""
|
||||
if system == WHOLE_SIGN:
|
||||
start = sign_index(_snap_to_sign_boundary(asc)) * 30.0
|
||||
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 polar_circle(eps_deg: float) -> float:
|
||||
"""Szerokość koła podbiegunowego [°] dla danego nachylenia ekliptyki.
|
||||
|
||||
NIE jest to stała 66,56°: ε zmienia się z datą (ok. 23,71° w 370 p.n.e.,
|
||||
23,44° dziś), więc granica przesuwa się o ~0,3° w zakresie dat programu.
|
||||
Powyżej niej stopnie ekliptyki bywają okołobiegunowe — nie wschodzą ani nie
|
||||
zachodzą — przez co systemy oparte na łuku dobowym (Placidus, Koch) tracą
|
||||
definicję."""
|
||||
return 90.0 - abs(eps_deg)
|
||||
|
||||
|
||||
# ── geometria wektorowa dla systemów dzielących koła wielkie ─────────────
|
||||
# Wzory na te systemy krążą w literaturze w kilku wariantach i łatwo o pomyłkę
|
||||
# w gałęzi albo znaku. Liczymy więc WPROST z geometrii: budujemy wektory kierunkowe
|
||||
# w układzie równikowym, przecinamy płaszczyzny i dopiero wynik zamieniamy na
|
||||
# długość ekliptyczną. Jest to dłuższe, ale jednoznaczne i sprawdzalne.
|
||||
|
||||
def _cross(a, b):
|
||||
return (a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0])
|
||||
|
||||
|
||||
def _dot(a, b):
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
|
||||
|
||||
def _equatorial_to_lon(v, eps_rad: float) -> float:
|
||||
"""Wektor w układzie RÓWNIKOWYM → długość ekliptyczna [°]."""
|
||||
x, y, z = v
|
||||
y_ecl = y * math.cos(eps_rad) + z * math.sin(eps_rad)
|
||||
return norm360(math.degrees(math.atan2(y_ecl, x)))
|
||||
|
||||
|
||||
def _ecliptic_pole(eps_rad: float):
|
||||
"""Biegun ekliptyki (normalna płaszczyzny ekliptyki) w układzie równikowym."""
|
||||
return (0.0, -math.sin(eps_rad), math.cos(eps_rad))
|
||||
|
||||
|
||||
def _horizon_north(ramc_rad: float, lat_rad: float):
|
||||
"""Punkt północny horyzontu: RA = RAMC+180°, deklinacja = 90°−φ."""
|
||||
return (-math.sin(lat_rad) * math.cos(ramc_rad),
|
||||
-math.sin(lat_rad) * math.sin(ramc_rad),
|
||||
math.cos(lat_rad))
|
||||
|
||||
|
||||
# Domy POŚREDNIE (bez osi) i to, po której stronie MC leżą. Domy 11, 12, 2, 3
|
||||
# są na wschód od MC (przesunięcie 0–180°), domy 5, 6, 8, 9 — na zachód.
|
||||
_INTERMEDIATE = {1: True, 2: True, 4: False, 5: False,
|
||||
7: False, 8: False, 10: True, 11: True}
|
||||
|
||||
|
||||
def _house_circle_cusp(north, q, eps_rad: float, mc: float, east_of_mc: bool) -> float:
|
||||
"""Cusp = przecięcie ekliptyki z kołem domu.
|
||||
|
||||
Koło domu przechodzi przez punkty N/S horyzontu oraz przez punkt podziału `q`
|
||||
(na równiku dla Regiomontanusa, na pierwszym wertykale dla Campanusa).
|
||||
Przecięcie dwóch płaszczyzn daje PROSTĄ, czyli DWA antypodyczne kierunki;
|
||||
wybieramy ten po właściwej stronie południka.
|
||||
|
||||
Używane WYŁĄCZNIE dla domów pośrednich. Osie (1, 4, 7, 10) znamy dokładnie
|
||||
z Asc i MC — liczenie ich tą drogą było błędem, bo leżą dokładnie na granicy
|
||||
„wschód/zachód" (przesunięcie 0° i 180°), gdzie porównanie zmiennoprzecinkowe
|
||||
się chwieje i potrafi wybrać przeciwny punkt nieba."""
|
||||
normal = _cross(north, q) # normalna płaszczyzny koła domu
|
||||
line = _cross(normal, _ecliptic_pole(eps_rad))
|
||||
lon = _equatorial_to_lon(line, eps_rad)
|
||||
return lon if ((lon - mc) % 360.0 < 180.0) == east_of_mc else norm360(lon + 180.0)
|
||||
|
||||
|
||||
def _culminating_mc(mc: float, eps: float, lat: float) -> float:
|
||||
"""Punkt południka, który dla tej szerokości leży NAD horyzontem.
|
||||
|
||||
Systemy oparte na horyzoncie (Regiomontanus, Campanus, Topocentric) biorą jako
|
||||
dziesiąty dom punkt GÓRUJĄCY, a nie matematyczne MC — a za kołem podbiegunowym
|
||||
to nie zawsze to samo. Punkt południka o deklinacji δ ma wysokość 90−|φ−δ|,
|
||||
więc jest nad horyzontem dokładnie wtedy, gdy |φ−δ| < 90.
|
||||
|
||||
Systemy dzielące samą ekliptykę (porphyry, equal, alcabitus, whole sign) tego
|
||||
nie robią — i tak samo zachowuje się wyrocznia."""
|
||||
dec = math.degrees(math.asin(math.sin(math.radians(mc)) * math.sin(math.radians(eps))))
|
||||
return norm360(mc + 180.0) if abs(lat - dec) > 90.0 else mc
|
||||
|
||||
|
||||
def _with_exact_angles(intermediate, asc: float, mc: float) -> list[float]:
|
||||
"""Składa 12 cuspów: osie wstawione dokładnie, reszta z geometrii."""
|
||||
out = [0.0] * 12
|
||||
out[0], out[3] = asc, norm360(mc + 180.0) # Asc, IC
|
||||
out[6], out[9] = norm360(asc + 180.0), mc # Dsc, MC
|
||||
for i, value in intermediate.items():
|
||||
out[i] = value
|
||||
return out
|
||||
|
||||
|
||||
def _ra_to_ecliptic_lon(ra_deg: float, eps_rad: float) -> float:
|
||||
"""Punkt ekliptyki o zadanej rektascensji (koło godzinne → ekliptyka)."""
|
||||
r = math.radians(ra_deg)
|
||||
return norm360(math.degrees(math.atan2(math.sin(r), math.cos(r) * math.cos(eps_rad))))
|
||||
|
||||
|
||||
def _equator_point(ra_deg: float):
|
||||
"""Kierunek punktu na równiku niebieskim o danej rektascensji."""
|
||||
r = math.radians(ra_deg)
|
||||
return (math.cos(r), math.sin(r), 0.0)
|
||||
|
||||
|
||||
def _prime_vertical_point(ramc_rad: float, lat_rad: float, angle_deg: float):
|
||||
"""Punkt pierwszego wertykału, `angle_deg` od punktu wschodu w stronę nadiru.
|
||||
|
||||
Pierwszy wertykał to koło przez wschód, zenit, zachód i nadir — Campanus dzieli
|
||||
właśnie je."""
|
||||
east = (-math.sin(ramc_rad), math.cos(ramc_rad), 0.0)
|
||||
zenith = (math.cos(lat_rad) * math.cos(ramc_rad),
|
||||
math.cos(lat_rad) * math.sin(ramc_rad),
|
||||
math.sin(lat_rad))
|
||||
a = math.radians(angle_deg)
|
||||
return tuple(east[i] * math.cos(a) - zenith[i] * math.sin(a) for i in range(3))
|
||||
|
||||
|
||||
def _cusps_regiomontanus(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Równik niebieski dzielony na 12 równych łuków, rzut kołami przez N/S horyzontu."""
|
||||
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
||||
north = _horizon_north(rr, lr)
|
||||
mid = {i: _house_circle_cusp(north, _equator_point(ramc + 90.0 + 30.0 * i), er, mc, e)
|
||||
for i, e in _INTERMEDIATE.items()}
|
||||
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
||||
|
||||
|
||||
def _cusps_campanus(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Pierwszy wertykał dzielony na 12 równych łuków, rzut tak samo jak wyżej."""
|
||||
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
||||
north = _horizon_north(rr, lr)
|
||||
mid = {i: _house_circle_cusp(north, _prime_vertical_point(rr, lr, 30.0 * i), er, mc, e)
|
||||
for i, e in _INTERMEDIATE.items()}
|
||||
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
||||
|
||||
|
||||
def _cusps_morinus(ramc: float, eps: float) -> list[float]:
|
||||
"""Równik dzielony od RAMC i rzutowany WPROST na ekliptykę — bez horyzontu.
|
||||
|
||||
Dlatego Morinus jako jedyny nie zależy od szerokości geograficznej, a jego
|
||||
dom I nie pokrywa się z Ascendentem. Uwaga: to ZAMIANA WSPÓŁRZĘDNYCH punktu
|
||||
równika (RA, dec=0) na ekliptyczne, a nie rzut kołem godzinnym — te dwie
|
||||
operacje dają różne wyniki i pomylenie ich kosztowało tu do 5°."""
|
||||
er = math.radians(eps)
|
||||
return [_equatorial_to_lon(_equator_point(ramc + 90.0 + 30.0 * i), er)
|
||||
for i in range(12)]
|
||||
|
||||
|
||||
def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
|
||||
"""Łuki równika MC→Asc i Asc→IC dzielone na trzy; rzut kołami godzinnymi."""
|
||||
er = math.radians(eps)
|
||||
a = math.radians(asc)
|
||||
ra_asc = norm360(math.degrees(math.atan2(math.sin(a) * math.cos(er), math.cos(a))))
|
||||
day = (ra_asc - ramc) % 360.0 # łuk MC → Asc (domy 11, 12)
|
||||
night = (ramc + 180.0 - ra_asc) % 360.0 # łuk Asc → IC (domy 2, 3)
|
||||
|
||||
ra = [0.0] * 12
|
||||
ra[9] = ramc # dom 10 = MC
|
||||
ra[10] = ramc + day / 3.0 # dom 11
|
||||
ra[11] = ramc + 2.0 * day / 3.0 # dom 12
|
||||
ra[0] = ra_asc # dom 1 = Asc
|
||||
ra[1] = ra_asc + night / 3.0 # dom 2
|
||||
ra[2] = ra_asc + 2.0 * night / 3.0 # dom 3
|
||||
for i in range(6): # domy 4–9 naprzeciw 10–3
|
||||
ra[i + 3] = ra[(i + 9) % 12] + 180.0
|
||||
return [_ra_to_ecliptic_lon(x, er) for x in ra]
|
||||
|
||||
|
||||
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (Polich–Page).
|
||||
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
|
||||
# Polich–Page: dom pośredni to Ascendent policzony pod własnym „biegunem"
|
||||
# tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu. Rodzina jest CIĄGŁA:
|
||||
# przy k=0 (biegun 0, przesunięcie −90°) daje MC, przy k=3 (biegun φ, przesunięcie 0)
|
||||
# Ascendent, a domy 11 i 12 leżą po drodze.
|
||||
#
|
||||
# Cała trudność tego systemu siedziała w wyborze gałęzi — dwa koła wielkie
|
||||
# przecinają się w dwóch punktach antypodycznych. Heurystyki („po której stronie
|
||||
# MC", „w łuku kwadrantu", „wschodnia połowa horyzontu", śledzenie ciągłości
|
||||
# krokami) myliły się na 6–11% przypadków powyżej ~70°, bo każda z nich rozstrzyga
|
||||
# LOKALNIE, a przy dużych szerokościach kolejność domów potrafi się odwrócić.
|
||||
#
|
||||
# Rozwiązanie: nie wybierać w ogóle. Iloczyn wektorowy zenitu z biegunem ekliptyki
|
||||
# jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot — dwuznaczność
|
||||
# wprowadza dopiero atan2. Zostajemy więc w wektorach, a znak ustalamy RAZ, kotwicząc
|
||||
# rodzinę na MC górującym. Stąd zgodność co do zera na całej dziedzinie, bez iteracji
|
||||
# i bez zawężania szerokości.
|
||||
|
||||
# Dom → (przesunięcie RAMC [°], ułamek bieguna k/3).
|
||||
_TOPO_STEP = {10: (-60.0, 1 / 3), 11: (-30.0, 2 / 3), # domy 11, 12
|
||||
1: (30.0, 2 / 3), 2: (60.0, 1 / 3)} # domy 2, 3
|
||||
|
||||
|
||||
def _cusps_topocentric(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Ascendenty pod biegunami tan(P) = tan(φ)·k/3, liczone wektorowo.
|
||||
|
||||
Domy 5, 6, 8, 9 bierzemy jako OPOZYCJE domów 11, 12, 2, 3 — to nie skrót,
|
||||
lecz własność konstrukcji: przeciwległe domy leżą na tym samym kole wielkim.
|
||||
|
||||
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik
|
||||
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
|
||||
konstrukcji „ascendent pod biegunem"."""
|
||||
# Cuspy topocentrica są poprawne na CAŁEJ dziedzinie (zgodne z wyrocznią co do
|
||||
# zera), ale powyżej koła podbiegunowego przestają DZIELIĆ OKRĄG: domy nachodzą
|
||||
# na siebie, bo cusp VII (= I + 180°) wypada przed cuspem VI. Przypisanie planety
|
||||
# do domu traci wtedy sens — co potwierdza sama wyrocznia, której swe_house_pos
|
||||
# przeczy tam własnym cuspom (100% zgodności do 62°, 83,9% przy 66°, ok. 50%
|
||||
# przy 72°). Odmawiamy, zamiast zwracać liczbę bez znaczenia.
|
||||
#
|
||||
# To INNY rodzaj granicy niż u Placidusa i Kocha: tam nie istnieją same cuspy,
|
||||
# tu istnieją, tylko nie tworzą podziału. Próg jest wyprowadzony z warunku
|
||||
# „dwanaście cuspów sumuje się do 360°", nie dobrany pod wynik testu — i wypada
|
||||
# na kole podbiegunowym (zmierzone: 100% podziałów do 65°, 78% w pasie 66-67°).
|
||||
if abs(lat) >= polar_circle(eps):
|
||||
raise HouseSystemUndefined(
|
||||
f"φ={lat:.4f}° powyżej koła podbiegunowego ({polar_circle(eps):.4f}° dla "
|
||||
f"ε={eps:.4f}°): cuspy topocentryczne przestają dzielić okrąg, "
|
||||
f"domy nachodzą na siebie")
|
||||
er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
|
||||
epole = _ecliptic_pole(er)
|
||||
# Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
|
||||
# cała rodzina obraca się razem z dziesiątym domem — stąd zwrot iloczynu.
|
||||
culminating = _culminating_mc(mc, eps, lat)
|
||||
sign = 1.0 if abs(((culminating - mc + 180.0) % 360.0) - 180.0) > 90.0 else -1.0
|
||||
|
||||
out = [0.0] * 12
|
||||
for i, (offset, fraction) in _TOPO_STEP.items():
|
||||
th = math.radians(ramc + offset)
|
||||
pole = math.atan(tan_lat * fraction)
|
||||
zenith = (math.cos(pole) * math.cos(th),
|
||||
math.cos(pole) * math.sin(th),
|
||||
math.sin(pole))
|
||||
v = _cross(zenith, epole)
|
||||
lon = _equatorial_to_lon(tuple(sign * x for x in v), er)
|
||||
out[i] = lon
|
||||
out[(i + 6) % 12] = norm360(lon + 180.0)
|
||||
out[0], out[3] = asc, norm360(culminating + 180.0)
|
||||
out[6], out[9] = norm360(asc + 180.0), culminating
|
||||
return out
|
||||
|
||||
|
||||
# ── systemy łuku dobowego (Placidus, Koch) ───────────────────────────────
|
||||
# Różnią się od wszystkich poprzednich tym, że NIE MAJĄ wzoru zamkniętego: cusp
|
||||
# jest zdefiniowany warunkiem na samego siebie („punkt, który przebył 1/3 swojego
|
||||
# półłuku"), więc trzeba go znaleźć iteracyjnie. Mają też jako jedyne REALNĄ
|
||||
# granicę dziedziny — powyżej koła podbiegunowego stopnie ekliptyki bywają
|
||||
# okołobiegunowe, nie wschodzą ani nie zachodzą, i półłuk po prostu nie istnieje.
|
||||
|
||||
|
||||
class HouseSystemUndefined(ValueError):
|
||||
"""System domów nie ma definicji dla podanych parametrów (nie: błąd liczenia).
|
||||
|
||||
Podnoszone zamiast zwrócenia liczby, bo cicha podmiana systemu jest gorsza
|
||||
niż błąd: wykres wygląda poprawnie, a planety siedzą w innych domach, niż
|
||||
astrolog zamawiał. Warstwa aplikacyjna łapie to w cusps_detailed() i robi
|
||||
JAWNY fallback."""
|
||||
|
||||
|
||||
_ITER_MAX = 100
|
||||
_ITER_TOL_DEG = 1e-11
|
||||
|
||||
|
||||
def _declination_of_ecliptic_lon(lon_deg: float, eps_rad: float) -> float:
|
||||
"""Deklinacja punktu LEŻĄCEGO NA EKLIPTYCE o danej długości."""
|
||||
return math.degrees(math.asin(math.sin(eps_rad) * math.sin(math.radians(lon_deg))))
|
||||
|
||||
|
||||
def _ascensional_difference(dec_deg: float, lat_deg: float) -> float:
|
||||
"""Różnica wschodnia: o ile półłuk dobowy odbiega od 90°.
|
||||
|
||||
sin(AD) = tan(φ)·tan(δ). Gdy |tan(φ)·tan(δ)| ≥ 1, punkt jest okołobiegunowy
|
||||
(nigdy nie wschodzi albo nigdy nie zachodzi) i półłuk nie istnieje."""
|
||||
v = math.tan(math.radians(lat_deg)) * math.tan(math.radians(dec_deg))
|
||||
if abs(v) >= 1.0:
|
||||
raise HouseSystemUndefined(
|
||||
f"punkt okołobiegunowy (tan φ·tan δ = {v:.6f}): półłuk dobowy nie istnieje")
|
||||
return math.degrees(math.asin(v))
|
||||
|
||||
|
||||
# Dom → (ułamek półłuku, czy łuk NOCNY). Domy 11 i 12 dzielą łuk dzienny licząc
|
||||
# od MC; domy 2 i 3 — łuk nocny, licząc WSTECZ od IC.
|
||||
_PLACIDUS_STEP = {10: (1 / 3, False), 11: (2 / 3, False),
|
||||
1: (2 / 3, True), 2: (1 / 3, True)}
|
||||
|
||||
|
||||
def _placidus_cusp(ramc: float, eps: float, lat: float,
|
||||
fraction: float, nocturnal: bool) -> float:
|
||||
"""Punkt ekliptyki, który przebył `fraction` swojego półłuku.
|
||||
|
||||
Warunek jest uwikłany: półłuk zależy od deklinacji, deklinacja od długości,
|
||||
a długość od położenia — więc iterujemy po punkcie stałym. Zbieżność jest
|
||||
szybka z dala od koła podbiegunowego i psuje się przy nim, dlatego brak
|
||||
zbieżności traktujemy jako wyjście poza dziedzinę, a nie jako wynik."""
|
||||
eps_rad = math.radians(eps)
|
||||
# Start od podziału równomiernego — to Porphyry na równiku, czyli dokładnie
|
||||
# ten przypadek, w którym Placidus się do niego sprowadza.
|
||||
ra = ramc + 180.0 - 90.0 * fraction if nocturnal else ramc + 90.0 * fraction
|
||||
for _ in range(_ITER_MAX):
|
||||
dec = _declination_of_ecliptic_lon(_ra_to_ecliptic_lon(ra, eps_rad), eps_rad)
|
||||
ad = _ascensional_difference(dec, lat)
|
||||
nxt = (ramc + 180.0 - fraction * (90.0 - ad) if nocturnal
|
||||
else ramc + fraction * (90.0 + ad))
|
||||
if abs(nxt - ra) < _ITER_TOL_DEG:
|
||||
return _ra_to_ecliptic_lon(nxt, eps_rad)
|
||||
ra = nxt
|
||||
raise HouseSystemUndefined(
|
||||
f"brak zbieżności po {_ITER_MAX} krokach (φ={lat:.4f}, RAMC={ramc:.4f})")
|
||||
|
||||
|
||||
def _cusps_placidus(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Półłuki dobowe i nocne dzielone na trzy — każdy punkt swoim własnym łukiem."""
|
||||
if abs(lat) >= polar_circle(eps):
|
||||
raise HouseSystemUndefined(
|
||||
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
|
||||
inter = {i: _placidus_cusp(ramc, eps, lat, f, noct)
|
||||
for i, (f, noct) in _PLACIDUS_STEP.items()}
|
||||
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
|
||||
return _with_exact_angles(inter, asc, mc)
|
||||
|
||||
|
||||
# Koch dzieli CZAS, nie łuk na niebie. Kryterium: ile czasu minęło od wschodu
|
||||
# tego stopnia zodiaku, który stoi na MC. Ten odcinek (półłuk dobowy stopnia MC)
|
||||
# dzielimy na trzy i dla punktów podziału liczymy ZWYKŁY Ascendent — stąd nazwa
|
||||
# „system miejsca urodzenia". Zgodne z definicją Astrodienst (astro.com/astrowiki).
|
||||
#
|
||||
# W przeciwieństwie do Placidusa NIE wymaga iteracji: półłuk zależy od deklinacji
|
||||
# stopnia MC, którą znamy wprost. Granicę dziedziny dzieli natomiast z Placidusem —
|
||||
# gdy stopień MC jest okołobiegunowy, „moment jego wschodu" nie istnieje.
|
||||
_KOCH_OFFSET = {10: -2 / 3, 11: -1 / 3, 1: 1 / 3, 2: 2 / 3}
|
||||
|
||||
|
||||
def _cusps_koch(ramc: float, eps: float, lat: float,
|
||||
asc: float, mc: float) -> list[float]:
|
||||
"""Ascendenty dla chwil trójdzielących drogę stopnia MC od wschodu do górowania."""
|
||||
if abs(lat) >= polar_circle(eps):
|
||||
raise HouseSystemUndefined(
|
||||
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
|
||||
dec_mc = _declination_of_ecliptic_lon(mc, math.radians(eps))
|
||||
half_arc = 90.0 + _ascensional_difference(dec_mc, lat)
|
||||
inter = {i: compute_asc(ramc + f * half_arc, eps, lat)
|
||||
for i, f in _KOCH_OFFSET.items()}
|
||||
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
|
||||
return _with_exact_angles(inter, asc, mc)
|
||||
|
||||
|
||||
def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]:
|
||||
"""Kanoniczne wejście: (RAMC, ε, φ) → 12 cusps.
|
||||
|
||||
Systemy proste (whole sign / equal / porphyry) potrzebują tylko Asc i MC,
|
||||
ale systemy egzotyczne dzielą inne koła wielkie i wymagają pełnego zestawu
|
||||
(RAMC, ε, φ). Ta funkcja jest wspólnym punktem wejścia dla obu rodzajów —
|
||||
i to ją porównuje z wyrocznią framework testowy (tests/oracle).
|
||||
"""
|
||||
asc = compute_asc(ramc, eps, lat)
|
||||
mc = compute_mc(ramc, eps)
|
||||
if system in (WHOLE_SIGN, EQUAL, PORPHYRY):
|
||||
return cusps(asc, mc, system)
|
||||
if system == WHOLE_SIGN_ARIES:
|
||||
# Znaki jako domy, ale numeracja rusza od Barana niezależnie od Ascendentu.
|
||||
# Wariant spotykany w tradycji indyjskiej i w części szkół hellenistycznych.
|
||||
return [norm360(30.0 * i) for i in range(12)]
|
||||
if system == EQUAL_MC:
|
||||
# Równe domy jak `equal`, ale zakotwiczone na MC: dom X zaczyna się
|
||||
# DOKŁADNIE na MC, więc oś południka wypada na granicy domu, a nie w środku.
|
||||
return [norm360(mc + 90.0 + 30.0 * i) for i in range(12)]
|
||||
if system == VEHLOW:
|
||||
# equal, ale Ascendent leży w ŚRODKU domu I, nie na jego początku
|
||||
return [norm360(asc - 15.0 + 30.0 * i) for i in range(12)]
|
||||
if system == MORINUS:
|
||||
return _cusps_morinus(ramc, eps)
|
||||
if system == REGIOMONTANUS:
|
||||
return _cusps_regiomontanus(ramc, eps, lat, asc, mc)
|
||||
if system == CAMPANUS:
|
||||
return _cusps_campanus(ramc, eps, lat, asc, mc)
|
||||
if system == ALCABITUS:
|
||||
return _cusps_alcabitus(ramc, eps, asc)
|
||||
if system == TOPOCENTRIC:
|
||||
return _cusps_topocentric(ramc, eps, lat, asc, mc)
|
||||
if system == PLACIDUS:
|
||||
return _cusps_placidus(ramc, eps, lat, asc, mc)
|
||||
if system == KOCH:
|
||||
return _cusps_koch(ramc, eps, lat, asc, mc)
|
||||
raise ValueError(f"nieznany system domów: {system}")
|
||||
|
||||
|
||||
# ── jawny fallback poza dziedziną ────────────────────────────────────────
|
||||
# Placidus i Koch jako jedyne mają miejsca, w których po prostu NIE ISTNIEJĄ.
|
||||
# Astrolog z Tromsø ma dostać wynik, ale musi wiedzieć, że dostał inny system —
|
||||
# cicha podmiana jest gorsza niż brak wyniku, bo jest nie do wykrycia z wykresu.
|
||||
|
||||
FALLBACK_SYSTEM = PORPHYRY
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CuspSet:
|
||||
"""12 cuspów + uczciwa informacja, czym naprawdę zostały policzone."""
|
||||
|
||||
cusps: list[float]
|
||||
system: str # system FAKTYCZNIE użyty
|
||||
requested: str # o który poproszono
|
||||
reason: str | None = None # dlaczego nie dało się użyć żądanego
|
||||
|
||||
@property
|
||||
def is_fallback(self) -> bool:
|
||||
return self.system != self.requested
|
||||
|
||||
@property
|
||||
def notice(self) -> str | None:
|
||||
"""Komunikat dla człowieka. Ma trafić na ekran, do raportu i do PDF-a."""
|
||||
if not self.is_fallback:
|
||||
return None
|
||||
return (f"UWAGA: system domów \u201e{self.requested}\u201d nie ma definicji "
|
||||
f"dla tego miejsca i czasu \u2014 {self.reason}. Domy policzono "
|
||||
f"systemem \u201e{self.system}\u201d. To NIE jest ten sam podzia\u0142: "
|
||||
f"pozycje planet s\u0105 poprawne, ale przypisanie ich do dom\u00f3w "
|
||||
f"pochodzi z innego systemu.")
|
||||
|
||||
|
||||
def cusps_detailed(ramc: float, eps: float, lat: float, system: str) -> CuspSet:
|
||||
"""Jak cusps_for, ale zamiast wyjątku poza dziedziną robi JAWNY fallback.
|
||||
|
||||
cusps_for zostaje funkcją czystą i nieustępliwą (to ją porównuje wyrocznia);
|
||||
ustępstwo wobec rzeczywistości jest tutaj — i zawsze zostawia ślad."""
|
||||
try:
|
||||
return CuspSet(cusps_for(ramc, eps, lat, system), system, system)
|
||||
except HouseSystemUndefined as e:
|
||||
return CuspSet(cusps_for(ramc, eps, lat, FALLBACK_SYSTEM),
|
||||
FALLBACK_SYSTEM, system, str(e))
|
||||
|
||||
|
||||
def _runs_forward(cusp_list: list[float]) -> bool:
|
||||
"""Czy domy biegną w stronę rosnących długości ekliptycznych.
|
||||
|
||||
Zwykle tak — ale NIE ZAWSZE. Przy dużych szerokościach systemy dzielące koła
|
||||
wielkie (regiomontanus, campanus, topocentric) mają kolejność ODWRÓCONĄ:
|
||||
przy φ = −84,3° cusp domu I wypada na 174,2°, a domu II na 165,3°. To nie
|
||||
jest błąd — wyrocznia zwraca dokładnie te same wartości.
|
||||
|
||||
Rozstrzygamy sumą przeskoków „do przodu": dwanaście cuspów dzieli okrąg, więc
|
||||
idąc we WŁAŚCIWĄ stronę zsumują się do 360°. Idąc pod prąd każdy przeskok
|
||||
obchodzi koło dookoła i suma wychodzi 11 × 360° = 3960°."""
|
||||
total = sum((cusp_list[(i + 1) % 12] - cusp_list[i]) % 360.0 for i in range(12))
|
||||
return abs(total - 360.0) < abs(total - 3960.0)
|
||||
|
||||
|
||||
def assign_house(lon: float, cusp_list: list[float]) -> int:
|
||||
"""Numer domu (1..12), w którym leży dana długość ekliptyczna.
|
||||
|
||||
Kierunek liczenia bierzemy z samych cuspów. Zaszycie „zawsze do przodu"
|
||||
dawało przy |φ| powyżej koła podbiegunowego złe domy dla regiomontanusa,
|
||||
campanusa i topocentrica — mimo cuspów zgodnych z wyrocznią co do zera.
|
||||
Błąd był CICHY: wykres wyglądał poprawnie, tylko planety siedziały gdzie
|
||||
indziej. Sprawdzane wobec swe_house_pos (tests/oracle)."""
|
||||
lon = norm360(lon)
|
||||
forward = _runs_forward(cusp_list)
|
||||
for i in range(12):
|
||||
start = cusp_list[i]
|
||||
end = cusp_list[(i + 1) % 12]
|
||||
if forward:
|
||||
span, offset = (end - start) % 360.0, (lon - start) % 360.0
|
||||
else:
|
||||
span, offset = (start - end) % 360.0, (start - lon) % 360.0
|
||||
if offset < span:
|
||||
return i + 1
|
||||
return 12
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Lots / punkty arabskie (LOG-08) — 7 Lots hermetycznych.
|
||||
|
||||
Formuła: Lot = C + A − B (od punktu C odmierzamy odległość między A i B).
|
||||
Większość Lots **odwraca się w horoskopach nocnych** (zamiana A↔B) — np.
|
||||
Fortuna: dzień Asc + Mo − Su, noc Asc + Su − Mo.
|
||||
|
||||
Dwa warianty liczenia (notes3):
|
||||
- `degree` (domyślny) — dokładny stopień,
|
||||
- `sign` — liczone całymi znakami (Lot wypada na 0° wyliczonego znaku).
|
||||
|
||||
Kolejność ma znaczenie: Fortuna i Duch liczone są pierwsze, bo pozostałe Lots
|
||||
odwołują się do nich.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.engine.formats import norm360, sign_index
|
||||
|
||||
# (nazwa, C, A, B, odwracalny w nocy)
|
||||
LOT_DEFS: list[tuple[str, str, str, str, bool]] = [
|
||||
("Fortune", "Asc", "Moon", "Sun", True),
|
||||
("Spirit", "Asc", "Sun", "Moon", True),
|
||||
("Eros", "Asc", "Venus", "Spirit", True),
|
||||
("Necessity", "Asc", "Fortune", "Mercury", True),
|
||||
("Courage", "Asc", "Fortune", "Mars", True),
|
||||
("Victory", "Asc", "Jupiter", "Spirit", True),
|
||||
("Nemesis", "Asc", "Fortune", "Saturn", True),
|
||||
]
|
||||
|
||||
METHODS = ("degree", "sign")
|
||||
|
||||
|
||||
def compute_lots(
|
||||
points: dict[str, float], is_day: bool, method: str = "degree"
|
||||
) -> list[dict]:
|
||||
"""points: nazwa → długość natalna (wymagane Asc + planety formuł).
|
||||
|
||||
Zwraca listę {name, longitude, formula} w kolejności definicji.
|
||||
"""
|
||||
if method not in METHODS:
|
||||
raise ValueError(f"nieznana metoda liczenia Lots: {method}")
|
||||
|
||||
vals = dict(points)
|
||||
out: list[dict] = []
|
||||
for name, c, a, b, reversible in LOT_DEFS:
|
||||
first, second = (a, b) if (is_day or not reversible) else (b, a)
|
||||
if any(k not in vals for k in (c, first, second)):
|
||||
continue # brak składnika — pomijamy
|
||||
if method == "sign":
|
||||
idx = (sign_index(vals[c]) + sign_index(vals[first])
|
||||
- sign_index(vals[second])) % 12
|
||||
lon = idx * 30.0
|
||||
else:
|
||||
lon = norm360(vals[c] + vals[first] - vals[second])
|
||||
vals[name] = lon # dostępny dla kolejnych Lots
|
||||
out.append({
|
||||
"name": name,
|
||||
"longitude": lon,
|
||||
"formula": f"{c} + {first} − {second}",
|
||||
})
|
||||
return out
|
||||
@@ -0,0 +1,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,140 @@
|
||||
"""Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja.
|
||||
|
||||
Aspekty głowne (LOG-06) mierzą kąt wzdłuż EKLIPTYKI. Ale dwa ciała mogą być
|
||||
powiązane też inaczej — a te powiązania klasyczna astrologia liczy naprawdę,
|
||||
nie na oko:
|
||||
|
||||
* **Paralela / kontrparalela deklinacji.** Deklinacja to „szerokość" na równiku
|
||||
niebieskim — jak daleko na północ/południe od równika stoi ciało. Dwa ciała na
|
||||
tej samej deklinacji (parallel) działają jak koniunkcja, na przeciwnej
|
||||
(kontrparalela) — jak opozycja, mimo że wzdłuż ekliptyki mogą być gdziekolwiek.
|
||||
Baza interpretacyjna zna to zjawisko pod skrótem „P. Dec.".
|
||||
|
||||
* **Antyscja / kontrantyscja.** Odbicie punktu względem osi przesileń
|
||||
(0° Raka – 0° Koziorożca). Dwa punkty w antyscji są równo odległe od tej osi —
|
||||
„dzielą" tę samą długość dnia. Kontrantyscja to odbicie względem osi
|
||||
równonocy (0° Barana – 0° Wagi).
|
||||
|
||||
Wszystko liczymy na współrzędnych TROPIKALNYCH of-date, bo:
|
||||
- deklinacja jest wielkością fizyczną (równikową), niezależną od wyboru zodiaku;
|
||||
- antyscja jest z definicji tropikalna — jej oś to punkty przesileń, czyli
|
||||
kardynalne punkty zodiaku tropikalnego.
|
||||
Dlatego moduł bierze surowe długości/szerokości z silnika, a nie etykiety po
|
||||
przesunięciu na zodiak syderyczny/draconiczny.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.engine.aspects import LUMINARIES, RIGID_PAIRS, separation
|
||||
from app.engine.formats import norm360
|
||||
from app.engine.zodiac import to_equatorial
|
||||
|
||||
# Orby — konfigurowalne (wymóg LOG-07). Paralele i antyscja są klasycznie CIASNE:
|
||||
# to kontakty „punktowe", więc szeroki orb produkowałby fałszywe trafienia.
|
||||
DECL_ORB = 1.0 # ° deklinacji dla paraleli/kontrparaleli
|
||||
ANTISCIA_ORB = 1.0 # ° długości dla antyscji/kontrantyscji
|
||||
LUMINARY_BONUS = 0.0 # świadomie 0 — te kontakty trzymamy ciasno; do podniesienia w API
|
||||
|
||||
|
||||
def _is_rigid(name_a: str, name_b: str) -> bool:
|
||||
return frozenset({name_a, name_b}) in RIGID_PAIRS
|
||||
|
||||
|
||||
def declination(lon: float, lat: float, eps: float) -> float:
|
||||
"""Deklinacja [−90, 90]° z długości i szerokości ekliptycznej (pełny wzór,
|
||||
z szerokością — istotne dla Księżyca i planet, które schodzą z ekliptyki)."""
|
||||
_, dec = to_equatorial(lon, lat, eps)
|
||||
return dec
|
||||
|
||||
|
||||
def is_out_of_bounds(dec: float, eps: float) -> bool:
|
||||
"""Deklinacja poza zakresem Słońca (|dec| > nachylenie ekliptyki).
|
||||
|
||||
„Out of bounds" — ciało zaszło dalej na północ/południe, niż Słońce kiedykolwiek
|
||||
potrafi. Astrologicznie czytane jako działanie „poza normą", stąd wart odnotowania."""
|
||||
return abs(dec) > eps
|
||||
|
||||
|
||||
def antiscion(lon: float) -> float:
|
||||
"""Odbicie długości względem osi przesileń (0° Raka – 0° Koziorożca)."""
|
||||
return norm360(180.0 - lon)
|
||||
|
||||
|
||||
def contra_antiscion(lon: float) -> float:
|
||||
"""Odbicie długości względem osi równonocy (0° Barana – 0° Wagi)."""
|
||||
return norm360(-lon)
|
||||
|
||||
|
||||
def _allowed(orb: float, name_a: str, name_b: str, bonus: float) -> float:
|
||||
if bonus and (name_a in LUMINARIES or name_b in LUMINARIES):
|
||||
return orb + bonus
|
||||
return orb
|
||||
|
||||
|
||||
def find_declination_aspects(
|
||||
bodies: list[dict], orb: float = DECL_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||
) -> list[dict]:
|
||||
"""bodies: dicty z 'name' i 'declination' (°).
|
||||
|
||||
Zwraca paralele (ta sama deklinacja) i kontrparalele (przeciwna). Pary z
|
||||
RIGID_PAIRS pomijane — np. węzły są z definicji zawsze w kontrparaleli
|
||||
(SN = NN+180 na ekliptyce → deklinacja przeciwna), co nie niesie informacji.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
n = len(bodies)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = bodies[i], bodies[j]
|
||||
if _is_rigid(a["name"], b["name"]):
|
||||
continue
|
||||
da, db = a.get("declination"), b.get("declination")
|
||||
if da is None or db is None:
|
||||
continue
|
||||
da, db = float(da), float(db)
|
||||
allowed = _allowed(orb, a["name"], b["name"], luminary_bonus)
|
||||
parallel_dev = abs(da - db)
|
||||
contra_dev = abs(da + db)
|
||||
# ciało może wpaść tylko w jeden z dwóch — bierzemy ciaśniejszy
|
||||
if parallel_dev <= allowed and parallel_dev <= contra_dev:
|
||||
out.append(_row("parallel", a, b, parallel_dev, allowed, da, db))
|
||||
elif contra_dev <= allowed:
|
||||
out.append(_row("contraparallel", a, b, contra_dev, allowed, da, db))
|
||||
return out
|
||||
|
||||
|
||||
def find_antiscia(
|
||||
bodies: list[dict], orb: float = ANTISCIA_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||
) -> list[dict]:
|
||||
"""bodies: dicty z 'name' i 'decimal' (długość tropikalna of-date, °).
|
||||
|
||||
Zwraca antyscje (odbicie względem osi przesileń) i kontrantyscje (osi równonocy).
|
||||
"""
|
||||
out: list[dict] = []
|
||||
n = len(bodies)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = bodies[i], bodies[j]
|
||||
if _is_rigid(a["name"], b["name"]):
|
||||
continue
|
||||
la, lb = a.get("decimal"), b.get("decimal")
|
||||
if la is None or lb is None:
|
||||
continue
|
||||
la, lb = float(la), float(lb)
|
||||
allowed = _allowed(orb, a["name"], b["name"], luminary_bonus)
|
||||
anti_dev = separation(la, antiscion(lb))
|
||||
contra_dev = separation(la, contra_antiscion(lb))
|
||||
if anti_dev <= allowed and anti_dev <= contra_dev:
|
||||
out.append(_row("antiscion", a, b, anti_dev, allowed))
|
||||
elif contra_dev <= allowed:
|
||||
out.append(_row("contra_antiscion", a, b, contra_dev, allowed))
|
||||
return out
|
||||
|
||||
|
||||
def _row(kind: str, a: dict, b: dict, dev: float, allowed: float,
|
||||
dec_a: float | None = None, dec_b: float | None = None) -> dict:
|
||||
row = {
|
||||
"obj1": a["name"], "obj2": b["name"],
|
||||
"type": kind, "orb": round(dev, 3), "allowed": round(allowed, 3),
|
||||
}
|
||||
if dec_a is not None:
|
||||
row["dec1"], row["dec2"] = round(dec_a, 3), round(dec_b, 3)
|
||||
return row
|
||||
@@ -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,144 @@
|
||||
"""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.
|
||||
"""
|
||||
t = self.ts.from_datetime(moment.when_utc)
|
||||
ramc = norm360(t.gast * 15.0 + moment.lon)
|
||||
return ramc, true_obliquity(t)
|
||||
|
||||
def health(self) -> dict:
|
||||
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
|
||||
|
||||
|
||||
def true_obliquity(t) -> float:
|
||||
"""ε PRAWDZIWE (średnie + nutacja w nachyleniu), w stopniach.
|
||||
|
||||
MUSI być prawdziwe, nie średnie. RAMC liczymy z `t.gast` — czasu gwiazdowego
|
||||
POZORNEGO, mierzonego od równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej
|
||||
samej równonocy ma ε z nutacją; sparowanie GAST z ε średnim miesza dwa układy
|
||||
odniesienia. Kosztowało to ~3,2″ na cuspach domów i było niewidoczne dla
|
||||
frameworka wyroczni, bo ten z założenia podaje to samo ε obu stronom
|
||||
(izoluje samą funkcję domów) — patrz tests/oracle/README.md.
|
||||
|
||||
Nutacja z serii IAU 2000A, czyli z tego samego źródła, którego Skyfield
|
||||
używa do policzenia GAST — dzięki temu oba są spójne z definicji."""
|
||||
from skyfield.nutationlib import iau2000a, mean_obliquity
|
||||
|
||||
mean_arcsec = float(mean_obliquity(t.tdb))
|
||||
d_eps_arcsec = float(iau2000a(t.tt)[1]) * 1e-7 # jednostki 0,1 µas
|
||||
return (mean_arcsec + d_eps_arcsec) / 3600.0
|
||||
@@ -0,0 +1,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,525 @@
|
||||
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
|
||||
|
||||
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
|
||||
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
|
||||
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
|
||||
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
|
||||
zrzut z węzła), miał je w całości.
|
||||
|
||||
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
|
||||
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
|
||||
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
|
||||
podatne na modyfikację".
|
||||
|
||||
**Dwa niezależne klucze**, po jednym na parę rozmówców:
|
||||
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
|
||||
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
|
||||
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
|
||||
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
|
||||
|
||||
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
|
||||
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
|
||||
losowej jednorazówki w jedną stronę nie osłabia drugiej.
|
||||
|
||||
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
|
||||
|
||||
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
|
||||
|
||||
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
|
||||
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
|
||||
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
|
||||
|
||||
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
|
||||
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
|
||||
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
|
||||
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from typing import Iterable, Iterator
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
log = logging.getLogger("astrololo.link")
|
||||
|
||||
MAGIC = b"AL1"
|
||||
VERSION = "v1"
|
||||
NONCE_BYTES = 12
|
||||
KEY_BYTES = 32 # AES-256
|
||||
LENGTH_PREFIX = 4
|
||||
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
|
||||
MAX_SKEW_SECONDS = 300.0
|
||||
|
||||
HEADER_ENC = "X-Astrololo-Enc"
|
||||
HEADER_TS = "X-Astrololo-Enc-Ts"
|
||||
CONTENT_TYPE = "application/vnd.astrololo.enc"
|
||||
|
||||
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
|
||||
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
|
||||
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
|
||||
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
|
||||
# jej klucza nie może otwierać łącza do logiki ani do danych.
|
||||
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
|
||||
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
|
||||
|
||||
REQUEST, RESPONSE = b"req", b"res"
|
||||
|
||||
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
|
||||
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
|
||||
PUBLIC_PATHS = frozenset({"/health"})
|
||||
|
||||
|
||||
class LinkError(Exception):
|
||||
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- klucze
|
||||
|
||||
def parse_key(raw: str) -> bytes:
|
||||
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
|
||||
text = raw.strip()
|
||||
if not text:
|
||||
raise LinkError("pusty klucz łącza")
|
||||
try:
|
||||
key = bytes.fromhex(text)
|
||||
except ValueError:
|
||||
try:
|
||||
key = base64.b64decode(text, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
|
||||
if len(key) != KEY_BYTES:
|
||||
raise LinkError(
|
||||
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
|
||||
f"`openssl rand -hex 32`"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def key_from_env(env_name: str) -> bytes | None:
|
||||
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
|
||||
raw = os.getenv(env_name, "")
|
||||
return parse_key(raw) if raw.strip() else None
|
||||
|
||||
|
||||
def encryption_required() -> bool:
|
||||
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
|
||||
|
||||
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
|
||||
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
|
||||
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
|
||||
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
|
||||
"""
|
||||
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _subkey(link_key: bytes, direction: bytes) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
|
||||
info=b"astrololo/link/" + direction,
|
||||
).derive(link_key)
|
||||
|
||||
|
||||
class Link:
|
||||
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
|
||||
|
||||
def __init__(self, link_key: bytes) -> None:
|
||||
self._by_direction = {
|
||||
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
|
||||
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------- pojedyncza ramka
|
||||
|
||||
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
|
||||
return b"|".join([MAGIC, direction, path.encode("utf-8"),
|
||||
stamp.encode("ascii"), str(seq).encode("ascii")])
|
||||
|
||||
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||
plaintext: bytes) -> bytes:
|
||||
nonce = os.urandom(NONCE_BYTES)
|
||||
sealed = self._by_direction[direction].encrypt(
|
||||
nonce, plaintext, self._aad(direction, path, stamp, seq))
|
||||
return MAGIC + nonce + sealed
|
||||
|
||||
def open(self, direction: bytes, path: str, stamp: str, seq: int,
|
||||
frame: bytes) -> bytes:
|
||||
if not frame.startswith(MAGIC):
|
||||
raise LinkError("ramka bez znacznika protokołu")
|
||||
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",
|
||||
}
|
||||
+606
-1
@@ -4,17 +4,58 @@ W górę: udostępnia API dla warstwy prezentacji.
|
||||
W dół: woła warstwę bazodanową (DataClient).
|
||||
Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
|
||||
"""
|
||||
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import link_crypto, security
|
||||
from app.clients.data_client import DataClient
|
||||
from app.models import QueryRequest, QueryResponse
|
||||
from app.service import QueryService
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
|
||||
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
|
||||
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
|
||||
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
|
||||
app = FastAPI(title="astrololo · warstwa logiczna",
|
||||
docs_url=None, redoc_url=None, openapi_url=None)
|
||||
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 (PRYMARNY — pod kosmogram)
|
||||
house_systems: list[str] | None = None # PRE-05: dodatkowe systemy do porównania obok
|
||||
aspect_orb: float = 8.0 # PRE-06: orb aspektów (konfigurowalny)
|
||||
aspect_luminary_bonus: float = 2.0 # PRE-06: dodatkowy orb dla Słońca/Księżyca
|
||||
aspect_minor: bool = False # PRE-06: dołóż aspekty poboczne (30/45/150°)
|
||||
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
||||
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
|
||||
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
||||
|
||||
|
||||
@app.post("/api/query", response_model=QueryResponse)
|
||||
@@ -25,6 +66,570 @@ def query(req: QueryRequest) -> QueryResponse:
|
||||
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,
|
||||
house_systems=req.house_systems,
|
||||
aspect_orb=req.aspect_orb,
|
||||
aspect_luminary_bonus=req.aspect_luminary_bonus,
|
||||
aspect_minor=req.aspect_minor)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
if req.stations:
|
||||
from app.engine.stations import find_stations
|
||||
|
||||
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
|
||||
|
||||
|
||||
class PersonMoment(BaseModel):
|
||||
when_utc: datetime
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
|
||||
|
||||
class SynastryRequest(BaseModel):
|
||||
"""Dwie osoby (PRE-04). Aspekty liczone MIĘDZY horoskopami, nie w środku."""
|
||||
person_a: PersonMoment
|
||||
person_b: PersonMoment
|
||||
zodiac: str = "tropical"
|
||||
aspect_orb: float = 8.0
|
||||
aspect_luminary_bonus: float = 2.0
|
||||
aspect_minor: bool = False
|
||||
|
||||
|
||||
@app.get("/bases")
|
||||
def bases() -> dict:
|
||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||
|
||||
Przelot do warstwy danych — same METADANE, żadnej treści baz."""
|
||||
from app.clients.data_client import DataClient
|
||||
|
||||
try:
|
||||
return DataClient().bases()
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||
|
||||
|
||||
# ── zarządzanie plikami baz (DAN-27) — czysty przelot ───────────────────
|
||||
# Warstwa logiczna niczego tu nie rozstrzyga: uprawnienia zna PREZENTACJA
|
||||
# (PRE-27), właścicielem plików jest warstwa DANYCH. Ta warstwa tylko przenosi,
|
||||
# bo prezentacja nie ma prawa rozmawiać z danymi wprost.
|
||||
|
||||
class FilesQuery(BaseModel):
|
||||
for_admin: bool = False
|
||||
tenant: str = ""
|
||||
|
||||
|
||||
class FileAction(BaseModel):
|
||||
path: str
|
||||
status: str = ""
|
||||
by: str = ""
|
||||
tenant: str = ""
|
||||
|
||||
|
||||
class FileUpload(BaseModel):
|
||||
filename: str
|
||||
content_b64: str
|
||||
by: str = ""
|
||||
tenant: str = ""
|
||||
|
||||
|
||||
class RulesUpdate(BaseModel):
|
||||
rules: dict
|
||||
tenant: str = ""
|
||||
|
||||
|
||||
def _files_call(fn, *args, tenant: str = "", **kw) -> dict:
|
||||
from app.clients.data_client import DataClient
|
||||
|
||||
try:
|
||||
return fn(DataClient(tenant=tenant), *args, **kw)
|
||||
except httpx.HTTPStatusError as e:
|
||||
# 422 z warstwy danych to ODMOWA MERYTORYCZNA (np. plik nie przeszedł
|
||||
# walidacji), nie awaria — ma dojść do prezentacji jako 422, żeby dało
|
||||
# się pokazać powód zamiast „usługa niedostępna".
|
||||
raise HTTPException(status_code=e.response.status_code,
|
||||
detail=_detail(e)) from e
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||
|
||||
|
||||
def _detail(e: httpx.HTTPStatusError) -> str:
|
||||
try:
|
||||
return str(e.response.json().get("detail") or e)
|
||||
except Exception: # noqa: BLE001
|
||||
return str(e)
|
||||
|
||||
|
||||
@app.post("/files")
|
||||
def files_list(q: FilesQuery) -> dict:
|
||||
return _files_call(lambda c: c.files_list(q.for_admin), tenant=q.tenant)
|
||||
|
||||
|
||||
@app.post("/files/status")
|
||||
def files_status(a: FileAction) -> dict:
|
||||
return _files_call(lambda c: c.files_status(a.path, a.status, a.by), tenant=a.tenant)
|
||||
|
||||
|
||||
@app.post("/files/upload")
|
||||
def files_upload(u: FileUpload) -> dict:
|
||||
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by), tenant=u.tenant)
|
||||
|
||||
|
||||
@app.post("/files/delete")
|
||||
def files_delete(a: FileAction) -> dict:
|
||||
return _files_call(lambda c: c.files_delete(a.path), tenant=a.tenant)
|
||||
|
||||
|
||||
@app.post("/files/rules")
|
||||
def files_rules(u: RulesUpdate) -> dict:
|
||||
return _files_call(lambda c: c.files_rules(u.rules), tenant=u.tenant)
|
||||
|
||||
|
||||
@app.post("/chart/synastry")
|
||||
def chart_synastry(req: SynastryRequest) -> dict:
|
||||
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
|
||||
osoby A vs planeta osoby B). Bez interpretacji z bazy — sama siatka aspektów."""
|
||||
from app.engine import glyphs as GL
|
||||
from app.engine.aspects import find_cross_aspects
|
||||
from app.engine.chart import build_chart
|
||||
from app.engine.models import ChartMoment
|
||||
|
||||
engine = get_engine()
|
||||
a = build_chart(engine, ChartMoment(when_utc=req.person_a.when_utc,
|
||||
lat=req.person_a.lat, lon=req.person_a.lon), zodiac=req.zodiac)
|
||||
b = build_chart(engine, ChartMoment(when_utc=req.person_b.when_utc,
|
||||
lat=req.person_b.lat, lon=req.person_b.lon), zodiac=req.zodiac)
|
||||
cross = find_cross_aspects(
|
||||
a["positions"], b["positions"], orb=req.aspect_orb,
|
||||
luminary_bonus=req.aspect_luminary_bonus, minor=req.aspect_minor)
|
||||
for c in cross:
|
||||
c["glyph"] = GL.aspect_glyph(c["aspect"]) # symbol aspektu (LOG-22)
|
||||
return {"engine": engine.name,
|
||||
"person_a": {"positions": a["positions"]},
|
||||
"person_b": {"positions": b["positions"]},
|
||||
"aspects": cross}
|
||||
|
||||
|
||||
@app.post("/chart/compare")
|
||||
def chart_compare(req: PositionsRequest) -> dict:
|
||||
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
|
||||
|
||||
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
|
||||
# Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie
|
||||
# rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa
|
||||
# prezentacji, bo to ona zna konta.
|
||||
tenant: str = ""
|
||||
|
||||
|
||||
@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(tenant=req.tenant),
|
||||
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
||||
per_object_limit=req.limit, group=req.group,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
||||
return {"engine": engine.name, **report}
|
||||
|
||||
|
||||
class PromptRequest(BaseModel):
|
||||
"""Wejście generatora promptu (LOG-29/30)."""
|
||||
profile: str = "natal" # natal | period
|
||||
when_utc: datetime
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
budget: str = "medium" # concise | medium | extensive | huge | max
|
||||
limit: int = 5000
|
||||
provider: str | None = None # do wyliczenia budżetu „max" wg okna modelu
|
||||
model: str | None = None
|
||||
# tylko dla profilu period:
|
||||
from_date: str | None = None
|
||||
to_date: str | None = None
|
||||
techniques: list[str] | None = None
|
||||
|
||||
|
||||
@app.post("/chart/prompt")
|
||||
def chart_prompt(req: PromptRequest) -> dict:
|
||||
"""Gotowy prompt do LLM z naszych wyliczeń (LOG-29) z budżetowaniem (LOG-30).
|
||||
|
||||
profile=natal → horoskop urodzeniowy (ekran Interpretacje)
|
||||
profile=period → horoskop na wybrany okres (ekran Kalendarz)
|
||||
|
||||
Nie woła żadnego modelu — zwraca sam prompt i statystyki redukcji, żeby dało się
|
||||
go obejrzeć i skopiować. Wysyłkę do modelu doda LOG-31.
|
||||
"""
|
||||
from app.engine.chart import build_chart
|
||||
from app.engine.models import ChartMoment
|
||||
from app.prompt import CHARS_PER_TOKEN, MAX_BUDGET, build_natal_prompt, build_period_prompt
|
||||
|
||||
engine = get_engine()
|
||||
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||
chart = build_chart(engine, moment)
|
||||
label = req.when_utc.strftime("%Y-%m-%d %H:%M UTC")
|
||||
data_error = None
|
||||
|
||||
# Budżet „maksymalny kontekst modelu": limit znaków liczymy z okna kontekstu
|
||||
# WYBRANEGO modelu, zawsze po odjęciu miejsca zarezerwowanego na odpowiedź.
|
||||
budget_chars = None
|
||||
if req.budget == MAX_BUDGET:
|
||||
from app.llm.factory import resolve_model
|
||||
from app.llm.limits import prompt_token_budget
|
||||
# celowo bez build_provider(): budżet zależy TYLKO od okna kontekstu modelu,
|
||||
# więc nie może wymagać skonfigurowanego klucza API
|
||||
provider_name, model_name = resolve_model(req.provider, req.model)
|
||||
budget_chars = int(prompt_token_budget(provider_name, model_name) * CHARS_PER_TOKEN)
|
||||
|
||||
# Warstwa danych dokłada wyłącznie WSKAZANIA. Wyliczenia (horoskop, oś czasu) są
|
||||
# od niej niezależne — gdy padnie, prompt musi zachować wszystko, co policzyliśmy.
|
||||
try:
|
||||
if req.profile == "natal":
|
||||
from app.significators import build_report
|
||||
|
||||
report: dict = {"objects": []}
|
||||
try:
|
||||
report = build_report(
|
||||
chart["positions"], DataClient(),
|
||||
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
||||
per_object_limit=req.limit,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
data_error = f"Warstwa danych niedostępna: {e}"
|
||||
out = build_natal_prompt(chart, report, req.budget, label, budget_chars)
|
||||
|
||||
elif req.profile == "period":
|
||||
if not (req.from_date and req.to_date):
|
||||
raise HTTPException(422, "profile=period wymaga from_date i to_date")
|
||||
from app.engine import houses as H
|
||||
from app.engine.timeline import build_timeline
|
||||
from app.significators import interpret_events
|
||||
|
||||
ramc, eps = engine.sidereal(moment)
|
||||
points = {"Asc": H.compute_asc(ramc, eps, moment.lat), "MC": H.compute_mc(ramc, eps)}
|
||||
for p in engine.positions(moment):
|
||||
points[p.name] = p.longitude
|
||||
events = build_timeline(engine, moment, points, req.from_date, req.to_date,
|
||||
req.techniques)
|
||||
try:
|
||||
interpret_events(events, DataClient())
|
||||
except httpx.HTTPError as e:
|
||||
data_error = f"Warstwa danych niedostępna: {e}" # oś czasu zostaje
|
||||
out = build_period_prompt(chart, events, req.from_date, req.to_date,
|
||||
req.budget, label, budget_chars)
|
||||
else:
|
||||
raise HTTPException(422, f"Nieznany profil: {req.profile!r} (natal | period)")
|
||||
except ValueError as e: # nieznany budżet
|
||||
raise HTTPException(422, str(e))
|
||||
|
||||
out["engine"] = engine.name
|
||||
if data_error:
|
||||
out["data_error"] = data_error
|
||||
return out
|
||||
|
||||
|
||||
class HoroscopeRequest(PromptRequest):
|
||||
"""Jak PromptRequest (niesie już provider i model) + limit wyjścia (LOG-31)."""
|
||||
max_tokens: int | None = None
|
||||
|
||||
|
||||
@app.post("/chart/horoscope")
|
||||
def chart_horoscope(req: HoroscopeRequest) -> dict:
|
||||
"""Napisany horoskop (LOG-31): prompt z LOG-29/30 → model → gotowy tekst.
|
||||
|
||||
Zwraca TAKŻE użyty prompt — również gdy wywołanie modelu padnie — żeby dało się
|
||||
go obejrzeć i użyć ręcznie. `leaves_lan` mówi, czy treść baz opuściła naszą sieć
|
||||
(LOG-32); prezentacja ma na tej podstawie ostrzegać.
|
||||
"""
|
||||
from app.llm.base import LLMError
|
||||
from app.llm.factory import build_provider
|
||||
from app.llm.limits import plan
|
||||
|
||||
out = chart_prompt(req) # ten sam prompt co w podglądzie
|
||||
try:
|
||||
provider = build_provider(req.provider, req.model)
|
||||
|
||||
# Ile tokenów ma naprawdę ten prompt i ile zostaje na odpowiedź. Anthropic
|
||||
# liczy dokładnie (własny endpoint), reszta szacuje — od tego zależy, czy
|
||||
# w oknie kontekstu w ogóle zmieści się miejsce na horoskop.
|
||||
prompt_tokens = provider.count_tokens(out["prompt"])
|
||||
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
||||
out["token_plan"] = budget
|
||||
if budget["warnings"]:
|
||||
out["warnings"] = budget["warnings"]
|
||||
if not budget["fits"]:
|
||||
out["llm_error"] = " ".join(budget["warnings"])
|
||||
return out
|
||||
|
||||
result = provider.generate(out["prompt"], budget["max_output"])
|
||||
except LLMError as e:
|
||||
# prompt zostaje — użytkownik moze go skopiowac i uzyc recznie
|
||||
out["llm_error"] = str(e)
|
||||
return out
|
||||
|
||||
out.update(
|
||||
horoscope=result.text,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
leaves_lan=result.leaves_lan,
|
||||
usage=result.usage,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/chart/horoscope/stream")
|
||||
def chart_horoscope_stream(req: HoroscopeRequest):
|
||||
"""To samo co /chart/horoscope, ale strumieniuje POSTĘP w trakcie pracy.
|
||||
|
||||
Pisanie horoskopu trwa minutami — bez sygnału aplikacja wygląda na zawieszoną.
|
||||
Strumień (NDJSON, jedna linia = jedno zdarzenie) niesie RZECZYWISTE etapy:
|
||||
budowę promptu, limity modelu i każdą turę generowania. Ostatnie zdarzenie
|
||||
(`result`) ma identyczny kształt co odpowiedź zwykłego endpointu.
|
||||
"""
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.progress import stream
|
||||
|
||||
def work(emit) -> dict:
|
||||
from app.llm.base import LLMError
|
||||
from app.llm.factory import build_provider
|
||||
from app.llm.limits import plan
|
||||
|
||||
emit({"type": "stage", "message": "Liczę horoskop i szukam wskazań w bazach…"})
|
||||
out = chart_prompt(req)
|
||||
st = out.get("stats", {})
|
||||
emit({"type": "stage", "message":
|
||||
f"Prompt gotowy: {st.get('chars', 0)} znaków, "
|
||||
f"wskazań {st.get('included', 0)}"
|
||||
+ (f", pominięto {st['omitted']}" if st.get("omitted") else "")})
|
||||
if out.get("data_error"):
|
||||
emit({"type": "warn", "message": out["data_error"]})
|
||||
|
||||
try:
|
||||
provider = build_provider(req.provider, req.model)
|
||||
emit({"type": "stage", "message":
|
||||
f"Dostawca: {provider.name}, model: {provider.model}"
|
||||
+ ("" if not provider.leaves_lan else " — dane opuszczają sieć")})
|
||||
|
||||
emit({"type": "stage", "message": "Liczę tokeny promptu…"})
|
||||
prompt_tokens = provider.count_tokens(out["prompt"])
|
||||
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
||||
out["token_plan"] = budget
|
||||
emit({"type": "stage", "message":
|
||||
f"Prompt {prompt_tokens} tok. · okno modelu {budget['context_window']} · "
|
||||
f"na odpowiedź {budget['max_output']}"})
|
||||
for warning in budget["warnings"]:
|
||||
emit({"type": "warn", "message": warning})
|
||||
if budget["warnings"]:
|
||||
out["warnings"] = budget["warnings"]
|
||||
if not budget["fits"]:
|
||||
out["llm_error"] = " ".join(budget["warnings"])
|
||||
return out
|
||||
|
||||
result = provider.generate(out["prompt"], budget["max_output"], on_event=emit)
|
||||
except LLMError as e:
|
||||
emit({"type": "warn", "message": f"Model zawiódł: {e}"})
|
||||
out["llm_error"] = str(e)
|
||||
return out
|
||||
|
||||
out.update(horoscope=result.text, provider=result.provider, model=result.model,
|
||||
leaves_lan=result.leaves_lan, usage=result.usage)
|
||||
return out
|
||||
|
||||
return StreamingResponse(
|
||||
stream(work),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/llm/models")
|
||||
def llm_models() -> dict:
|
||||
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
||||
|
||||
To nie jest lista zamknięta: pole modelu jest tekstowe, więc można wpisać
|
||||
dowolny identyfikator, do którego konto ma dostęp.
|
||||
"""
|
||||
from app.llm.catalog import catalog
|
||||
from app.llm.factory import _DEFAULT_MODEL
|
||||
|
||||
return {"providers": catalog(), "defaults": dict(_DEFAULT_MODEL)}
|
||||
|
||||
|
||||
@app.get("/llm/health")
|
||||
def llm_health(provider: str | None = None) -> dict:
|
||||
"""Czy model jest osiągalny i skonfigurowany (bez generowania czegokolwiek)."""
|
||||
from app.llm.base import LLMError
|
||||
from app.llm.factory import build_provider
|
||||
|
||||
try:
|
||||
return build_provider(provider).health()
|
||||
except LLMError as e:
|
||||
return {"status": f"blad konfiguracji: {e}"}
|
||||
|
||||
|
||||
class ProfectionsRequest(BaseModel):
|
||||
when_utc: datetime # moment urodzenia (UTC)
|
||||
lat: float = 0.0
|
||||
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")
|
||||
def health() -> dict:
|
||||
info = {"status": "ok", "layer": "logic"}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Strumień postępu długiej operacji (NDJSON).
|
||||
|
||||
Po co: pisanie horoskopu trwa — czasem minuty. Bez sygnału aplikacja wygląda na
|
||||
zawieszoną. Zamiast udawanego paska postępu strumieniujemy **rzeczywiste**
|
||||
zdarzenia z kolejnych etapów, żeby log pokazywał to, co faktycznie się dzieje.
|
||||
|
||||
Dlaczego NDJSON, a nie SSE: `EventSource` w przeglądarce obsługuje wyłącznie GET,
|
||||
a to jest POST z ciałem. Strumień „jedna linia = jeden obiekt JSON" czyta się
|
||||
zwykłym `fetch()` i jest trywialny do sparsowania.
|
||||
|
||||
Dlaczego wątek: właściwa praca (silnik, baza, model) jest synchroniczna. Puszczamy
|
||||
ją w wątku roboczym, a generator odpompowuje kolejkę zdarzeń — dzięki temu
|
||||
zdarzenia docierają w trakcie pracy, a nie dopiero na końcu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import traceback
|
||||
from collections.abc import Iterator
|
||||
from typing import Any, Callable
|
||||
|
||||
_HEARTBEAT_SECONDS = 10.0
|
||||
_DONE = object()
|
||||
|
||||
|
||||
def line(kind: str, message: str, **extra: Any) -> str:
|
||||
return json.dumps({"type": kind, "message": message, **extra}, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def stream(work: Callable[[Callable[[dict], None]], dict]) -> Iterator[str]:
|
||||
"""Uruchamia `work(emit)` w wątku i strumieniuje zdarzenia w czasie rzeczywistym.
|
||||
|
||||
`work` dostaje funkcję `emit(zdarzenie)` i zwraca końcowy wynik, który leci
|
||||
jako ostatnie zdarzenie typu `result`. Wyjątek zamienia się w zdarzenie `error`
|
||||
— połączenie nigdy nie urywa się bez wyjaśnienia.
|
||||
"""
|
||||
events: queue.Queue = queue.Queue()
|
||||
|
||||
def emit(event: dict) -> None:
|
||||
events.put(event)
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
result = work(emit)
|
||||
events.put({"type": "result", "message": "Gotowe.", "result": result})
|
||||
except Exception as e: # noqa: BLE001 — zgłaszamy KAŻDY błąd
|
||||
events.put({
|
||||
"type": "error",
|
||||
"message": f"{type(e).__name__}: {e}",
|
||||
"detail": traceback.format_exc(limit=3),
|
||||
})
|
||||
finally:
|
||||
events.put(_DONE)
|
||||
|
||||
worker = threading.Thread(target=run, daemon=True)
|
||||
worker.start()
|
||||
|
||||
while True:
|
||||
try:
|
||||
event = events.get(timeout=_HEARTBEAT_SECONDS)
|
||||
except queue.Empty:
|
||||
# cisza dłuższa niż heartbeat: dajemy znak życia, żeby pośredniki
|
||||
# (proxy, load balancer) nie uznały połączenia za martwe
|
||||
yield line("ping", "…")
|
||||
continue
|
||||
if event is _DONE:
|
||||
break
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Generator promptów do LLM (LOG-29) + budżetowanie rozmiaru (LOG-30).
|
||||
|
||||
Składa z naszych wyliczeń gotowe, profesjonalne polecenie po polsku:
|
||||
- profil **natal** — horoskop urodzeniowy (na bazie /chart/report),
|
||||
- profil **period** — horoskop na wybrany okres (na bazie /chart/timeline).
|
||||
|
||||
Zasada naczelna promptu: model ma pisać WYŁĄCZNIE na podstawie dostarczonych danych
|
||||
i przy każdej tezie wskazać konkretny sygnifikator, z którego ona wynika. To odróżnia
|
||||
wynik od ogólnikowej wróżby i pozwala go zweryfikować.
|
||||
|
||||
Budżetowanie (LOG-30) — kolejność redukcji:
|
||||
1. deduplikacja (ten sam sygnifikator ORAZ ten sam opis),
|
||||
2. grupowanie identycznych opisów z licznikiem wystąpień,
|
||||
3. sortowanie malejąco wg punktacji siły (LOG-21),
|
||||
4. obcięcie ogona do budżetu — jednostką obcięcia jest CAŁE wskazanie,
|
||||
5. skracanie nadmiernie długich opisów (z jawnym oznaczeniem).
|
||||
Zawsze raportujemy, ile wskazań weszło i ile pominięto — użytkownik ma wiedzieć,
|
||||
czego brakuje, i móc zwiększyć budżet.
|
||||
|
||||
Generowanie jest deterministyczne: ten sam horoskop + ten sam budżet = ten sam prompt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# budżety w znakach (całego promptu); dobrane pod wklejanie do ChatGPT/Claude
|
||||
BUDGETS: dict[str, int] = {
|
||||
"concise": 4_000,
|
||||
"medium": 12_000,
|
||||
"extensive": 30_000,
|
||||
"huge": 120_000,
|
||||
# „maksymalny kontekst modelu" — wyliczany dynamicznie z okna kontekstu
|
||||
# wybranego modelu, ZAWSZE po odjęciu miejsca zarezerwowanego na odpowiedź.
|
||||
# Wartość poniżej jest tylko zapasem, gdy limity modelu są nieznane.
|
||||
"max": 400_000,
|
||||
}
|
||||
DEFAULT_BUDGET = "medium"
|
||||
MAX_BUDGET = "max"
|
||||
|
||||
MAX_EFFECT_CHARS = 320 # dłuższe opisy skracamy (krok 5 redukcji)
|
||||
CHARS_PER_TOKEN = 4.0 # zgrubny szacunek tokenów do podglądu w UI
|
||||
|
||||
DISCLAIMER = (
|
||||
"Zakończ krótką notą: treść jest interpretacją astrologiczną i nie stanowi porady "
|
||||
"medycznej, prawnej ani finansowej."
|
||||
)
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", (text or "").strip().lower())
|
||||
|
||||
|
||||
def est_tokens(text: str) -> int:
|
||||
return int(len(text) / CHARS_PER_TOKEN)
|
||||
|
||||
|
||||
def _shorten(text: str, limit: int = MAX_EFFECT_CHARS) -> tuple[str, bool]:
|
||||
text = (text or "").strip()
|
||||
if len(text) <= limit:
|
||||
return text, False
|
||||
return text[:limit].rstrip() + " […skrócono]", True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- wskazania
|
||||
|
||||
def natal_indications(report: dict) -> list[dict]:
|
||||
"""Płaska lista wskazań z raportu natalnego: obiekt + faseta + opis + waga."""
|
||||
out: list[dict] = []
|
||||
for obj in report.get("objects") or []:
|
||||
name = obj.get("object")
|
||||
for facet in obj.get("facets") or []:
|
||||
score = float(facet.get("score") or 0.0)
|
||||
for s in facet.get("samples") or []:
|
||||
out.append({
|
||||
"context": f"{name} — {facet.get('label')}",
|
||||
"sort_key": (name or "", str(facet.get("label") or "")),
|
||||
"score": score,
|
||||
"significator": s.get("expanded") or s.get("significator") or "",
|
||||
"effect": s.get("effect") or "",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def period_indications(events: list[dict]) -> list[dict]:
|
||||
"""Wskazania z osi czasu — waga rośnie z liczbą trafień w bazie."""
|
||||
out: list[dict] = []
|
||||
for ev in events or []:
|
||||
head = f"{ev.get('technique')} · {ev.get('significator')} · {ev.get('exact')}"
|
||||
total = float(ev.get("interpretations_count") or 0)
|
||||
for s in ev.get("interpretations") or []:
|
||||
out.append({
|
||||
"context": head,
|
||||
"sort_key": (str(ev.get("exact") or ""), str(ev.get("technique") or "")),
|
||||
"score": total,
|
||||
"significator": s.get("expanded") or s.get("significator") or "",
|
||||
"effect": s.get("effect") or "",
|
||||
"date": ev.get("exact"),
|
||||
"start": ev.get("start"),
|
||||
"end": ev.get("end"),
|
||||
"technique": ev.get("technique"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def reduce_indications(items: list[dict], budget_chars: int) -> tuple[list[dict], dict]:
|
||||
"""Dedup → grupowanie → sortowanie wg wagi → obcięcie do budżetu.
|
||||
|
||||
Zwraca (wybrane wskazania, statystyki). Jednostką obcięcia jest całe wskazanie.
|
||||
"""
|
||||
# 1+2: dedup i grupowanie identycznych (kontekst, opis) z licznikiem
|
||||
grouped: dict[tuple[str, str], dict] = {}
|
||||
order: list[tuple[str, str]] = []
|
||||
for it in items:
|
||||
key = (_norm(it["context"]), _norm(it["effect"]))
|
||||
g = grouped.get(key)
|
||||
if g is None:
|
||||
g = {**it, "count": 0, "significators": []}
|
||||
grouped[key] = g
|
||||
order.append(key)
|
||||
g["count"] += 1
|
||||
if it["significator"] not in g["significators"]:
|
||||
g["significators"].append(it["significator"])
|
||||
merged = [grouped[k] for k in order]
|
||||
deduped = len(items) - len(merged)
|
||||
|
||||
# 3: sortowanie malejąco wg wagi; remis rozstrzygany deterministycznie
|
||||
merged.sort(key=lambda g: (-g["score"], -g["count"], g["sort_key"], g["significator"]))
|
||||
|
||||
# 5 (przed obcięciem, bo wpływa na rozmiar): skracanie długich opisów
|
||||
shortened = 0
|
||||
for g in merged:
|
||||
g["effect"], was = _shorten(g["effect"])
|
||||
shortened += 1 if was else 0
|
||||
|
||||
# 4: obcięcie ogona do budżetu — cała pozycja albo nic
|
||||
chosen: list[dict] = []
|
||||
used = 0
|
||||
for g in merged:
|
||||
cost = len(_render_indication(g)) + 1
|
||||
if used + cost > budget_chars and chosen:
|
||||
break
|
||||
chosen.append(g)
|
||||
used += cost
|
||||
|
||||
omitted = len(merged) - len(chosen)
|
||||
stats = {
|
||||
"source_rows": len(items),
|
||||
"after_grouping": len(merged),
|
||||
"deduplicated": deduped,
|
||||
"included": len(chosen),
|
||||
"omitted": omitted,
|
||||
"shortened": shortened,
|
||||
"min_score_included": round(chosen[-1]["score"], 3) if chosen else None,
|
||||
"max_score_omitted": round(merged[len(chosen)]["score"], 3) if omitted else None,
|
||||
}
|
||||
return chosen, stats
|
||||
|
||||
|
||||
def _render_indication(g: dict) -> str:
|
||||
times = f" (×{g['count']})" if g.get("count", 1) > 1 else ""
|
||||
sig = g.get("significator") or ""
|
||||
return f"- {sig} → {g.get('effect')}{times}"
|
||||
|
||||
|
||||
def _render_grouped(chosen: list[dict]) -> str:
|
||||
"""Renderuje wskazania pogrupowane po kontekście, zachowując kolejność wagi."""
|
||||
blocks: list[str] = []
|
||||
seen: dict[str, list[dict]] = {}
|
||||
order: list[str] = []
|
||||
for g in chosen:
|
||||
ctx = g["context"]
|
||||
if ctx not in seen:
|
||||
seen[ctx] = []
|
||||
order.append(ctx)
|
||||
seen[ctx].append(g)
|
||||
for ctx in order:
|
||||
rows = seen[ctx]
|
||||
blocks.append(f"## {ctx} [waga {rows[0]['score']:.2f}]")
|
||||
blocks.extend(_render_indication(g) for g in rows)
|
||||
blocks.append("")
|
||||
return "\n".join(blocks).rstrip()
|
||||
|
||||
|
||||
# ------------------------------------------------------------- sekcje danych
|
||||
|
||||
def _chart_section(chart: dict, moment_label: str | None) -> str:
|
||||
lines: list[str] = ["# DANE HOROSKOPU"]
|
||||
meta = []
|
||||
if moment_label:
|
||||
meta.append(f"moment: {moment_label}")
|
||||
if chart.get("zodiac"):
|
||||
z = chart["zodiac"]
|
||||
if chart.get("ayanamsha") is not None:
|
||||
z += f" (ayanamsa {chart['ayanamsha']:.4f}°)"
|
||||
meta.append(f"zodiak: {z}")
|
||||
if chart.get("house_system"):
|
||||
meta.append(f"system domów: {chart['house_system']}")
|
||||
if chart.get("sect"):
|
||||
meta.append(f"sekta: {'dzienna' if chart['sect'] == 'day' else 'nocna'}")
|
||||
if meta:
|
||||
lines.append(" · ".join(meta))
|
||||
|
||||
# Model interpretuje domy, więc musi wiedzieć, że podział pochodzi z innego
|
||||
# systemu, niż zamówiono — inaczej napisze „Twój Placidus" o Porphyrym.
|
||||
for w in chart.get("house_warnings") or []:
|
||||
lines.append(f"\n{w}")
|
||||
|
||||
if chart.get("positions"):
|
||||
lines.append("\n## Pozycje")
|
||||
for p in chart["positions"]:
|
||||
house = f"dom {p['house']}" if p.get("house") else "—"
|
||||
lines.append(f"{p['name']:<12} {p.get('in_sign',''):<16} {house:<8} {p.get('direction','')}")
|
||||
|
||||
angles = chart.get("angles") or {}
|
||||
if angles:
|
||||
lines.append("\n## Osie")
|
||||
for key in ("Asc", "MC", "Dsc", "IC"):
|
||||
a = angles.get(key)
|
||||
if a:
|
||||
lines.append(f"{a['name']:<5} {a.get('in_sign','')}")
|
||||
|
||||
if chart.get("lots"):
|
||||
lines.append("\n## Lots (punkty arabskie)")
|
||||
for lot in chart["lots"]:
|
||||
lines.append(
|
||||
f"{lot['name']:<10} {lot.get('in_sign',''):<16} dom {lot.get('house','—')}"
|
||||
f" ({lot.get('formula','')})"
|
||||
)
|
||||
|
||||
if chart.get("aspects"):
|
||||
lines.append("\n## Aspekty (orb; A = aplikacyjny, S = separacyjny)")
|
||||
for a in chart["aspects"]:
|
||||
mark = a.get("as") or ""
|
||||
lines.append(
|
||||
f"{a['obj1']} {a['aspect']} {a['obj2']} orb {a.get('orb', 0):.2f}° {mark}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _indications_section(chosen: list[dict], stats: dict) -> str:
|
||||
if not chosen:
|
||||
return (
|
||||
"# WSKAZANIA Z BAZ\n"
|
||||
"(brak trafień w bazach dla tego horoskopu — oprzyj interpretację wyłącznie "
|
||||
"na danych horoskopu powyżej)"
|
||||
)
|
||||
head = [
|
||||
"# WSKAZANIA Z BAZ INTERPRETACYJNYCH",
|
||||
"Wskazania dopasowane do tego horoskopu, uporządkowane od najsilniejszych.",
|
||||
"Zapis: `- sygnifikator → opis (×ile razy wystąpiło w bazach)`.",
|
||||
]
|
||||
if stats.get("omitted"):
|
||||
head.append(
|
||||
f"UWAGA: pokazano {stats['included']} najsilniejszych wskazań, pominięto "
|
||||
f"{stats['omitted']} słabszych (limit długości)."
|
||||
)
|
||||
return "\n".join(head) + "\n\n" + _render_grouped(chosen)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ prompty
|
||||
|
||||
_NATAL_TASK = """# ZADANIE
|
||||
Jesteś doświadczonym astrologiem. Napisz profesjonalną interpretację HOROSKOPU
|
||||
URODZENIOWEGO po polsku, opierając się wyłącznie na danych podanych niżej."""
|
||||
|
||||
_PERIOD_TASK = """# ZADANIE
|
||||
Jesteś doświadczonym astrologiem. Napisz profesjonalną prognozę astrologiczną
|
||||
NA WYBRANY OKRES po polsku, opierając się wyłącznie na danych podanych niżej."""
|
||||
|
||||
_NATAL_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
|
||||
1. Struktura: (a) portret ogólny, (b) temperament i sekta, (c) obszary życia według domów,
|
||||
(d) napięcia i wyzwania, (e) zasoby i mocne strony, (f) zwięzłe podsumowanie.
|
||||
2. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie, np. „(Moon in 12th house)".
|
||||
Teza bez wskazania jest niedopuszczalna.
|
||||
3. Nie dodawaj twierdzeń, których nie da się wywieść z powyższych danych. Nie zmyślaj
|
||||
pozycji, aspektów ani wskazań; nie korzystaj z wiedzy spoza tego promptu.
|
||||
4. Gdy wskazania są sprzeczne, powiedz to wprost i wskaż obie strony, zamiast wybierać jedną.
|
||||
5. Wagę wskazania traktuj jako siłę świadectwa — mocniejsze mają pierwszeństwo w syntezie.
|
||||
6. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
|
||||
|
||||
_PERIOD_OUTPUT = """# JAK MA WYGLĄDAĆ ODPOWIEDŹ
|
||||
1. Uporządkuj prognozę CHRONOLOGICZNIE; przy każdym okresie podaj daty (start / dokładna / koniec).
|
||||
2. Dla każdej daty napisz, która technika ją wyznacza (profekcja, solariusz, dyrekcja solar-arc,
|
||||
Firdaria) i co z niej wynika.
|
||||
3. KAŻDĄ tezę oprzyj na konkretnym wskazaniu i podaj je w nawiasie. Teza bez wskazania jest
|
||||
niedopuszczalna.
|
||||
4. Nie zmyślaj dat ani zdarzeń spoza podanych. Nie korzystaj z wiedzy spoza tego promptu.
|
||||
5. Rozróżniaj okresy o mocnym świadectwie (wysoka waga, kilka technik zbieżnych w czasie)
|
||||
od słabych — i powiedz wprost, które są które.
|
||||
6. Na końcu dodaj krótkie zestawienie: najważniejsze okresy w kolejności ważności.
|
||||
7. Ton rzeczowy i profesjonalny: bez wróżbiarstwa, bez straszenia, bez diagnoz medycznych."""
|
||||
|
||||
|
||||
def _assemble(task: str, chart_sec: str, ind_sec: str, output: str) -> str:
|
||||
return "\n\n".join([task, chart_sec, ind_sec, output, f"# ZASTRZEŻENIE\n{DISCLAIMER}"])
|
||||
|
||||
|
||||
def _budget_chars(budget: str, budget_chars: int | None = None) -> int:
|
||||
"""Limit znaków promptu. `budget_chars` nadpisuje tabelę — używane dla opcji
|
||||
„maksymalny kontekst modelu", gdzie limit zależy od wybranego modelu i musi
|
||||
być policzony po odjęciu miejsca zarezerwowanego na odpowiedź."""
|
||||
if budget not in BUDGETS:
|
||||
raise ValueError(
|
||||
f"Nieznany budżet: {budget!r} (dostępne: {', '.join(BUDGETS)})"
|
||||
)
|
||||
if budget_chars and budget_chars > 0:
|
||||
return budget_chars
|
||||
return BUDGETS[budget]
|
||||
|
||||
|
||||
def _finish(prompt: str, budget: str, limit: int, stats: dict, profile: str) -> dict:
|
||||
stats = {
|
||||
**stats,
|
||||
"profile": profile,
|
||||
"budget": budget,
|
||||
"limit_chars": limit,
|
||||
"chars": len(prompt),
|
||||
"est_tokens": est_tokens(prompt),
|
||||
}
|
||||
return {"profile": profile, "prompt": prompt, "stats": stats}
|
||||
|
||||
|
||||
def build_natal_prompt(chart: dict, report: dict, budget: str = DEFAULT_BUDGET,
|
||||
moment_label: str | None = None,
|
||||
budget_chars: int | None = None) -> dict:
|
||||
"""Prompt na horoskop urodzeniowy (ekran „Interpretacje")."""
|
||||
limit = _budget_chars(budget, budget_chars)
|
||||
chart_sec = _chart_section(chart, moment_label)
|
||||
fixed = len(_NATAL_TASK) + len(chart_sec) + len(_NATAL_OUTPUT) + len(DISCLAIMER) + 200
|
||||
chosen, stats = reduce_indications(natal_indications(report), max(limit - fixed, 500))
|
||||
prompt = _assemble(_NATAL_TASK, chart_sec, _indications_section(chosen, stats), _NATAL_OUTPUT)
|
||||
return _finish(prompt, budget, limit, stats, "natal")
|
||||
|
||||
|
||||
def build_period_prompt(chart: dict, events: list[dict], from_date: str, to_date: str,
|
||||
budget: str = DEFAULT_BUDGET, moment_label: str | None = None,
|
||||
budget_chars: int | None = None) -> dict:
|
||||
"""Prompt na horoskop okresowy (ekran „Kalendarz")."""
|
||||
limit = _budget_chars(budget, budget_chars)
|
||||
chart_sec = _chart_section(chart, moment_label)
|
||||
task = f"{_PERIOD_TASK}\nZakres prognozy: **{from_date} — {to_date}**."
|
||||
|
||||
ev_lines = ["# OŚ CZASU (techniki predykcyjne)",
|
||||
"Zapis: technika · sygnifikator · start → dokładna → koniec."]
|
||||
for ev in events or []:
|
||||
ev_lines.append(
|
||||
f"- {ev.get('technique')} · {ev.get('significator')} · "
|
||||
f"{ev.get('start')} → {ev.get('exact')} → {ev.get('end')}"
|
||||
)
|
||||
events_sec = "\n".join(ev_lines)
|
||||
|
||||
fixed = len(task) + len(chart_sec) + len(events_sec) + len(_PERIOD_OUTPUT) + len(DISCLAIMER) + 200
|
||||
chosen, stats = reduce_indications(period_indications(events), max(limit - fixed, 500))
|
||||
body = events_sec + "\n\n" + _indications_section(chosen, stats)
|
||||
prompt = _assemble(task, chart_sec, body, _PERIOD_OUTPUT)
|
||||
stats["events"] = len(events or [])
|
||||
return _finish(prompt, budget, limit, stats, "period")
|
||||
@@ -0,0 +1,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,283 @@
|
||||
"""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,
|
||||
parallels: list[dict] | None = None,
|
||||
per_object_limit: int = 5000,
|
||||
group: bool = False,
|
||||
) -> dict:
|
||||
"""positions: pozycje z build_chart (name, sign, direction, house).
|
||||
|
||||
Dla każdego obiektu fasety: „w znaku", „w domu", „w aspekcie" (dla każdego
|
||||
aspektu głównego z listy `aspects`) oraz „paralela deklinacji" (dla paraleli
|
||||
z listy `parallels` — LOG-07). Duplikaty (ten sam sygnifikator i opis) są
|
||||
odsiewane wewnątrz każdej fasety.
|
||||
"""
|
||||
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
|
||||
|
||||
# W bazie paralela deklinacji zapisana jest jako fraza „P. Dec." (nie token [).
|
||||
# UWAGA: samo „Dec." w bazie to CZĘSTO dekanat („3rd Dec. of [Gem"), więc
|
||||
# szukamy dokładnie „P. Dec.", inaczej sypnęłoby fałszywymi trafieniami.
|
||||
PARALLEL_MARK = "P. Dec."
|
||||
|
||||
items: list[dict] = []
|
||||
provider = None
|
||||
for p in positions:
|
||||
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,
|
||||
})
|
||||
|
||||
# paralele deklinacji (LOG-07) — baza zna je jako „P. Dec.". Tylko `parallel`:
|
||||
# kontrparaleli i antyscji baza nie opisuje osobnym znacznikiem.
|
||||
for par in (parallels or []):
|
||||
if par.get("type") != "parallel" or name not in (par.get("obj1"), par.get("obj2")):
|
||||
continue
|
||||
other = par["obj2"] if par["obj1"] == name else par["obj1"]
|
||||
if other not in PLANET_ABBR:
|
||||
continue
|
||||
other_tok = "[" + PLANET_ABBR[other]
|
||||
par_samples = _facet_samples(rows, [PARALLEL_MARK, other_tok])
|
||||
if not par_samples: # pokazujemy tylko z trafieniami
|
||||
continue
|
||||
facets.append({
|
||||
"type": "parallel", "label": f"paralela deklinacji z {other}",
|
||||
"token": f"{PARALLEL_MARK} + {other_tok}",
|
||||
"orb": par.get("orb"), "allowed": par.get("allowed"),
|
||||
"count": len(par_samples), "samples": par_samples,
|
||||
})
|
||||
|
||||
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
|
||||
for f in facets:
|
||||
f["score"] = score_facet(f)
|
||||
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
|
||||
httpx>=0.28
|
||||
pydantic>=2.10
|
||||
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
|
||||
skyfield>=1.49
|
||||
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||
cryptography>=44.0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user