Compare commits

..

2 Commits

Author SHA1 Message Date
gitea d4a37aca52 feat(presentation): wyszukiwarka lokalizacji + mapa (OSM/Leaflet)
QoL 2/2: pole „Szukaj miejsca" (nazwa/adres/POI) + interaktywna mapa na
formularzach horoskopu / interpretacji / kalendarza. Bez klucza API.

- geocode.py: proxy OSM/Nominatim PO STRONIE SERWERA (poprawny User-Agent,
  throttling ~1 req/s, cache TTL 1h) — endpointy /geocode i /reverse.
  Wolanie z serwera, nie z przegladarki: latwiej trzymac polityke Nominatim
  i dziala niezaleznie od secure-context (http://<ip>).
- _location_picker.html: wspolny partial (search + wyniki + mapa + atrybucja),
  wpiety includem do 3 formularzy z polami lat/lon.
- geo.js: Leaflet — wyszukiwanie (debounce), klik na wynik ustawia lat/lon i
  centruje mape, klik/drag pineski ustawia wspolrzedne + /reverse pokazuje
  nazwe; sync z „Tu i teraz" (now.js emituje event astrololo:coords).
- Leaflet 1.9.4 vendorowany lokalnie (static/vendor/leaflet, BSD-2-Clause,
  permisywny) — niezaleznosc od CDN; kafelki mapy z OSM. Marker jako divIcon
  (bez plikow PNG).
- styles.css: style pod ciemny motyw.

Zweryfikowane w przegladarce: domyslny widok (pineska na Szpitalu Barlickiego),
wyszukanie „Wawel Krakow" -> lista -> klik ustawia 50.0547/19.9361 i przesuwa
mape, klik w mape ustawia wspolrzedne + reverse wypelnia nazwe. Zero bledow
w konsoli. /geocode i /reverse zwracaja szpital; cache dziala.

Uwaga wdrozeniowa: pod prezentacji potrzebuje egressu do
nominatim.openstreetmap.org; przegladarki — do tile.openstreetmap.org.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:26:03 +02:00
gitea e39c31618d feat(presentation): domyslna lokalizacja = Szpital Barlickiego, Lodz
QoL: formularze (horoskop / interpretacje / kalendarz) maja wstepnie
wpisana lokalizacje urodzenia wlasciciela — Szpital Barlickiego w Lodzi
(51.7739N, 19.4829E; potwierdzone reverse-geokodowaniem OSM: Kopcinskiego
22/28). Nie trzeba jej wpisywac za kazdym razem.

- config.py: jedno zrodlo prawdy (DEFAULT_LAT/LON/LABEL, nadpisywalne ENV)
  + helper default_form().
- main.py: GET wstrzykuje default_form() + location_label do 3 formularzy
  z polami lokalizacji (significators pominiete — nie ma tam lat/lon).
- szablony: dyskretna podpowiedz z nazwa lokalizacji, widoczna tylko na
  czystym formularzu (po POST znika, wygrywa wpisana wartosc).
- „Tu i teraz" nadal nadpisuje domyslne wspolrzedne geolokalizacja.

Zweryfikowane TestClientem: 3 strony renderuja 51.7739/19.4829 + etykiete;
POST z innymi wspolrzednymi je zachowuje i chowa podpowiedz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:12:59 +02:00
242 changed files with 538 additions and 26625 deletions
-33
View File
@@ -1,33 +0,0 @@
# Kontekst builda dla obrazów budowanych z KORZENIA repo (dziś: pomocniczy obraz
# wyroczni w CI — patrz .gitea/workflows/tests.yml). Obrazy usług mają własne
# konteksty (services/<usługa>), więc ten plik ich nie dotyczy.
#
# UWAGA: docker NIE czyta .gitignore. Bez tego pliku do demona poleciałby m.in.
# lokalny wirtualenv (~344 MB) i jądra efemeryd — a runner miał już incydent
# „no space left on device".
# środowiska lokalne
.env/
.venv/
venv/
# cache Pythona i narzędzi
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.ruff_cache/
.mypy_cache/
# dane generowane/pobierane, odtwarzalne
**/.ephemeris/
**/.cache/
# historia i metadane repo
.git/
.gitea/
.claude/
# rzeczy nieużywane w obrazach
docs/
*.md
-45
View File
@@ -1,45 +0,0 @@
name: build-render
# Osobny pipeline dla uslugi render (raport PDF, PRE-24) — celowo ODDZIELONY od
# glownego build.yaml (data/logic/presentation). Obraz dzwiga TeX Live (setki MB),
# wiec budowanie go przy KAZDYM pushu do mastera spowalnialoby kazdy deploy — a to
# wlasnie ta izolacja mial usunac (patrz services/render/app/main.py, LOG-27).
# Buduje sie tylko, gdy zmienia sie sama usluga.
#
# Obraz konsumuje astrololo/render.yaml w repo `deploy`.
on:
push:
branches: [master]
paths:
- 'services/render/**'
- '.gitea/workflows/build-render.yaml'
workflow_dispatch: {} # reczne odpalenie (bootstrap pierwszego obrazu)
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
- name: Build & push render (TeX Live — pierwszy build trwa dluzej)
run: |
TAG=${GITHUB_SHA::8}
IMG=gitea.czernobog.pl/gitea/astrololo-render
# Dockerfile sprawdza obecnosc xelatex + rsvg-convert przy budowie, wiec
# build jest zarazem testem, ze obraz ma komplet narzedzi.
docker build -t $IMG:$TAG -t $IMG:latest ./services/render
docker push $IMG:$TAG
docker push $IMG:latest
echo "Zbudowano i wypchnieto: $IMG:$TAG (+ latest)"
# Sprzątanie ZAWSZE, także po nieudanym budowaniu. Ten obraz dźwiga TeX Live
# i to właśnie na nim runner zatkał się kiedyś na „no space left on device" —
# a po nieudanym buildzie zostaje najwięcej śmieci.
- name: Sprzątanie po budowaniu
if: always()
run: |
docker system df || true
docker system prune -af --filter "until=168h" || true
docker builder prune -f --filter "until=168h" || true
docker system df || true
-32
View File
@@ -1,32 +0,0 @@
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)"
+3 -96
View File
@@ -1,112 +1,19 @@
name: build name: build
# Buduje obrazy usług, które dzielą warstwę logiczną i łącze: data, logic,
# presentation, astrodemo. Render i silnik B mają własne pipeline'y, bo są ciężkie
# i rzadko się zmieniają (build-render.yaml, build-swisseph.yaml).
on: on:
push: push:
branches: [master] branches: [master]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
# Dwa commity, bo porównujemy z poprzednim. Domyślny płytki klon ma
# jeden i `HEAD^` w nim nie istnieje.
fetch-depth: 2
- name: Login - name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
- name: Build & push (data, logic, presentation)
- name: Build & push (tylko usługi, które się zmieniły)
run: | run: |
set -eu
TAG=${GITHUB_SHA::8} TAG=${GITHUB_SHA::8}
USLUGI="data logic presentation astrodemo astroklient" for SVC in data logic presentation; do
# Usługi z własnym pipeline'em — celowo poza tą pętlą. docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
OSOBNE="render engine-swisseph"
# Usługa, której nie ma na żadnej z list, nigdy się nie zbuduje i NIKT
# tego nie zauważy: brak obrazu wygląda potem na problem z rejestrem.
# Ten sam mechanizm już nas kosztował przy liście obserwowanych obrazów
# w image-updaterze. Lepiej zatrzymać budowanie i powiedzieć wprost.
for KATALOG in services/*/; do
NAZWA=$(basename "$KATALOG")
case " $USLUGI $OSOBNE " in
*" $NAZWA "*) ;;
*) echo "BŁĄD: usługa '$NAZWA' nie jest na żadnej liście budowania."
echo "Dopisz ją do USLUGI w tym pliku albo daj jej własny workflow"
echo "(jak render i engine-swisseph), zależnie od tego, czy ma"
echo "powstawać z każdego commita produktu."
exit 1 ;;
esac
done
# Kontekstem budowania każdego obrazu jest WYŁĄCZNIE ./services/<nazwa>,
# więc zmiana poza tym katalogiem nie może wpłynąć na jego zawartość.
# Dzięki temu porównanie ścieżek jest dokładne, a nie przybliżone.
if POPRZEDNI=$(git rev-parse --verify HEAD^ 2>/dev/null); then
ZMIENIONE=$(git diff --name-only "$POPRZEDNI" HEAD)
else
# Pierwszy commit albo przepisana historia — nie ma z czym porównać,
# więc budujemy wszystko. Lepiej zbudować za dużo niż wypuścić obraz
# ze starym kodem pod nowym tagiem.
echo "Brak poprzedniego commita — buduję komplet."
ZMIENIONE=""
fi
# Zmiana w samym pliku workflow dotyczy wszystkich obrazów naraz.
if [ -z "$ZMIENIONE" ] || echo "$ZMIENIONE" | grep -q '^\.gitea/workflows/build\.yaml$'; then
DO_BUDOWY="$USLUGI"
else
DO_BUDOWY=""
for SVC in $USLUGI; do
if echo "$ZMIENIONE" | grep -q "^services/$SVC/"; then
DO_BUDOWY="$DO_BUDOWY $SVC"
fi
done
# astroklient bierze rdzeń z warstwy prezentacji, więc zmiana TAMTEJ
# też go dotyczy. Bez tego jego obraz zostawałby ze starymi ekranami,
# a różnicy nie byłoby widać aż do zgłoszenia użytkownika.
if echo "$ZMIENIONE" | grep -q "^services/presentation/" \
&& ! echo "$DO_BUDOWY" | grep -q "astroklient"; then
DO_BUDOWY="$DO_BUDOWY astroklient"
fi
fi
if [ -z "$(echo "$DO_BUDOWY" | tr -d ' ')" ]; then
echo "Żadna z usług się nie zmieniła — nie ma czego budować."
echo "Wdrożone tagi zostają na poprzednich wersjach, i tak ma być."
exit 0
fi
echo "Buduję:$DO_BUDOWY (tag $TAG)"
for SVC in $DO_BUDOWY; do
# astroklient buduje się z KORZENIA repozytorium, bo jego Dockerfile
# sięga po rdzeń do services/presentation. Pozostałe mają kontekst
# ograniczony do własnego katalogu — i tak ma zostać, bo to właśnie
# ten kontekst gwarantuje, że nie wciągną niczego spoza siebie.
if [ "$SVC" = "astroklient" ]; then
docker build -f services/astroklient/Dockerfile \
-t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG .
else
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
fi
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
done done
echo "Tag: $TAG" echo "Tag: $TAG"
# Sprzątanie ZAWSZE, także po nieudanym budowaniu — to właśnie po awarii
# zostaje najwięcej śmieci, a kolejny przebieg zaczyna od mniejszego zapasu
# miejsca niż poprzedni. Tak zatkał się dysk przy budowaniu rendera.
- name: Sprzątanie po budowaniu
if: always()
run: |
docker system df || true
# `until=168h` zostawia tydzień: warstwy bazowe i cache z ostatnich dni
# przeżywają, więc kolejny build nie zaczyna od zera, a stare znikają.
docker system prune -af --filter "until=168h" || true
docker builder prune -f --filter "until=168h" || true
docker system df || true
-59
View File
@@ -1,59 +0,0 @@
name: Wyrocznia domów — nocny przemiał
# DLACZEGO TU, A NIE JAKO JOB W KLASTRZE
# Pierwotny plan zakładał CronJob w k3s, bo „duży przemiał jest kosztowny".
# Pomiar tego nie potwierdził: 500 000 przypadków × 13 systemów = 70 mln porównań
# w 64 sekundy, skalowanie liniowe. Osobny obraz w rejestrze (który już raz zapchał
# dysk hosta), manifest, CronJob i kopia harnessu poza repo byłyby infrastrukturą
# do problemu, którego nie ma — a każda kopia harnessu poza repo to ryzyko cichego
# rozjazdu z kodem, który ma testować.
#
# Zestaw brzegowy blokuje KAŻDY build (patrz tests.yml). Tutaj chodzi o co innego:
# duża losowa próbka z INNYM ZIARNEM co noc, żeby z czasem przeczesać dziedzinę
# gęściej, niż zrobi to pojedynczy przebieg.
on:
schedule:
- cron: '17 2 * * *' # 02:17 — poza godzinami budowania
workflow_dispatch:
inputs:
count:
description: 'Liczba przypadków'
default: '500000'
seed:
description: 'Ziarno (puste = z daty)'
default: ''
jobs:
sweep:
name: Przemiał losowy (13 systemów)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Obraz wyroczni (silnik B + nasza logika + harness)
run: |
docker build -t astrololo/engine-swisseph:sweep services/engine-swisseph
# Pliki WBUDOWANE, nie montowane: job Gitea Actions sam działa w kontenerze,
# więc `-v $PWD/...` rozwiązałoby się na HOŚCIE (docker cicho tworzy pusty
# katalog i skrypt „znika"). Kontekst builda jest strumieniowany do demona.
docker build -t astrololo/oracle:sweep -f - . <<'DOCKERFILE'
FROM astrololo/engine-swisseph:sweep
COPY services/logic /logic
COPY tests/oracle /oracle
ENV LOGIC_PATH=/logic
DOCKERFILE
- name: Przemiał
run: |
COUNT="${{ inputs.count }}"; COUNT="${COUNT:-500000}"
SEED="${{ inputs.seed }}"; SEED="${SEED:-$(date -u +%Y%m%d)}"
echo "count=$COUNT seed=$SEED"
# Kod wyjścia 1 przy przekroczeniu tolerancji ALBO niezgodności dziedziny,
# więc job czerwieni się sam — bez parsowania tekstu raportu.
docker run --rm astrololo/oracle:sweep \
python /oracle/run.py --mode sweep --count "$COUNT" --seed "$SEED"
# Runner miał już incydent „no space left on device" — sprzątamy zawsze.
- name: Usuń obrazy pomocnicze
if: always()
run: docker rmi -f astrololo/oracle:sweep astrololo/engine-swisseph:sweep 2>/dev/null || true
-293
View File
@@ -1,293 +0,0 @@
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
astroklient-tests:
name: Testy astroklient
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/astroklient/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/astroklient/requirements-dev.txt
# Testy budują drzewo tak samo, jak robi to Dockerfile: rdzeń z warstwy
# prezentacji, minus usun.txt, plus nakładka. Sprawdzają WYNIK złożenia,
# bo tylko on trafia do obrazu.
- name: Testy (pytest)
working-directory: services/astroklient
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
# Krok bez `run` i bez `uses` jest dla Gitei krokiem PUSTYM: zadanie mija
# go i kończy się powodzeniem, nie uruchomiwszy niczego. Tak właśnie
# zniknęły na jeden commit testy astrodemo — blok został rozcięty przy
# dopisywaniu sąsiedniego, a jego `run` przywędrował do cudzego zadania,
# które przez to przechodziło z niewłaściwego powodu.
#
# Zielone CI, które nic nie uruchomiło, jest gorsze od czerwonego.
- name: Kontrola kroków w workflow
run: |
python - <<'KONTROLA'
import pathlib, sys, yaml
grzechy = []
for f in sorted(pathlib.Path(".gitea/workflows").glob("*.y*ml")):
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
for nazwa, job in (d.get("jobs") or {}).items():
kroki = job.get("steps") or []
if not kroki:
grzechy.append(f"{f.name}: zadanie '{nazwa}' nie ma kroków")
for k in kroki:
if "run" not in k and "uses" not in k:
grzechy.append(
f"{f.name}: '{nazwa}' → krok "
f"'{k.get('name','(bez nazwy)')}' nie ma ani run, ani uses")
if grzechy:
print("PUSTE KROKI — zadanie przeszłoby nic nie robiąc:")
print("\n".join(" " + g for g in grzechy))
sys.exit(1)
print("Wszystkie kroki mają co uruchomić.")
KONTROLA
+65
View File
@@ -0,0 +1,65 @@
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
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
-2
View File
@@ -16,8 +16,6 @@ services/logic/.ephemeris/
# Dane wejściowe (duże pliki Excela trzymane poza repo) # Dane wejściowe (duże pliki Excela trzymane poza repo)
services/data/data_files/*.xlsx services/data/data_files/*.xlsx
# Rejestr plików (DAN-27) powstaje przy uruchomieniu — to stan, nie kod.
services/data/data_files/.files-state.json
!services/data/data_files/.gitkeep !services/data/data_files/.gitkeep
# Narzędzia # Narzędzia
-60
View File
@@ -1,60 +0,0 @@
services:
data:
image: gitea.czernobog.pl/gitea/astrololo-data:latest
container_name: astrololo-data
restart: unless-stopped
environment:
DATA_PROVIDER: ${DATA_PROVIDER:-excel}
EXCEL_DIR: /app/data_files
CACHE_DIR: /app/.cache
INDEXED_KEYS: name,id,symbol
volumes:
- ./services/data/data_files:/app/data_files
- data_cache:/app/.cache
# 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:
+6 -12
View File
@@ -9,10 +9,8 @@ services:
volumes: volumes:
- ./services/data/data_files:/app/data_files - ./services/data/data_files:/app/data_files
- data_cache:/app/.cache - data_cache:/app/.cache
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej ports:
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania - "8002:8002"
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
logic: logic:
build: ./services/logic build: ./services/logic
@@ -23,10 +21,8 @@ services:
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003 ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on: depends_on:
- data - data
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej ports:
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania - "8001:8001"
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison": # Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
# docker compose --profile comparison up # docker compose --profile comparison up
@@ -34,10 +30,8 @@ services:
engine-swisseph: engine-swisseph:
build: ./services/engine-swisseph build: ./services/engine-swisseph
profiles: ["comparison"] profiles: ["comparison"]
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej ports:
# compose, a opublikowana na hoście była osiągalna z pominięciem logowania - "8003:8003"
# w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
# `docker compose run --publish`.
presentation: presentation:
build: ./services/presentation build: ./services/presentation
Binary file not shown.
-58
View File
@@ -1,58 +0,0 @@
# Rekordy-pułapki (canary) — instrukcja (DAN-26)
Zabezpieczenie **detekcyjne**: nie zapobiega wyciekowi baz, ale pozwala go
**wykryć** i wskazać **z której kopii** wyciekł. Bazy są kupione i są rdzeniem
produktu — jeśli krążą gdzie indziej, chcemy to udowodnić.
Mechanizm żyje w warstwie danych (`services/data/app/canary.py`) i działa na
wyjściu z `/search`, więc pułapki **nie docierają** ani do użytkownika, ani do
promptu LLM (wymóg LOG-30) — niezależnie od dostawcy (Excel/SQL).
## Jak to działa
1. **Marker** — unikalny ciąg, który nie występuje w realnych danych, wpleciony
w kilka wiarygodnie wyglądających rekordów-pułapek w bazach (np. w polu
znaczącym: `Ve Tau ASTROLOLO-CANARY-7f3a9`).
2. **Odsiewanie** — warstwa danych wykrywa rekord z markerem i usuwa go z wyników,
zanim opuszczą usługę. Interpretacje i prompty są czyste (log `info`).
3. **Tripwire** — jeśli zapytanie **celuje wprost** w marker (ktoś enumeruje bazę,
a nie liczy horoskopu), leci `warning` — sygnał podejrzanego zachowania.
## Konfiguracja (per wdrożenie)
Zmienne środowiskowe usługi `data`:
| Zmienna | Znaczenie |
|---|---|
| `CANARY_MARKERS` | markery oddzielone przecinkami (kilka na wariant) |
| `CANARY_VARIANT` | etykieta wariantu tego wdrożenia (np. `prod-2026`, `partnerX`) |
Bez `CANARY_MARKERS` mechanizm jest **przezroczysty** (zero kosztu). Markery są
sekretem — trzymaj je jak `astrololo-auth` (poza repo GitOps), przez `secretKeyRef`.
## Rejestr wariant → kopia (traitor tracing)
Sedno atrybucji: **każda dystrybuowana kopia baz dostaje inny zestaw pułapek**,
a Ty trzymasz mapę, który wariant trafił dokąd. Gdy zobaczysz pułapkę w cudzej
kopii → sprawdzasz marker w rejestrze → wiesz, skąd wyciekła.
Rejestr trzymaj **poza kodem i repo** (arkusz/menedżer sekretów po stronie ops),
np.:
| Wariant | Markery | Wdrożenie / odbiorca | Data |
|---|---|---|---|
| `prod-2026` | `…7f3a9`, `…b12c` | produkcja czernobog | 2026-08 |
| `partnerX` | `…9de4`, `…0a1b` | kopia dla partnera X | 2026-08 |
## Wstrzyknięcie pułapek do baz (krok właściciela)
To robi właściciel na **realnych** plikach (kod tego nie robi — nie ruszamy
kupionych baz automatycznie): dodać kilka rekordów-pułapek z markerem danego
wariantu, w stylu nieodróżnialnym od prawdziwych wpisów. Kilka, wtopionych —
łatwiej, gdy ktoś zna mechanizm, wyciąć jeden oczywisty niż wszystkie.
## Granice
Canary dowodzi pochodzenia tylko, gdy wyciek **zawiera** treść pułapki (pełna
kopia — tak; parafraza — niekoniecznie). Nie wykrywa retencji *prawdziwej* treści
u dostawcy LLM — od tego jest bramka LOG-32 i wniosek o Zero Data Retention.
-195
View File
@@ -1,195 +0,0 @@
# DAN-25 — zamknięcie dostępu do baz na NFS (TrueNAS SCALE)
Bazy interpretacyjne leżą na `192.168.1.34:/mnt/Tank1/astrololo`. Dziś udział jest
osiągalny z całej sieci, więc **kto ma dostęp do LAN, bierze kompletne bazy
w oryginale — z pominięciem logowania, limitów, audytu i canary**. Żadne
zabezpieczenie w kodzie tego nie zamyka: to najkrótsza droga do wycieku.
Cel: udział `astrololo` widoczny **tylko dla trzech węzłów k3s**, **tylko do
odczytu**, z **root_squash**.
## ⚠️ Zasada nadrzędna: ruszamy WYŁĄCZNIE udział astrololo
Tank1 obsługuje cały homelab — Proxmox (`proxmox-NFS`), conjurera (`conjurer_swap`,
z **zapisem**), media, LXC-e, stację roboczą. **Nie dotykamy globalnych ustawień
usługi NFS ani innych udziałów** — inaczej wywalimy VM-y, bota i bibliotekę mediów.
Każda komenda niżej celuje w jeden konkretny udział.
Druga zasada: **w TrueNAS SCALE nie edytuje się `/etc/exports` ręcznie.** Plik
generuje middleware i nadpisze każdą ręczną zmianę. Wszystko robimy przez `midclt`
(albo GUI: *Shares → Unix (NFS) Shares*).
## Ustalone dane
| Co | Wartość |
|---|---|
| NAS | `192.168.1.34` (TrueNAS SCALE / Community Edition) |
| Udział do zamknięcia | `/mnt/Tank1/astrololo` |
| Węzły k3s (jedyni uprawnieni) | `192.168.1.73` (server), `192.168.1.80` (agent2), `192.168.1.81` (agent1) |
| Kto montuje astrololo | wyłącznie pod `data` w ns `astrololo`, **read-only** |
---
## Faza 0 — rozpoznanie (nic nie zmienia)
```bash
ssh admin@192.168.1.34
```
Wersja systemu (potwierdza, że komendy niżej pasują):
```bash
midclt call system.version
```
Lista udziałów NFS z ich obecnymi ustawieniami — **stąd bierzemy ID udziału astrololo**:
```bash
midclt call sharing.nfs.query | python3 -m json.tool
```
> W wyniku poszukaj wpisu ze ścieżką `/mnt/Tank1/astrololo` i zapamiętaj jego `id`.
> **Sprawdź też, jak nazywają się pola** (`path` vs `paths`, `hosts`, `networks`,
> `ro`, `maproot_user`, `mapall_user`) — middleware zmieniało ich nazwy między
> wersjami SCALE. Dalsze komendy używają nazw z Twojego wyniku.
Kto jest teraz podłączony (żeby nie odciąć czegoś w trakcie pracy):
```bash
ss -tn state established '( sport = :2049 )'
```
## Faza 1 — dowód dziury (zrób PRZED zmianą)
Na **stacji roboczej** (Mac mini, czyli host spoza klastra):
```bash
mkdir -p /tmp/nfs-test && sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
```
```bash
ls -la /tmp/nfs-test | head
```
Jeśli widzisz pliki baz — **to jest dokładnie problem, który zamykamy**. Odmontuj:
```bash
sudo umount /tmp/nfs-test
```
## Faza 2 — kopia obecnej konfiguracji (możliwość cofnięcia)
Na NAS-ie, podstaw `<ID>` z Fazy 0:
```bash
midclt call sharing.nfs.query '[["id","=",<ID>]]' > /root/astrololo-nfs-share.backup.json && cat /root/astrololo-nfs-share.backup.json
```
## Faza 3 — zawężenie udziału
Jedna komenda ustawia wszystkie trzy zabezpieczenia naraz: listę hostów, tylko
odczyt i root_squash. Podstaw `<ID>`:
```bash
midclt call sharing.nfs.update <ID> '{"hosts": ["192.168.1.73", "192.168.1.80", "192.168.1.81"], "ro": true, "maproot_user": null, "maproot_group": null, "mapall_user": null, "mapall_group": null}'
```
Co robi każdy element:
| Ustawienie | Znaczenie |
|---|---|
| `hosts` | eksport **tylko** dla trzech węzłów k3s — reszta LAN przestaje widzieć udział |
| `ro: true` | tylko odczyt; aplikacja i tak montuje read-only, więc niczego nie łamie |
| `maproot_*`, `mapall_*` = `null` | **root_squash**: root z klienta nie jest rootem na udziale |
Zastosuj i sprawdź, że middleware przepisał eksporty:
```bash
midclt call service.reload nfs && exportfs -v | grep -A1 astrololo
```
> Jeśli Twoja wersja nie ma `service.reload`, użyj GUI (*Shares → NFS → zapisz*),
> co wymusi to samo.
## Faza 4 — weryfikacja (wszystkie cztery testy)
**1. Spoza klastra ma NIE działać.** Na Macu:
```bash
sudo mount -t nfs -o ro,vers=3 192.168.1.34:/mnt/Tank1/astrololo /tmp/nfs-test
```
Oczekiwane: `access denied` / `Operation not permitted`. **Jeśli montuje się dalej —
zmiana nie zadziałała, nie idź dalej.**
**2. Z węzła klastra ma działać.**
```bash
ssh 192.168.1.73 'sudo mount -t nfs -o ro 192.168.1.34:/mnt/Tank1/astrololo /mnt/test && ls /mnt/test | head -3 && sudo umount /mnt/test'
```
**3. Aplikacja żyje.** Pody muszą wstać i realnie czytać bazy:
```bash
kubectl -n astrololo rollout restart deploy/data && kubectl -n astrololo rollout status deploy/data
```
```bash
kubectl -n astrololo exec deploy/data -- ls /app/data_files | head -3
```
**4. Reszta homelabu nietknięta** — conjurer (zapis!) i pozostałe udziały:
```bash
kubectl -n conjurer get pods
```
```bash
midclt call sharing.nfs.query | python3 -c "import sys,json;[print(s.get('path') or s.get('paths'), '| hosts:', s.get('hosts'), '| ro:', s.get('ro')) for s in json.load(sys.stdin)]"
```
Oczekiwane: **tylko** astrololo ma zawężone `hosts` i `ro: true`; reszta bez zmian.
## Faza 5 — wycofanie (gdyby coś padło)
```bash
midclt call sharing.nfs.update <ID> '{"hosts": [], "ro": false}'
```
```bash
midclt call service.reload nfs
```
To przywraca poprzedni stan (pełna kopia w `/root/astrololo-nfs-share.backup.json`).
---
## Pułapki, o których warto wiedzieć
**Hookscript Proxmoxa.** Na Proxmoxie działa `wait-truenas.sh`, który przed startem
VM czeka w pętli na `showmount -e 192.168.1.34`. Zawężamy tylko udział astrololo,
więc `showmount` nadal zwróci pozostałe eksporty i pętla przejdzie. Mimo to sprawdź
po zmianie:
```bash
ssh root@pve2 'showmount -e 192.168.1.34'
```
**Aktualizacja baz przestanie działać przez NFS.** Po `ro: true` nikt nie wgra
nowych plików baz przez ten udział — również Ty. Do wgrywania użyj GUI TrueNAS,
SMB albo SSH bezpośrednio na NAS-ie. To celowe: udział ma być drogą tylko do
czytania przez aplikację.
**`hosts` przyjmuje adresy IP, nie nazwy** — świadomie, żeby dostęp nie zależał od
DNS-u (AdGuard na `.57`). Gdyby doszedł czwarty węzeł k3s, trzeba dopisać jego IP,
inaczej pod `data` na nim nie wstanie.
**To nie jest uwierzytelnianie.** Lista IP zatrzymuje przypadkowy i oportunistyczny
dostęp, ale adres da się podszyć w tej samej sieci. Docelowo (poza zakresem tego
kroku): NFSv4 + Kerberos albo przeniesienie plików na wolumen nieosiągalny poza
klastrem — tak mówi samo wymaganie DAN-25.
## Po wykonaniu
Zaktualizuj status DAN-25 w `docs/astrololo_wymagania.xlsx` na **Zrobione** (albo
**W trakcie**, jeśli zostawiasz Kerberosa jako etap docelowy).
-65
View File
@@ -1,65 +0,0 @@
# Konta imienne i dziennik audytowy (PRE-17)
Zamiast jednego wspólnego hasła: **konta imienne**, bo przy bazach o realnej
wartości handlowej trzeba wiedzieć **kto** sięgał do treści — i móc odciąć jedną
osobę bez zmiany hasła całej reszcie.
## Zakładanie konta
Hasło podajesz interaktywnie (nie trafia do historii powłoki ani do listy procesów);
na wyjściu dostajesz **hash**, nie hasło:
```bash
cd services/presentation && python scripts/make_user.py alicja
```
Wynik wklejasz do `APP_USERS` (wpisy po przecinku):
```
APP_USERS='alicja:scrypt$…,bartek:scrypt$…'
```
Hash liczy `scrypt` ze stdlib — **bez nowych zależności**. Sekret ustawiasz jak
resztę (`kubectl create secret …`, `secretKeyRef`), nigdy w repo GitOps.
## Odebranie dostępu jednej osobie
Usuń jej wpis z `APP_USERS` i zrestartuj `presentation`. **Pozostali nie zmieniają
haseł** — to była główna bolączka wspólnego hasła.
## Uwaga: wspólne hasło przestaje działać
Gdy `APP_USERS` jest ustawione, stare `APP_PASSWORD` **nie działa** (aplikacja
zgłasza to ostrzeżeniem przy starcie). Celowo: działające obok kont wspólne hasło
byłoby tylnym wejściem bez śladu w dzienniku, czyli dokładnie problemem, który to
wymaganie zamyka. Po migracji usuń `APP_PASSWORD` z konfiguracji.
Zgodność wstecz: dopóki `APP_USERS` **nie** jest ustawione, `APP_USER`/`APP_PASSWORD`
działa jak dotąd — aktualizacja nie wywraca istniejącego wdrożenia.
## Dziennik audytowy
Każde żądanie do chronionej ścieżki zostawia wpis na stdout (w k8s zbierany
standardowo):
```
2026-08-03 21:45:44 INFO AUDYT user=alicja ip=10.1.2.3 method=POST path=/interpret status=200 records=428 ms=1530
```
| Pole | Znaczenie |
|---|---|
| `user` | kto (`-` przy nieudanym logowaniu — nie podpowiadamy, które konto istnieje) |
| `path`, `method`, `status` | co robił i z jakim skutkiem |
| `records` | **ile rekordów baz** oddaliśmy (`-` gdy żądanie nie dotyka baz) |
| `ms` | czas obsługi |
`records` jest tu najważniejsze: pojedyncze zapytanie wygląda niewinnie, ale suma
pokazuje **powolne wypompowywanie bazy** przez osobę uprawnioną — czego żadne
uwierzytelnienie nie wykryje. Liczone m.in. dla wyszukiwarki sygnifikatorów,
raportu interpretacji i **eksportu do Excela** (ten wynosi najwięcej naraz).
**W dzienniku nie ma treści** — ani rekordów, ani promptów, ani danych
urodzeniowych. Logi byłyby kolejnym nośnikiem wycieku; do wykrycia nadużycia
wystarczą metadane i liczby.
Poziom sterujesz przez `AUDIT_LEVEL` (domyślnie `INFO`).
-168
View File
@@ -1,168 +0,0 @@
# Konta i uprawnienia (PRE-27)
Rozszerzenie kont imiennych z [PRE-17](konta-i-audyt.md): konta zakłada się
**z aplikacji**, a każde dostaje własny zestaw funkcji.
## Dwie zasady, z których wynika reszta
**1. Konto ograniczone widzi program KOMPLETNY — tylko mniejszy.**
Nic nie może zdradzać, że istnieje coś więcej. Żadnych wyszarzonych zakładek,
żadnego „brak uprawnień", żadnego 403 — bo **403 samo w sobie jest informacją**,
że pod tym adresem coś jest. Ścieżka bez uprawnienia odpowiada **404**, tak samo
jak adres, którego nie ma.
Z tej zasady wynikło też wyłączenie `/docs`, `/redoc` i `/openapi.json`.
Automatyczna dokumentacja FastAPI wypisuje komplet tras — czyli spis wszystkich
funkcji programu. Ochrona zakładek nic by nie dała, gdyby obok leżał ich katalog.
(Znalezione testem, nie przeglądem kodu.)
**2. Konto administracyjne pochodzi WYŁĄCZNIE ze środowiska.**
`APP_USER` / `APP_PASSWORD` (albo `APP_USERS`) — jak dotąd. To konto ma wszystkie
uprawnienia i jako jedyne zarządza pozostałymi. **Nie leży w pliku kont**, więc
nie da się go skasować ani ograniczyć z ekranu — nawet przez pomyłkę, nawet
spreparowanym żądaniem. Konto założone w pliku o tym samym loginie **nie
przesłoni** administracyjnego (kolejność sprawdzania jest odwrotna).
## Podział funkcji
**Ekrany** — zakładki widoczne w nawigacji:
| klucz | zakładka |
|---|---|
| `chart` | Horoskop |
| `interpret` | Interpretacje |
| `timeline` | Kalendarz |
| `synastry` | Synastria |
| `significators` | Sygnifikatory |
| `compile` | Skompiluj |
| `settings` | Ustawienia |
**Rozszerzenia** — poziomy złożoności wewnątrz ekranów:
| klucz | co daje |
|---|---|
| `houses_compare` | wybór systemu domów, zestawienie kilku obok siebie, obrót koła |
| `extra_charts` | aspektarian, wykres deklinacji, oś antyscji |
| `advanced_calc` | stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne |
| `ai` | horoskopy pisane przez model językowy (**każde użycie kosztuje**) |
| `export` | pobieranie PDF i Excela |
Konto bez `houses_compare` dostaje horoskop w Whole Sign i **nie widzi**, że
systemów jest trzynaście. Konto bez `ai` nie zobaczy przycisku generowania ani
nie wywoła go z pominięciem interfejsu.
## Gdzie leży granica
W handlerze, nie w szablonie. Ukrycie pola w formularzu chroni przed przypadkiem,
ale nie przed kimś, kto zna nazwy pól — dlatego `_limit_options()` ścina opcje
**po stronie serwera**, a rysunki dodatkowe bez uprawnienia w ogóle nie powstają
(nie ma ich nawet w źródle strony).
Mapa `trasa → uprawnienie` jest **jedna**, w `app/features.py`. Rozproszenie jej
po dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura
jest niewidoczna do chwili, gdy ktoś ją znajdzie. Trasa bez wpisu w mapie wymaga
uprawnień administracyjnych: **przeoczenie ma zamykać, nie otwierać**. Test
przechodzi po wszystkich trasach aplikacji i wymaga, by każda była opisana.
### 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.
-171
View File
@@ -1,171 +0,0 @@
# LOG-33 — sekrety w spoczynku i procedura rotacji
Sekrety (`APP_PASSWORD`/`APP_USERS`, `INTERNAL_TOKEN`, klucze łącz AES, klucze API
do dostawców LLM) trafiają do obiektów Secret w Kubernetesie, gdzie domyślnie są
**tylko zakodowane base64** — jawne dla każdego, kto przeczyta magazyn stanu k3s
albo ma prawo odczytu sekretów w namespace.
Ten dokument opisuje: **macierz rotacji** (co restartować przy zmianie czego),
**procedury rotacji per sekret** i **kroki hartowania**, które wymagają dostępu do
węzła.
---
## Macierz zależności — kto używa którego sekretu
Wyliczona z żywych deploymentów, nie z założeń:
| Sekret / klucz | Usługi, które go czytają | Restart obejmuje |
|---|---|---|
| `astrololo-auth` / **`INTERNAL_TOKEN`** | data, logic, presentation, **render** | **wszystkie cztery, równocześnie** |
| `astrololo-auth` / `APP_PASSWORD`, `APP_USERS` | presentation | tylko presentation |
| `astrololo-link` / `LINK_KEY_LOGIC_DATA` | logic, data | **para**: logic + data |
| `astrololo-link` / `LINK_KEY_PRESENTATION_LOGIC` | presentation, logic | **para**: presentation + logic |
| `astrololo-link` / `LINK_KEY_PRESENTATION_RENDER` | presentation, render | **para**: presentation + render |
| `astrololo-llm` / `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | logic | tylko logic |
> **UWAGA — częsty błąd.** Wcześniejsza wersja tego wymagania mówiła o „wszystkich
> **trzech** usługach" przy `INTERNAL_TOKEN`. To już nieprawda: `render` (PRE-24)
> również go używa. Restart trzech zostawi render ze starym tokenem i **usługa po
> cichu przestanie się dogadywać** — dokładnie ta awaria, przed którą wymaganie
> ostrzega.
Klucze łącz są **parami** — rotacja jednego wymaga restartu tylko dwóch usług, nie
całej czwórki. W trakcie wymiany para chwilowo się nie dogaduje (klucze muszą być
zgodne po obu stronach łącza), dlatego restart obu naraz.
---
## Procedury rotacji
### A. Klucze LLM (najbezpieczniejsze do przećwiczenia)
Dotykają wyłącznie logiki, a awaria jest widoczna od razu i nieszkodliwa
(niedostępna chmura, model lokalny działa dalej). **Zacznij ćwiczenie od tego.**
```bash
read -rs -p "Nowy ANTHROPIC_API_KEY: " NEW; echo
```
```bash
kubectl -n astrololo create secret generic astrololo-llm \
--from-literal=OPENAI_API_KEY="$(kubectl -n astrololo get secret astrololo-llm -o jsonpath='{.data.OPENAI_API_KEY}' | base64 -d)" \
--from-literal=ANTHROPIC_API_KEY="$NEW" \
--dry-run=client -o yaml | kubectl apply -f -
```
```bash
unset NEW && kubectl -n astrololo rollout restart deploy/logic && kubectl -n astrololo rollout status deploy/logic
```
### B. Klucz łącza (para usług)
Przykład dla `LINK_KEY_PRESENTATION_RENDER`. Pozostałe klucze zachowujemy bez zmian,
odczytując je z istniejącego sekretu — inaczej skasowalibyśmy pozostałe łącza.
```bash
kubectl -n astrololo create secret generic astrololo-link \
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
--dry-run=client -o yaml | kubectl apply -f -
```
```bash
kubectl -n astrololo rollout restart deploy/presentation deploy/render
```
```bash
kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
```
### C. `INTERNAL_TOKEN` (wszystkie cztery naraz)
Najbardziej wrażliwa rotacja: w trakcie usługi z różnymi tokenami **odrzucają się
nawzajem**, więc restart musi objąć całą czwórkę.
```bash
kubectl -n astrololo create secret generic astrololo-auth \
--from-literal=APP_PASSWORD="$(kubectl -n astrololo get secret astrololo-auth -o jsonpath='{.data.APP_PASSWORD}' | base64 -d)" \
--from-literal=INTERNAL_TOKEN="$(openssl rand -hex 32)" \
--dry-run=client -o yaml | kubectl apply -f -
```
```bash
kubectl -n astrololo rollout restart deploy/data deploy/logic deploy/presentation deploy/render
```
```bash
kubectl -n astrololo rollout status deploy/data && kubectl -n astrololo rollout status deploy/logic && kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
```
> Jeśli używasz kont imiennych (PRE-17), zamiast `APP_PASSWORD` zachowaj `APP_USERS`
> — patrz [`konta-i-audyt.md`](konta-i-audyt.md).
### Weryfikacja po KAŻDEJ rotacji
Sam `Running` nie wystarczy — pody wstaną nawet, gdy warstwy się nie dogadują.
Trzeba sprawdzić **realny przelot przez wszystkie łącza**:
```bash
kubectl -n astrololo get pods
```
```bash
curl -s -o /dev/null -w "aplikacja: %{http_code}\n" -u "<login>:<hasło>" https://astrololo.czernobog.pl/
```
Policz horoskop w przeglądarce (dotyka presentation→logic→data) i wygeneruj PDF
(dotyka presentation→render). Dopiero to potwierdza, że wszystkie cztery klucze
i token są spójne.
---
## Hartowanie — kroki wymagające dostępu do węzła
### 1. Szyfrowanie sekretów w spoczynku (najważniejsze)
k3s z jednym serwerem trzyma stan w **SQLite**, nie w etcd, więc „szyfrowanie etcd"
sprowadza się do wbudowanej funkcji k3s. Kroki są w sekcji „Co musisz zrobić sam"
poniżej. Efekt: kopia pliku stanu albo snapshot VM przestaje być wyciekiem haseł
i kluczy API.
**Granica:** klucz szyfrujący leży na tym samym serwerze. Chroni przed kradzieżą
pliku/snapshotu — nie przed kimś, kto ma roota na węźle.
### 2. Ograniczenie tokenów kont serwisowych ✅
Zrobione: `automountServiceAccountToken: false` we wszystkich czterech usługach
(deploy #13). Żadna nie rozmawia z API Kubernetesa — sekrety wstrzykuje kubelet,
nie pod — więc token był zbędny, a stanowił gotowy punkt wyjścia do klastra.
### 3. RBAC
Stan sprawdzony: **zero RoleBindings** w `astrololo`, `cluster-admin` tylko dla
`system:masters` i dwóch kont Helma w `kube-system`. Nie ma rozdanych nadmiarowych
uprawnień do cofania.
Realna ekspozycja to **kubeconfig admina**. Sensowny krok: osobny, ograniczony
kubeconfig do codziennej pracy, a admin tylko wtedy, gdy naprawdę potrzebny.
### 4. Sealed Secrets / SOPS — świadomie ODŁOŻONE
Dziś sekrety tworzone są ręcznie i **nie ma ich w repo GitOps** — czyli zasada
„nie wpisywać sekretów do repozytorium" **jest już spełniona**. Kosztem jest
odtwarzalność: po utracie klastra nikt nie wie, co tam było.
Sealed Secrets pozwoliłoby trzymać je w gicie w postaci zaszyfrowanej, ale to
zmiana filozofii i **nowy pojedynczy punkt awarii**: utrata klucza kontrolera =
utrata wszystkich sekretów. Rekomendacja: dopiero po punkcie 1, i tylko jeśli
zależy Ci na odtwarzalności klastra z gita.
---
## Czego NIE robimy
- **Nie wpisujemy sekretów do logów.** Dziennik audytowy (PRE-17) niesie wyłącznie
metadane i liczby.
- **Nie wpisujemy sekretów do repo GitOps.** Manifesty odwołują się do sekretów
przez `secretKeyRef` i celowo nie zawierają wartości.
- **Nie zostawiamy haseł w historii powłoki** — stąd `read -rs` i odczyt istniejących
wartości przez `kubectl … | base64 -d` zamiast wpisywania ich ponownie.
-296
View File
@@ -1,296 +0,0 @@
# Wdrożenie PRE-16 — HTTPS na wejściu i szyfrowanie łączy między warstwami
Instrukcja krok po kroku. **Kolejność ma znaczenie** — punkt „Dlaczego taka
kolejność" niżej tłumaczy, co się stanie, jeśli ją zamienić.
Dotyczy dwóch pull requestów:
| Repo | PR | Co wnosi |
|---|---|---|
| `gitea/astrololo` | [#21](https://gitea.czernobog.pl/gitea/astrololo/pulls/21) | kod: szyfrowanie łączy, limit żądań za proxy |
| `gitea/deploy` | [#4](https://gitea.czernobog.pl/gitea/deploy/pulls/4) | manifesty: Ingress, certyfikat, klucze łączy |
---
## Co się właściwie zmienia
**Na wejściu do aplikacji.** Dotąd logowanie szło przez HTTP Basic po zwykłym
http — czyli hasło leciało siecią w postaci trywialnej do podsłuchania (base64 to
nie szyfrowanie). Po zmianie wejście jest po https, a http odsyła na https.
Przy okazji **odblokowują się dwie funkcje zepsute dziś z tego samego powodu**:
geolokalizacja („Tu i teraz") i kopiowanie promptu do schowka działają wyłącznie
w tzw. secure context i po http po prostu odmawiały.
**Między warstwami.** Prezentacja, logika i dane rozmawiały ze sobą otwartym
tekstem wewnątrz klastra. Token międzywarstwowy mówił *kto* pyta, ale nie ukrywał
*czego dotyczy odpowiedź* — a płyną nią surowe wiersze oryginalnych baz. Teraz
każde ciało żądania i odpowiedzi jest szyfrowane **AES-256-GCM**, osobnym kluczem
na każdą parę rozmówców.
**Wejście na świat pozostaje jedno: prompt do modelu.** Ta zmiana niczego tu nie
rusza — dotyczy wyłącznie ruchu wewnątrz sieci i wejścia z przeglądarki.
---
## Zanim zaczniesz — stan wyjściowy
```bash
kubectl -n astrololo get deploy,svc
kubectl -n astrololo get secret # powinny być: astrololo-auth, gitea-registry
kubectl -n kube-system get svc traefik -o jsonpath='{.status.loadBalancer.ingress[*].ip}'; echo
```
Zanotuj adres Traefika — będzie potrzebny w kroku 3. Sprawdź też, czy działa
aplikacja w obecnej postaci (przez NodePort), żeby mieć punkt odniesienia.
---
## Krok 1 — sekret z kluczami łączy
**Przed czymkolwiek innym.** Klucze muszą istnieć, zanim pody spróbują wstać
z nową konfiguracją, bo bez nich celowo **nie wystartują**.
```bash
kubectl -n astrololo create secret generic astrololo-link \
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(openssl rand -hex 32)" \
--from-literal=LINK_KEY_LOGIC_DATA="$(openssl rand -hex 32)"
```
Kluczy nikt nigdy nie musi oglądać — służą tylko usługom. Nie ma ich w repo
GitOps i **nie ma ich tam wkładać**: cokolwiek trafi do gita, zostaje w historii
na zawsze.
Dwa osobne klucze to nie ozdobnik. Przejęcie klucza prezentacji nie daje dostępu
do warstwy danych, gdzie leżą całe bazy. Logika dostaje oba, bo rozmawia w obie
strony; prezentacja i dane dostają wyłącznie swój.
Sprawdź:
```bash
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
# oczekiwane: dwa klucze, każdy 64 znaki po odkodowaniu (32 bajty)
```
---
## Krok 2 — cert-manager
Jednorazowo, na cały klaster:
```bash
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml
kubectl -n cert-manager rollout status deploy/cert-manager deploy/cert-manager-webhook --timeout=180s
```
Poczekaj, aż **webhook** będzie gotowy — dopóki nie wstanie, tworzenie obiektów
`Certificate` kończy się błędem połączenia i wygląda jak zepsuty manifest.
Sprawdź:
```bash
kubectl get crd | grep cert-manager | head -3 # muszą się pojawić
```
> **Dlaczego własne CA, a nie Let's Encrypt.** Klaster stoi w LAN (Traefik trzyma
> LoadBalancera na adresach 192.168.1.x), więc walidacja HTTP-01 nie ma jak dojść
> z internetu, a DNS-01 wymagałby trzymania w klastrze tokena API do domeny.
> Własne CA nie potrzebuje niczego z zewnątrz i odnawia certyfikaty samo. Cena:
> raz na urządzenie importujesz korzeń (krok 6).
---
## Krok 3 — DNS
Wpis `astrololo.czernobog.pl` → adres Traefika z kroku „stan wyjściowy”.
W routerze, lokalnym DNS-ie albo doraźnie w `/etc/hosts`:
```bash
echo "192.168.1.73 astrololo.czernobog.pl" | sudo tee -a /etc/hosts
```
**To nie jest krok opcjonalny.** Service `presentation` przestaje być NodePortem
(był drugą, nieszyfrowaną drogą do aplikacji — czyli obejściem całego PRE-16),
więc po wdrożeniu manifestów nazwa jest jedynym wejściem. Awaryjnie zawsze zostaje:
```bash
kubectl -n astrololo port-forward svc/presentation 8000:8000 # http://localhost:8000
```
---
## Krok 4 — merge PR-a aplikacji (astrololo #21)
Teraz, **przed** manifestami.
```bash
tea pr merge --login gitea --repo gitea/astrololo 21
```
Po merge'u CI zbuduje obrazy, a image-updater sam podbije tagi w repo `deploy`,
skąd ArgoCD wymieni pody. Poczekaj, aż to się przetoczy:
```bash
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
kubectl -n astrololo get pods -o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}'
```
Na tym etapie **nic się jeszcze nie szyfruje** — nowy kod to potrafi, ale zmienne
z kluczami dokłada dopiero PR do `deploy`. Aplikacja działa dokładnie jak dotąd.
To celowe: chcemy, żeby *cała* obsada podów umiała szyfrować, zanim ktokolwiek
tego zażąda.
---
## Krok 5 — merge PR-a manifestów (deploy #4)
```bash
tea pr merge --login gitea --repo gitea/deploy 4
```
ArgoCD zsynchronizuje się sam (`automated`, `selfHeal`). Wjeżdża naraz: Ingress,
certyfikat, zmienne z kluczami, `TRUST_PROXY` i zdjęcie NodePortu.
```bash
kubectl -n argocd get application astrololo
kubectl -n astrololo rollout status deploy/presentation deploy/logic deploy/data
kubectl -n astrololo get certificate # astrololo-ca i astrololo-tls: READY=True
```
> **Spodziewaj się kilkudziesięciu sekund błędów w trakcie.** Pody wymieniają się
> po kolei, więc przez chwilę stara prezentacja (jeszcze bez klucza) rozmawia
> z nową logiką (już z kluczem) i dostaje odmowę. To zamierzone: alternatywą byłby
> tryb „przyjmuj i szyfrowane, i jawne”, który zwykle zostaje włączony na zawsze.
Merge nie cofnie tagów obrazów — PR dotyka w `kustomization.yaml` wyłącznie listy
`resources`, nie bloku `images`, więc git złoży to z nowszymi tagami z mastera.
---
## Krok 6 — zaufanie do własnego CA (raz na urządzenie)
Bez tego przeglądarka pokaże ostrzeżenie o certyfikacie. Korzeń jest ważny 10 lat,
więc robisz to raz:
```bash
kubectl -n astrololo get secret astrololo-ca -o jsonpath='{.data.ca\.crt}' \
| base64 -d > astrololo-ca.crt
# macOS — do systemowego zaufania (poprosi o hasło administratora)
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain astrololo-ca.crt
# Linux (Debian/Ubuntu)
sudo cp astrololo-ca.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
```
Firefox ma **własny** magazyn certyfikatów — import przez *Ustawienia →
Prywatność i bezpieczeństwo → Wyświetl certyfikaty → Organy certyfikacji*.
---
## Krok 7 — sprawdzenie, że działa to, co miało zadziałać
### Wejście po https
```bash
curl -sI http://astrololo.czernobog.pl/ | head -2 # 301 → https
curl -s -o /dev/null -w "bez hasła: %{http_code}\n" https://astrololo.czernobog.pl/
curl -s -o /dev/null -w "z hasłem: %{http_code}\n" -u astrololo:'<hasło>' https://astrololo.czernobog.pl/
curl -sI -u astrololo:'<hasło>' https://astrololo.czernobog.pl/ | grep -i strict-transport
```
Oczekiwane: **301**, **401**, **200**, nagłówek HSTS obecny. Brak ostrzeżenia
o certyfikacie w przeglądarce oznacza, że krok 6 się udał.
### W przeglądarce
Kliknij **„Tu i teraz"** — powinno pobrać lokalizację (po http odmawiało).
Wygeneruj prompt i kliknij **kopiuj** — schowek powinien zadziałać bez obejść.
### Szyfrowanie łączy — sprawdzenie wprost
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **bez klucza**:
```bash
kubectl -n astrololo exec deploy/presentation -- \
python -c "
import httpx, os
r = httpx.post('http://logic:8001/chart/report',
json={'when_utc':'1984-04-30T09:20:00+00:00','lat':50.06,'lon':19.94},
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
print(r.status_code, r.text[:120])
"
```
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Zwróć uwagę, że
żądanie miało **prawidłowy token** — sam token już nie wystarcza, i o to chodziło.
To samo w dół, do warstwy danych:
```bash
kubectl -n astrololo exec deploy/logic -- \
python -c "
import httpx, os
r = httpx.post('http://data:8002/search',
json={'key':'significator','value':'[Sat','exact':False,'limit':5},
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
print(r.status_code, r.text[:120])
"
```
### Logi startowe
```bash
kubectl -n astrololo logs deploy/logic | grep -i "łącze\|UWAGA"
```
Powinno być `łącze szyfrowane (AES-256-GCM…)`. Jeśli widzisz ostrzeżenie
o rozmowie **jawnym tekstem** — klucz nie doszedł do poda.
---
## Dlaczego taka kolejność
| Kolejność | Skutek zamiany |
|---|---|
| Sekret **przed** manifestami | `LINK_ENCRYPTION_REQUIRED=true` bez klucza celowo wywraca start. Pody wpadną w CrashLoop i będą tak siedzieć do czasu utworzenia sekretu. |
| cert-manager **przed** manifestami | API odrzuci `Certificate`/`Issuer` jako nieznane rodzaje zasobów, ArgoCD pokaże aplikację jako niezsynchronizowaną i sam tego nie naprawi. |
| DNS **przed** manifestami | NodePort znika razem z nimi. Bez wpisu DNS zostaje tylko `port-forward`. |
| Aplikacja **przed** manifestami | Odwrotnie: manifesty włączyłyby szyfrowanie na obrazach, które go nie znają — wszystkie żądania kończyłyby się odmową do czasu przebudowy obrazów. |
Fail-closed w obie strony jest zamierzony. Usługa, która wstała i **po cichu nie
szyfruje**, jest gorsza niż pod w CrashLoop — awarii nie widać, a bazy jadą
otwartym tekstem.
---
## Wycofanie
Manifestów: `git revert` merge'a w `deploy` — ArgoCD samo wróci do NodePortu
i ruchu bez szyfrowania. Kod aplikacji **nie wymaga wycofania**: bez zmiennych
`LINK_KEY_*` moduł przepuszcza ruch jak dotąd (i głośno o tym mówi w logach).
Certyfikat i CA zostają w namespace; usunięcie: `kubectl -n astrololo delete
certificate astrololo-ca astrololo-tls`. cert-managera można zostawić — nie
przeszkadza.
---
## Gdy coś nie gra
| Objaw | Przyczyna | Co zrobić |
|---|---|---|
| Pody w `CrashLoopBackOff`, w logach `LINK_ENCRYPTION_REQUIRED … nie ustawiony` | brak sekretu `astrololo-link` | krok 1, potem `rollout restart` |
| `400 Łącze międzywarstwowe wymaga szyfrowania` przy normalnym korzystaniu | jedna warstwa ma klucz, druga nie (albo trwa rollout) | `rollout status`; sprawdź, czy wszystkie trzy pody mają zmienną |
| `400 Nie udało się odczytać zaszyfrowanego żądania` | klucze po obu stronach łącza są **różne** | wymień sekret i zrestartuj **wszystkie trzy** naraz |
| `Certificate` stoi w `READY=False` | webhook cert-managera jeszcze nie wstał | `kubectl -n cert-manager get pods`, poczekaj i sprawdź `kubectl -n astrololo describe certificate astrololo-tls` |
| Przeglądarka: „połączenie nie jest prywatne” | korzeń CA nieimportowany na tym urządzeniu | krok 6 (pamiętaj, że Firefox ma osobny magazyn) |
| `404` z Traefika pod adresem aplikacji | DNS wskazuje gdzie indziej niż LoadBalancer Traefika | porównaj `dig +short astrololo.czernobog.pl` z adresem z kroku „stan wyjściowy” |
| Limit żądań odcina wszystkich naraz | brak `TRUST_PROXY=true` — cały ruch liczony jako jeden klient | sprawdź zmienną w `deploy/presentation` |
---
## Czego to nie załatwia
- **Szyfrowane są ciała żądań, nie nagłówki.** Ścieżka (`/search`) i token
międzywarstwowy jadą czytelnie. Sam token nikomu nic nie daje — bez klucza łącza
każde żądanie kończy się odmową — ale metadanych to nie ukrywa. Pełne ukrycie
wymagałoby mTLS.
- **Własne CA to nie publiczne zaufanie.** Każde nowe urządzenie wymaga importu
korzenia. Gdyby aplikacja miała kiedyś wyjść na świat, właściwą drogą jest
Let's Encrypt przez DNS-01.
- **NFS z plikami baz** stoi obok aplikacji — kto ma dostęp do share'u, bierze
pliki z pominięciem wszystkich powyższych zabezpieczeń. Do zamknięcia po stronie
infrastruktury (eksport tylko dla IP węzłów, `root_squash`, najlepiej read-only).
- **Sekrety w etcd** są tylko zakodowane base64. Docelowo: szyfrowanie etcd
at-rest albo Sealed Secrets / SOPS.
-139
View File
@@ -1,139 +0,0 @@
# Wdrożenie usługi `render` — raport PDF (PRE-24)
Instrukcja krok po kroku dla nowego komponentu. Dotyczy dwóch repozytoriów:
| Repo | Co wnosi |
|---|---|
| `gitea/astrololo` | usługa `services/render`, klient w prezentacji, przycisk „Pobierz PDF", wariant kosmogramu do druku |
| `gitea/deploy` | `astrololo/render.yaml`, adres i klucz łącza w `presentation.yaml` |
---
## Dlaczego osobna usługa
TeX Live waży setki megabajtów. W obrazie prezentacji spowalniałby każdy build
i deploy, a przy każdej poprawce w CSS trzeba by go ciągnąć od nowa. Osobno:
obraz produktu zostaje mały, TeX aktualizuje się niezależnie, a **awaria renderu
nie kładzie aplikacji** — przestaje działać wyłącznie przycisk „Pobierz PDF".
To ta sama zasada, co przy izolacji silnika swisseph (LOG-27).
---
## Krok 1 — klucz łącza (PRZED wdrożeniem)
Usługa dostaje **cały raport**: dane urodzeniowe i opisy z baz. Łącze jest
szyfrowane AES-256-GCM, własnym, **trzecim** kluczem — przejęcie go nie może
otwierać łącza do logiki ani do danych.
Sekret `astrololo-link` już istnieje (PRE-16); dokładamy do niego trzeci klucz,
**zachowując dwa dotychczasowe**:
```bash
kubectl -n astrololo create secret generic astrololo-link \
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link \
-o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link \
-o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
--dry-run=client -o yaml | kubectl apply -f -
```
Sprawdzenie — mają być **trzy** klucze:
```bash
kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data}' | tr ',' '\n'
```
> **Uwaga:** `LINK_ENCRYPTION_REQUIRED=true` działa fail-closed. Bez klucza pod
> `render` **nie wstanie** — i tak ma być. Usługa, która wstała i po cichu nie
> szyfruje, jest gorsza niż CrashLoop, bo awarii nie widać.
---
## Krok 2 — obraz usługi
Obraz budowany jest osobnym workflow (jak silnik swisseph — nie wchodzi do
głównego pipeline'u produktu). Pierwszy build trwa dłużej, bo ciągnie TeX Live.
```bash
kubectl -n astrololo get deploy render -o jsonpath='{.spec.template.spec.containers[0].image}'; echo
```
Pakiety w obrazie dobrane **wąsko** (nie `texlive-full`, który ma kilka GB):
| Pakiet | Po co |
|---|---|
| `texlive-xetex` | silnik **XeLaTeX** — konieczny, bo raport ma polskie znaki i glify astrologiczne; `pdflatex` ich nie złoży |
| `texlive-latex-recommended` | `geometry`, `graphicx` |
| `fonts-dejavu-core` | jeden font na polskie znaki **i** symbole (♄ ♓ ☉) |
| `librsvg2-bin` | `rsvg-convert` — SVG kosmogramu → PDF (LaTeX nie wstawia SVG wprost) |
Dockerfile sprawdza obecność obu narzędzi **przy budowie**, więc zepsuty obraz
nie dojedzie na produkcję niezauważony.
---
## Krok 3 — merge manifestów
```bash
tea pr merge --login gitea --repo gitea/deploy <numer>
kubectl -n astrololo rollout status deploy/render deploy/presentation
```
Wjeżdża naraz: `Deployment` + `Service` renderu (ClusterIP — **bez** Ingressu
i NodePortu, nie ma powodu sięgać do niej z zewnątrz) oraz `RENDER_URL`
i trzeci klucz w prezentacji.
---
## Krok 4 — sprawdzenie
### Narzędzia są na miejscu
`/health` raportuje obecność `xelatex` i `rsvg-convert`, żeby zepsuty obraz
było widać od razu, a nie dopiero przy pierwszym raporcie:
```bash
kubectl -n astrololo exec deploy/presentation -- \
python -c "import httpx; print(httpx.get('http://render:8004/health').text)"
```
Oczekiwane: `{"status":"ok","layer":"render","tools":{"xelatex":true,"rsvg-convert":true}}`.
Gdy `status` to `degraded` — obraz zbudował się bez któregoś narzędzia.
### Łącze faktycznie szyfruje
Najmocniejszy test to próba obejścia. Z wnętrza klastra, **z poprawnym tokenem,
ale bez szyfrowania**:
```bash
kubectl -n astrololo exec deploy/presentation -- python -c "
import httpx, os
r = httpx.post('http://render:8004/pdf', json={'person':'test'},
headers={'X-Astrololo-Token': os.environ['INTERNAL_TOKEN']})
print(r.status_code, r.text[:80])
"
```
Oczekiwane: **400** i `Łącze międzywarstwowe wymaga szyfrowania.` Sam token już
nie wystarcza — o to chodziło.
### Raport od końca do końca
W przeglądarce: **Skompiluj** → wypełnij dane → *Złóż podsumowanie**Pobierz PDF*.
PDF ma zacząć się od imienia i nazwiska, potem wprowadzone dane, **rysunek
kosmogramu**, a po nim interpretacja natalna i predykcje okresowe.
---
## Czego ta wersja nie załatwia
- **Kompilacja PDF nie została sprawdzona end-to-end** w środowisku, w którym
powstawała — nie było tam ani TeX Live, ani runtime'u kontenerów. Sprawdzone
jest wszystko dookoła: generowanie źródła `.tex` (w tym ucieczka znaków
specjalnych), szyfrowanie łącza, kontrakt API i samowystarczalność SVG.
**Pierwsze uruchomienie na klastrze trzeba obejrzeć.**
- **Dzielenie wyrazów** jest angielskie — nie wciągamy `polyglossia`, żeby nie
puchł obraz. Tekst składa się poprawnie, tylko przenoszenie bywa nieoptymalne.
- **Brak kolejki**. Długi raport blokuje jedno połączenie na czas kompilacji
(timeout klienta: 180 s). Przy większym ruchu warto dołożyć zadania w tle.
- **Glify w tekście od modelu**. Kosmogram idzie jako obraz (rysuje go
`rsvg-convert` z DejaVu), ale gdyby model wplótł symbole w prozę, złoży je
XeLaTeX — też z DejaVu. Jeśli któregoś zabraknie, LaTeX zgłasza
„Missing character" w logu i **nie drukuje znaku**; warto zerknąć w log
pierwszego raportu.
-14
View File
@@ -1,14 +0,0 @@
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"]
-91
View File
@@ -1,91 +0,0 @@
# 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.
@@ -1,79 +0,0 @@
"""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()
-22
View File
@@ -1,22 +0,0 @@
"""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()
-525
View File
@@ -1,525 +0,0 @@
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
zrzut z węzła), miał je w całości.
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
podatne na modyfikację".
**Dwa niezależne klucze**, po jednym na parę rozmówców:
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
losowej jednorazówki w jedną stronę nie osłabia drugiej.
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import struct
import time
from typing import Iterable, Iterator
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
log = logging.getLogger("astrololo.link")
MAGIC = b"AL1"
VERSION = "v1"
NONCE_BYTES = 12
KEY_BYTES = 32 # AES-256
LENGTH_PREFIX = 4
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
MAX_SKEW_SECONDS = 300.0
HEADER_ENC = "X-Astrololo-Enc"
HEADER_TS = "X-Astrololo-Enc-Ts"
CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
# jej klucza nie może otwierać łącza do logiki ani do danych.
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res"
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
PUBLIC_PATHS = frozenset({"/health"})
class LinkError(Exception):
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
# --------------------------------------------------------------------- klucze
def parse_key(raw: str) -> bytes:
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
text = raw.strip()
if not text:
raise LinkError("pusty klucz łącza")
try:
key = bytes.fromhex(text)
except ValueError:
try:
key = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError) as exc:
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
if len(key) != KEY_BYTES:
raise LinkError(
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
f"`openssl rand -hex 32`"
)
return key
def key_from_env(env_name: str) -> bytes | None:
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
raw = os.getenv(env_name, "")
return parse_key(raw) if raw.strip() else None
def encryption_required() -> bool:
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
"""
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
def _subkey(link_key: bytes, direction: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
info=b"astrololo/link/" + direction,
).derive(link_key)
class Link:
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
def __init__(self, link_key: bytes) -> None:
self._by_direction = {
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
}
# ---------------------------------------------------------- pojedyncza ramka
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
return b"|".join([MAGIC, direction, path.encode("utf-8"),
stamp.encode("ascii"), str(seq).encode("ascii")])
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
plaintext: bytes) -> bytes:
nonce = os.urandom(NONCE_BYTES)
sealed = self._by_direction[direction].encrypt(
nonce, plaintext, self._aad(direction, path, stamp, seq))
return MAGIC + nonce + sealed
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika 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")
-255
View File
@@ -1,255 +0,0 @@
"""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"}
-266
View File
@@ -1,266 +0,0 @@
"""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
-190
View File
@@ -1,190 +0,0 @@
"""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": "/"}
-63
View File
@@ -1,63 +0,0 @@
/* 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); }
@@ -1,30 +0,0 @@
<!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>
@@ -1,87 +0,0 @@
{% 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 %}
@@ -1,36 +0,0 @@
<!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>
-2
View File
@@ -1,2 +0,0 @@
-r requirements.txt
pytest>=8.0
-11
View File
@@ -1,11 +0,0 @@
# 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
-301
View File
@@ -1,301 +0,0 @@
"""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}"
@@ -1,192 +0,0 @@
"""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)
-28
View File
@@ -1,28 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY services/astroklient/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Rdzeń wspólny z pełną aplikacją: ekrany astrologiczne, szablony, zasoby.
# Jedno źródło, dwa produkty — inaczej te same 2500 linii szablonów żyłyby
# w dwóch egzemplarzach i rozjechały się w ciągu tygodni, po cichu.
COPY services/presentation/app ./app
# …minus wszystko, czego ten produkt nie ma. Lista jest DANYMI, a nie tekstem
# w tym pliku: czyta ją także test, który pilnuje, żeby zgadzała się ze
# złożeniem w main.py. Rozjazd między nimi znaczyłby albo martwy kod w obrazie,
# albo brakujący plik i błąd dopiero przy uruchomieniu.
COPY services/astroklient/usun.txt .
RUN while read -r SCIEZKA; do \
[ -n "$SCIEZKA" ] || continue; \
test -e "app/$SCIEZKA" || { echo "usun.txt wskazuje nieistniejące: $SCIEZKA"; exit 1; }; \
rm -rf "app/$SCIEZKA"; \
done < usun.txt && rm usun.txt
# Nakładka: własne logowanie, własna pula plików, własny ekran wgrywania,
# własne złożenie. Nadpisuje pliki o tych samych nazwach.
COPY services/astroklient/app ./app
EXPOSE 8006
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8006"]
@@ -1,174 +0,0 @@
"""Klient warstwy logicznej — z PULĄ KONTA (PRE-29).
Każde konto ma własny, niewidoczny dla pozostałych zbiór plików. Pula jedzie
w każdym żądaniu w dół i bierze się WYŁĄCZNIE z kontekstu ustawianego przy
wejściu, nigdy z pola formularza: gdyby przychodziła z żądania, wystarczyłoby
podstawić cudzy login, żeby czytać cudze pliki.
Kontekst zamiast argumentu, bo ekrany są wspólne z pełną aplikacją i wołają
klienta jako obiekt modułowy. Przekazywanie puli przez wszystkie wywołania
znaczyłoby, że pominięcie jednego z nich cicho otwiera dostęp do całego udziału —
a takiego pominięcia nie widać.
"""
from __future__ import annotations
import contextvars
import os
from typing import Any
import httpx
# Login zalogowanego konta na czas obsługi żądania. Ustawia go bramka wejścia.
KONTO: contextvars.ContextVar[str] = contextvars.ContextVar("konto", default="")
from app import link_crypto
from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza prezentacja↔logika. Czytany przy każdym wywołaniu, bo
konfiguracja może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC)
return link_crypto.Link(key) if key else None
class LogicClient:
def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/")
def _post(self, path: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
# Pula konta dokładana do KAŻDEGO żądania w dół. Bierze się z kontekstu
# ustawianego przy wejściu, nigdy z pola formularza — gdyby przychodziła
# z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze pliki.
# Trasy, których pula nie dotyczy, po prostu ignorują nadmiarowe pole.
payload = {**payload, "tenant": KONTO.get("")}
"""Jedyna droga w dół. Celowo JEDNA: dopóki każda metoda składała żądanie
sama, dołożenie nowej znaczyło, że łatwo zapomnieć o tokenie albo kluczu
łącza — i tak się już raz stało (401 wyszedł dopiero na produkcji)."""
with httpx.Client(timeout=timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(),
link=_link())
# ── transport dla modułów opcjonalnych ─────────────────────────────────
# Moduł nie może dopisywać sobie metod do tej klasy: jego nazwy siedziałyby
# wtedy w pliku, który jedzie do KAŻDEGO obrazu. Dostaje więc samą drogę
# w dół, a co nią wysyła, jest jego sprawą. Szyfrowanie łącza i token
# międzywarstwowy obowiązują tak samo — to nie jest obejście ochrony.
def wywolaj(self, sciezka: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
return self._post(sciezka, payload, timeout)
def pobierz(self, sciezka: str, timeout: float | None = None) -> dict[str, Any]:
with httpx.Client(timeout=timeout or settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}{sciezka}",
headers=_auth_headers(), link=_link())
def strumien(self, sciezka: str, payload: dict[str, Any]):
"""Strumień NDJSON w dół — przekazywany dalej ramka po ramce.
Idzie przez szyfrowane łącze jak reszta ruchu (PRE-16): bez tego przy
włączonym LINK_ENCRYPTION_REQUIRED serwer odrzuciłby żądanie."""
with httpx.Client(timeout=httpx.Timeout(None, connect=15.0)) as client:
yield from link_crypto.stream_lines(
client, f"{self.base_url}{sciezka}",
payload=payload, headers=_auth_headers(), link=_link())
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
return self._post("/api/query", payload, settings.http_timeout)
def positions(
self,
when_utc_iso: str,
lat: float,
lon: float,
objects: list[str] | None = None,
house_system: str = "whole_sign",
stations: bool = False,
zodiac: str = "tropical",
tables: bool = False,
house_systems: list[str] | None = None,
aspect_orb: float = 8.0,
aspect_luminary_bonus: float = 2.0,
aspect_minor: bool = False,
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
"when_utc": when_utc_iso,
"lat": lat,
"lon": lon,
"objects": objects,
"house_system": house_system,
"house_systems": house_systems,
"aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus,
"aspect_minor": aspect_minor,
"stations": stations,
"zodiac": zodiac,
"tables": tables,
}
# stacje ORAZ tabele wymagają root-findów / szukania numerycznego — dłuższy timeout
timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout
return self._post("/chart/positions", payload, timeout)
def synastry(
self, person_a: dict, person_b: dict, zodiac: str = "tropical",
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0, aspect_minor: bool = False,
) -> dict[str, Any]:
"""Synastria dwóch osób (PRE-04) — aspekty między horoskopami."""
payload = {
"person_a": person_a, "person_b": person_b, "zodiac": zodiac,
"aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus,
"aspect_minor": aspect_minor,
}
return self._post("/chart/synastry", payload, settings.http_timeout)
def report(
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
) -> dict[str, Any]:
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
return self._post("/chart/report", payload, max(settings.http_timeout, 30.0))
def bases(self) -> dict[str, Any]:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
headers=_auth_headers(), link=_link())
# ── 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_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
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})
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
payload = {**payload, "tenant": KONTO.get("")}
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 timeline(
self, when_utc_iso: str, lat: float, lon: float,
from_date: str, to_date: str, interpret: bool = True,
) -> dict[str, Any]:
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
payload = {
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
"from_date": from_date, "to_date": to_date, "interpret": interpret,
}
return self._post("/chart/timeline", payload, max(settings.http_timeout, 60.0))
@@ -1,75 +0,0 @@
"""Wgrywanie plików: 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. Nie ma listy plików
ani ich stanów: ta usługa ma jeden poziom dostępu, więc nie ma komu ich
pokazywać ani po co.
"""
from __future__ import annotations
import base64
import httpx
from fastapi import File, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from app.features import Feature
from app.podstawa import app, features, logic, templates, _logic_error
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
# tutaj wprost, bo ta usługa tamtej warstwy nie importuje; literówka objawiłaby
# się dopiero na żywo, więc pilnuje jej test.
W_UZYCIU = "active"
@app.get("/pliki", response_class=HTMLResponse)
def wgrywanie_form(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "wgrywanie.html",
{"error": error, "done": done})
@app.post("/pliki/wgraj")
async def wgraj(request: Request, upload: UploadFile = File(...)):
"""Wgranie ORAZ włączenie do użytku.
Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje
zapisany i czeka. Komunikat nie mówi, CZYM się nie nadaje: różnica w treści
byłaby wyrocznią, z której da się odczytać, czego program oczekuje.
"""
raw = await upload.read()
if not raw:
return _wroc(error="Pusty plik.")
kto = getattr(request.state, "user", "") or "?"
try:
out = logic.files_upload(upload.filename or "dane.xlsx",
base64.b64encode(raw).decode("ascii"), by=kto)
except httpx.HTTPError as e:
return _wroc(error=_logic_error(e))
nazwa = out.get("name") or "plik"
if not out.get("accepted"):
return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się "
f"do użycia w tej postaci.")
try:
logic.files_status(out["path"], W_UZYCIU, by=kto)
except httpx.HTTPError as e:
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć.")
return _wroc(done=f"Dodano „{nazwa}”. Plik jest już używany 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"/pliki{'?' + q if q else ''}", status_code=303)
features.zarejestruj(
ekran=Feature("files", "Pliki", "Dodawanie plików używanych przy wyszukiwaniu.",
"Ekrany", "/pliki", kolejnosc=70),
trasy={("GET", "/pliki"): "files", ("POST", "/pliki/wgraj"): "files"},
zasoby={"s-wgrywanie.css": "files"},
)
-34
View File
@@ -1,34 +0,0 @@
"""Astroklient — ZŁOŻENIE produktu.
Pełne funkcje astrologiczne, bez generowania tekstu i bez administracji. Ten plik
nie zawiera żadnej trasy: odpowiada wyłącznie na pytanie, z czego składa się ten
produkt. Import ekranu rejestruje jego trasy, więc lista poniżej JEST definicją.
Poza tą listą nie ma nic. Nie chodzi o funkcje wyłączone: plików, których tu nie
widać, nie ma w obrazie (patrz usun.txt), więc nie istnieje uprawnienie, którym
dałoby się cokolwiek odsłonić — ani nazwa, po której dałoby się zgadnąć, czego
szukać.
"""
from __future__ import annotations
from app import rozszerzenia
from app.podstawa import (_asset_body, _asset_version, _build_utc, _logic_error, app,
logic, static_url, templates)
from app.ekrany import ( # noqa: F401 - import rejestruje trasy
horoskop,
interpretacje,
kalendarz,
logowanie,
lokalizacja,
sygnifikatory,
synastria,
wgrywanie,
)
__all__ = ["app", "logic", "templates", "static_url", "_asset_body", "_asset_version"]
# Most modułów opcjonalnych zostaje CELOWO. W tym obrazie nie ma katalogu
# `app/moduly/`, więc odkrywa zero modułów i wszystkie odpowiedzi są puste —
# aplikacja zachowuje się tak, jakby takiego pojęcia nigdy nie było.
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
buduj_utc=_build_utc, blad=_logic_error)
-8
View File
@@ -1,8 +0,0 @@
"""Nazwa produktu — nakładka astroklienta.
Nadpisuje wersję z rdzenia. Test słownikowy pilnuje, żeby po nadpisaniu nazwa
tamtego produktu nie została nigdzie w szablonach, zasobach ani w odpowiedziach.
"""
from __future__ import annotations
NAZWA = "astroklient"
-384
View File
@@ -1,384 +0,0 @@
"""Logowanie i bramka uprawnień astroklienta.
Konta pochodzą WYŁĄCZNIE z konfiguracji środowiska (`ASTROKLIENT_USERS`), a każde
dostaje ten sam zestaw: wszystko, co ta usługa umie. Konta rozdziela się po to,
żeby każde miało własną, niewidoczną dla pozostałych pulę plików — nie po to, żeby
dawać im różne poziomy dostępu. Poziom jest jeden i nie ma go z czym porównywać.
Reszta jest wspólna z pozostałymi usługami i celowo nietknięta: hasła jako scrypt,
sesja w podpisanym ciasteczku, limit żądań, dziennik audytowy, oraz odmowa jako
404 zamiast 403 — bo 403 samo w sobie mówi, że coś tam jest.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import logging
import os
import secrets
import time
from collections import deque
from dataclasses import dataclass
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
log = logging.getLogger("astrololo.security")
MAX_TRACKED_CLIENTS_DEFAULT = 4096
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
# ── konta ─────────────────────────────────────────────────────────────────
# Wyłącznie z konfiguracji środowiska. Konta rozdziela się po to, żeby każde
# miało własną pulę plików — nie po to, żeby różniły się tym, co widzą.
# ── konta imienne (PRE-17) ───────────────────────────────────────────────
# Parametry scrypt: rozsądny kompromis czas/pamięć dla logowania do aplikacji
# wewnętrznej. Zmiana wymaga przeliczenia haseł (hash niesie tylko sól).
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
_HASH_PREFIX = "scrypt$"
def hash_password(password: str, salt: bytes | None = None) -> str:
"""Hasło → wpis do ASTROKLIENT_USERS: `scrypt$<sól_hex>$<hash_hex>`."""
salt = salt or secrets.token_bytes(16)
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
return f"{_HASH_PREFIX}{salt.hex()}${dk.hex()}"
def accounts() -> dict[str, str]:
"""Konta z `ASTROKLIENT_USERS` — `login:sekret`, po przecinku.
Sekret to hash scrypt (`scrypt$sól$hash`), ten sam format co w pozostałych
usługach — jedna implementacja, więc nie ma czego rozjechać."""
out: dict[str, str] = {}
for entry in os.getenv("ASTROKLIENT_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()
return out
def _secret_matches(spec: str, password: str) -> bool:
"""Sekret konta vs podane hasło. Hash scrypt albo (zgodność wstecz) jawne."""
if spec.startswith(_HASH_PREFIX):
try:
_, salt_hex, hash_hex = spec.split("$", 2)
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex)
except ValueError:
return False
actual = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN)
return hmac.compare_digest(actual, expected)
# Porównujemy BAJTY, nie tekst: `compare_digest` na stringach rzuca TypeError
# przy znakach spoza ASCII, więc hasło z polskimi literami wywracało logowanie
# błędem 500 zamiast zwykłej odmowy (błąd zastany, złapany testem).
return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8"))
def rate_limit_per_min() -> int:
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
def cookies_secure() -> bool:
"""Czy ciasteczko sesji ma być wyłącznie dla https.
Domyślnie TAK. Wyłączamy jedynie w pracy lokalnej po http — inaczej
przeglądarka po prostu nie zapamięta ciasteczka i logowanie „nie działa"
bez żadnego komunikatu, co jest wyjątkowo mylącą awarią."""
return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"}
def trust_proxy() -> bool:
return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"}
# /wyloguj jest „publiczna" tylko technicznie: przechodzi obok bramki, żeby móc
# odpowiedzieć 401 CELOWO, zamiast dostać 200 od zalogowanej sesji. Bez tego
# wylogowanie byłoby niemożliwe — przeglądarka dosyła zapamiętane dane przy każdym
# żądaniu, więc handler i tak widziałby zalogowaną osobę.
# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być
# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła.
# Ekran logowania musi się wyświetlić PRZED zalogowaniem, więc jego arkusz jest
# jedynym publicznym zasobem — i dlatego nie wolno w nim trzymać niczego, co
# nazywa funkcję (patrz features.STATIC).
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/base.css"})
# Pusto CELOWO. Dopóki był tu "/static/", cały katalog omijał bramkę: każdy plik
# JS i arkusz stylów pobierał ktokolwiek, także niezalogowany, pod zgadywalnym
# adresem — a ich treść wymienia ekrany, dostawców modeli i przeznaczenie baz.
# Ruch do zasobów nie trafiał przy tym ani do dziennika, ani pod limit żądań,
# więc wyciek był dla właściciela niewidoczny.
PUBLIC_PREFIXES: tuple[str, ...] = ()
_hits: dict[str, deque[float]] = {}
def auth_enabled() -> bool:
return bool(accounts())
def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
@dataclass(frozen=True)
class Principal:
"""Kto się zalogował i co wolno mu zobaczyć."""
login: str
permissions: frozenset[str]
is_admin: bool
def _uprawnienia() -> frozenset[str]:
"""Każde konto dostaje wszystko, co ta usługa umie — liczone z KATALOGU.
Katalog składa się ze zgłoszeń ekranów obecnych w obrazie, więc ten zbiór
opisuje dokładnie ten produkt, a nie listę wpisaną kiedyś ręcznie. Dopisanie
ekranu nie wymaga pamiętania o niczym; usunięcie go zabiera uprawnienie samo.
Nie ma tu uprawnienia administracyjnego i nie ma go skąd wziąć: `features.ADMIN`
celowo nie należy do GRANTABLE."""
from app import features
return frozenset(features.GRANTABLE)
def _sekret(login: str) -> str | None:
return accounts().get(login)
def verify(login: str, password: str) -> Principal | None:
"""Sprawdza login i hasło. None = odmowa, bez podawania powodu.
Nieznany login i złe hasło dają ten sam wynik: rozróżnienie mówiłoby, które
konta istnieją, a to informacja warta czegoś dla zgadującego."""
spec = _sekret(login)
if spec is None or not _secret_matches(spec, password):
return None
return Principal(login, _uprawnienia(), False)
def issue_session(login: str) -> str:
"""Zawartość ciasteczka dla właśnie zalogowanego konta.
W ciasteczku ląduje ODCISK poświadczenia, dzięki czemu zmiana hasła albo
skasowanie konta unieważnia jego sesje same z siebie — bez pamiętania o tym
i bez żadnego magazynu sesji."""
from app import session
# Wersja sesji zawsze 0: nie ma trwałego magazynu, więc nie ma gdzie trzymać
# licznika pokolenia. Wylogowanie i zmiana hasła i tak działają — pierwsze
# przez skasowanie ciasteczka, drugie przez odcisk, który przestaje pasować.
return session.issue(login, session.fingerprint(_sekret(login) or ""), 0)
def principal(request: Request) -> Principal | None:
"""Ciasteczko → kto to jest. None = nie wpuszczać."""
from app import session
dane = session.read(request.cookies.get(session.COOKIE))
if dane is None:
return None
spec = _sekret(dane["login"])
if spec is None:
return None
# Zmiana hasła w konfiguracji ubija sesje tego konta — to jest tutaj jedyny
# sposób na „wyloguj mnie ze wszystkich urządzeń", bo bez trwałego magazynu
# nie ma gdzie trzymać licznika pokolenia sesji.
if session.fingerprint(spec) != dane["odcisk"]:
return None
return Principal(dane["login"], _uprawnienia(), False)
def client_ip(request: Request) -> str:
"""Adres, po którym rozliczamy limit żądań.
Za odwrotnym proxy (u nas: Ingress/Traefik po włączeniu TLS — PRE-16)
`request.client.host` to adres POD-a proxy, jednakowy dla wszystkich. Bez
poprawki cały ruch trafiałby do jednego wiadra i pierwsza osoba, która
wyklika limit, odcięłaby pozostałe.
Nagłówkom wierzymy WYŁĄCZNIE przy TRUST_PROXY — bo inaczej wystarczyłoby
dopisać własny `X-Forwarded-For`, żeby przy każdym żądaniu wyglądać na kogoś
innego i ominąć limit całkowicie. Z tego samego powodu bierzemy OSTATNI wpis
listy: to jedyny, który dopisał nasz proxy. Wcześniejsze mógł podstawić
klient, więc nie znaczą nic.
"""
peer = request.client.host if request.client else "?"
if not trust_proxy():
return peer
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
last = forwarded.rsplit(",", 1)[-1].strip()
if last:
return last
return request.headers.get("x-real-ip", "").strip() or peer
def _rate_limited(client: str) -> bool:
cap = rate_limit_per_min()
if cap <= 0:
return False
now = time.monotonic()
window = _hits.get(client)
if window is None:
if len(_hits) >= MAX_TRACKED_CLIENTS:
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
window = _hits[client] = deque()
while window and now - window[0] > 60.0:
window.popleft()
if len(window) >= cap:
return True
window.append(now)
return False
# ── dziennik audytowy (PRE-17) ───────────────────────────────────────────
audit_log = logging.getLogger("astrololo.audit")
def _setup_audit_logging() -> None:
"""Gwarantuje, że wpisy audytowe FAKTYCZNIE wychodzą na stdout.
Domyślna konfiguracja uvicorna nie obsługuje naszych loggerów, więc wpisy na
poziomie INFO ginęły — dziennik istniał w kodzie, ale był pusty (sprawdzone
na żywym serwerze). Niewidoczny dziennik jest gorszy niż jego brak: daje
złudzenie kontroli. Własny handler na stdout, bo w k8s to stamtąd zbierane
są logi. `propagate=False` — żeby wpis nie dublował się przez korzeń.
"""
audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper())
if not audit_log.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
audit_log.addHandler(handler)
audit_log.propagate = False
def audit_records(request: Request, count: int) -> None:
"""Handler zgłasza, ILE rekordów baz oddał — trafi do wpisu audytowego.
Sama liczba, nigdy treść. Bez tego dziennik pokazywałby tylko „ktoś wchodził",
a nie „ktoś wyniósł 5000 rekordów" — a to drugie jest sygnałem nadużycia."""
try:
request.state.audit_records = int(count)
except (TypeError, ValueError):
pass
def audit_login(request: Request, login: str, udane: bool) -> None:
"""Zdarzenie logowania w dzienniku (PRE-17).
Nieudane próby są tu ważniejsze od udanych: pojedyncza nic nie znaczy, ale
seria pod jednym adresem to jedyny widoczny ślad zgadywania haseł. Logujemy
LOGIN, nigdy hasła ani jego fragmentu — dziennik to kolejny nośnik wycieku."""
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 _audit(request: Request, user: str, status: int, ms: float) -> None:
records = getattr(request.state, "audit_records", None)
audit_log.info(
"AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f",
user or "-", client_ip(request), request.method, request.url.path,
status, "-" if records is None else records, ms,
)
def install(app) -> None:
"""Podpina ochronę pod wszystkie ścieżki poza publicznymi."""
from app import session
_setup_audit_logging()
# FAIL-CLOSED. Usługa z kontami, ale bez klucza podpisu, nie umiałaby odróżnić
# ważnej sesji od podrobionej. Lepiej, żeby nie wstała, niż żeby wpuszczała —
# i lepiej teraz, przy starcie, niż przy pierwszym logowaniu człowieka.
if auth_enabled() and not session.available():
raise session.SessionError(
"Konta są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza "
"podpisu sesje nie działają, a logowanie nie miałoby czym się bronić. "
"Wygeneruj: openssl rand -hex 32")
if not auth_enabled():
log.warning(
"UWAGA: brak kont (ASTROKLIENT_USERS) — usługa jest OTWARTA dla "
"każdego, kto ma do niej dostęp sieciowy, wraz z treścią plików."
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if _is_public(request.url.path):
return await call_next(request)
started = time.monotonic()
if _rate_limited(client_ip(request)):
_audit(request, "-", 429, (time.monotonic() - started) * 1000)
return JSONResponse(
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
status_code=429, headers={"Retry-After": "60"},
)
from app import features
user, who, odswiez = "", None, None
if auth_enabled():
who = principal(request)
if who is None:
_audit(request, "-", 303, (time.monotonic() - started) * 1000)
# Przekierowanie na ekran logowania, nie 401: 401 z Basic otwierało
# okienko systemowe, którego nie da się ani opisać, ani z niego wyjść.
# Zapamiętujemy, dokąd ktoś zmierzał — po zalogowaniu wraca na miejsce.
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)
user = who.login
# Ruch przedłuża sesję, ale ciasteczko odsyłamy tylko wtedy, gdy
# znacznik zdążył się zestarzeć — inaczej Set-Cookie leciałby przy
# każdym obrazku i arkuszu stylów.
from app import session as _s
dane = _s.read(request.cookies.get(_s.COOKIE))
odswiez = _s.refresh(dane) if dane else None
else:
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
who = Principal("", _uprawnienia(), False)
request.state.user = user
request.state.principal = who
# Pula plików na czas obsługi tego żądania. Ustawiana TU, w jednym
# miejscu, zaraz po ustaleniu kto to jest — klient czyta ją z kontekstu,
# więc żadne wywołanie w dół nie może o niej zapomnieć.
from app.clients.logic_client import KONTO
KONTO.set(user)
# 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest —
# a konto ograniczone ma widzieć program kompletny, tylko mniejszy.
need = features.required(request.method, request.url.path)
if not features.can(who.permissions, need):
_audit(request, user, 404, (time.monotonic() - started) * 1000)
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
response = await call_next(request)
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()))
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
return response
-186
View File
@@ -1,186 +0,0 @@
"""Sesje logowania — podpisane ciasteczko (LOG-34).
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 = "astroklient_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:
"""Parametry 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": "/"}
@@ -1,4 +0,0 @@
/* Arkusz ekranu „files” — dokładany tylko dla konta z tym uprawnieniem. */
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 1rem 1.1rem; margin-top: .4rem; }
@@ -1,23 +0,0 @@
{% extends "base.html" %}
{% set nav_active = "files" %}
{% block title %}Pliki{% endblock %}
{% block styles %}<link rel="stylesheet" href="{{ static('s-wgrywanie.css') }}">
{% endblock %}
{% block content %}
{# Opis czysto operacyjny: co robi wgranie, i nic o tym, do CZEGO te dane służą
ani co się z nimi dzieje dalej. #}
<p class="muted">Wgrany plik jest od razu używany przy wyszukiwaniu.</p>
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Wgraj plik</div>
<form method="post" action="/pliki/wgraj" enctype="multipart/form-data" class="card">
<div class="grid">
<label>Plik <input type="file" name="upload" required></label>
</div>
<div class="actions"><button type="submit">Wgraj</button></div>
</form>
{% endblock %}
@@ -1,2 +0,0 @@
-r requirements.txt
pytest>=8.0
-14
View File
@@ -1,14 +0,0 @@
fastapi>=0.115
uvicorn[standard]>=0.34
httpx>=0.28
jinja2>=3.1
python-multipart>=0.0.20
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
# Strefa czasowa z lokalizacji (PRE-03): współrzędne → strefa IANA (offline, lekki
# wheel Rust), a offset/DST liczy stdlib zoneinfo. tzdata na wypadek slim-obrazu
# bez systemowej bazy stref.
tzfpy>=0.15
tzdata>=2024.1
# Eksport wyników do Excela — „tabela robocza" (DAN-23/PRE-10)
openpyxl>=3.1
-94
View File
@@ -1,94 +0,0 @@
"""Drzewo astroklienta budowane tak samo, jak buduje je Dockerfile.
Ta usługa nie ma własnego kompletu plików: bierze rdzeń z warstwy prezentacji,
usuwa z niego to, czego ten produkt nie ma (usun.txt), i nakłada własne. Testy
muszą patrzeć na WYNIK tego złożenia, a nie na katalog `app/` w repozytorium —
inaczej sprawdzałyby garść plików, a nie produkt.
"""
from __future__ import annotations
import json
import pathlib
import re
import shutil
import subprocess
import sys
import pytest
USLUGA = pathlib.Path(__file__).resolve().parents[1]
RDZEN = USLUGA.parents[0] / "presentation" / "app"
def zloz(cel: pathlib.Path) -> pathlib.Path:
"""Rdzeń minus usun.txt plus nakładka — dokładnie jak w Dockerfile."""
app = cel / "app"
shutil.copytree(RDZEN, app, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
for linia in (USLUGA / "usun.txt").read_text(encoding="utf-8").splitlines():
sciezka = linia.strip()
if not sciezka:
continue
do_usuniecia = app / sciezka
assert do_usuniecia.exists(), f"usun.txt wskazuje nieistniejące: {sciezka}"
shutil.rmtree(do_usuniecia) if do_usuniecia.is_dir() else do_usuniecia.unlink()
shutil.copytree(USLUGA / "app", app, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
return cel
@pytest.fixture(scope="session")
def drzewo(tmp_path_factory) -> pathlib.Path:
return zloz(tmp_path_factory.mktemp("astroklient"))
SONDA = '''
import json, os, hashlib, secrets, re
sol = secrets.token_bytes(16)
dk = hashlib.scrypt(b"tajne", salt=sol, n=2**14, r=8, p=1, dklen=32)
os.environ.update(ASTROKLIENT_USERS="klient:scrypt$%s$%s" % (sol.hex(), dk.hex()),
SESSION_SECRET="t"*64, COOKIES_INSECURE="1", RATE_LIMIT_PER_MIN="0")
from starlette.testclient import TestClient
from app.main import app
from app import security, session, features
from app.clients import logic_client
c = TestClient(app, follow_redirects=False)
H = {"Cookie": f"{session.COOKIE}={security.issue_session('klient')}"}
strona = c.get("/", headers=H).text
zebrane = []
logic_client.link_crypto.call_json = lambda *a, **kw: (zebrane.append(kw.get("payload")), {})[1]
c.post("/interpret", headers=H, data={"date":"1984-04-30","time":"11:20",
"tz_offset":"2","lat":"50","lon":"19","tenant":"ktos-inny"})
print("WYNIK:" + json.dumps({
"ekrany": [f.label for f in features.SCREENS],
"uprawnienia": sorted(security._uprawnienia()),
"dziala": {a: c.get(a, headers=H).status_code
for a in ("/", "/interpret", "/timeline", "/synastry",
"/significators", "/pliki")},
"nie_ma": {a: c.get(a, headers=H).status_code
for a in ("/compile", "/settings", "/accounts", "/files",
"/horoscope/stream", "/compile/pdf")},
"nawigacja": re.findall(r"<nav>(.*?)</nav>", strona, re.S)[0].count('<a href="'),
"pula": (zebrane[0] or {}).get("tenant"),
"nazwa_w_naglowku": (re.search(r"<h1>(.*?)</h1>", strona) or [None, "?"])[1],
"nazwa_w_tytule": (re.search(r"<title>(.*?) ·", strona) or [None, "?"])[1],
"rdzen_w_tresci": "astrololo" in strona.lower(),
"dokumentacja": {a: c.get(a, headers=H).status_code
for a in ("/docs", "/openapi.json", "/redoc")},
"logowanie_dziala": bool(security.verify("klient", "tajne")),
"zle_haslo_odmowa": security.verify("klient", "inne") is None,
}))
'''
@pytest.fixture(scope="module")
def wynik(drzewo):
"""Aplikacja uruchomiona w OSOBNYM PROCESIE.
Importy Pythona są zapamiętywane w procesie, więc sprawdzanie „produktu bez
modułu" tam, gdzie moduł już się zaimportował, dałoby wynik fałszywie
pozytywny."""
r = subprocess.run([sys.executable, "-c", SONDA], cwd=drzewo, capture_output=True,
text=True, env={"PYTHONPATH": str(drzewo), "PATH": "/usr/bin:/bin"})
linia = next((l for l in r.stdout.splitlines() if l.startswith("WYNIK:")), None)
assert linia, f"sonda nie wystartowała:\n{r.stdout[-2000:]}\n{r.stderr[-2000:]}"
return json.loads(linia[len("WYNIK:"):])
@@ -1,137 +0,0 @@
"""Po czym astroklienta NIE MA — ani w odpowiedzi, ani w kodzie w obrazie.
Ta usługa dzieli kod z pełną aplikacją, więc granica między produktami nie jest
oczywista z samego patrzenia: przebiega przez listę importów w `main.py` i przez
`usun.txt`. Ten test sprawdza jej SKUTEK na złożonym drzewie — bo tylko skutek
trafia do obrazu, który komuś się oddaje.
„Bez śladu" znaczy tu również bez zaprzeczeń. Zdanie „nie ma tu generowania
tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak
przycisk. Pierwsza wersja tego testu wyłapała trzy moje własne docstringi
wyliczające nieobecne funkcje.
"""
from __future__ import annotations
import pathlib
import re
import pytest
# Funkcje, których ten produkt nie ma — ani ich słownictwo.
GENEROWANIE = ["prompt", "LLM", "model językow", "generowanie tekstu", "sztuczn",
"anthropic", "openai", "gpt", "claude", "horoscope",
"x-ai.css", "models.js", "progress.js", "natal.js", "predictions.js"]
NIEOBECNE_EKRANY = ["Skompiluj", "Ustawienia", "Konta i uprawnien", "Eksport plików"]
MECHANIZMY = ["administrator", "kwarantann", "walidacj", "reguły przyjmowania"]
SLOWA = GENEROWANIE + NIEOBECNE_EKRANY + MECHANIZMY
WZORCE = [(r"(?<![\w-])AI(?![\w-])", "skrót nazwy funkcji")]
def _pliki(drzewo: pathlib.Path):
for f in sorted((drzewo / "app").rglob("*")):
if f.is_file() and f.suffix != ".pyc" and "__pycache__" not in str(f):
yield f
def test_the_shipped_tree_carries_no_trace(drzewo):
grzechy = []
for f in _pliki(drzewo):
try:
tresc = f.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
gdzie = f.relative_to(drzewo)
for slowo in SLOWA:
if re.search(rf"(?i)(?<![\w-]){re.escape(slowo)}", tresc):
grzechy.append(f"{gdzie}: „{slowo}")
for wzor, powod in WZORCE:
m = re.search(wzor, tresc)
if m:
grzechy.append(f"{gdzie}: „{m.group(0)}” — {powod}")
assert not grzechy, (
"\nW drzewie astroklienta padły słowa o funkcjach, których nie ma. "
"`grep` po kontenerze ma nic nie znaleźć:\n " + "\n ".join(grzechy))
def test_the_check_would_notice_if_something_slipped_in(drzewo, tmp_path):
"""Kontrola negatywna: zapora ma coś łapać.
Bez niej test wyżej przechodziłby także wtedy, gdyby lista słów była pusta
albo gdyby przeszukiwanie trafiało w niewłaściwy katalog."""
podrzucony = drzewo / "app" / "podrzucony.py"
podrzucony.write_text("# generowanie tekstu przez model\n", encoding="utf-8")
try:
with pytest.raises(AssertionError):
test_the_shipped_tree_carries_no_trace(drzewo)
finally:
podrzucony.unlink()
def test_the_removal_list_matches_what_is_actually_composed(drzewo):
"""usun.txt i main.py muszą mówić to samo.
Rozjazd między nimi znaczy albo martwy kod w obrazie (moduł usunięty
z importów, ale nie z listy), albo błąd dopiero przy uruchomieniu (plik
usunięty, a wciąż importowany). Jedno i drugie wychodzi za późno.
"""
main = (drzewo / "app" / "main.py").read_text(encoding="utf-8")
zaimportowane = set(re.findall(r"^\s{4}(\w+),$", main, re.M))
obecne = {f.stem for f in (drzewo / "app" / "ekrany").glob("*.py")
if f.stem != "__init__"}
assert zaimportowane == obecne, (
f"ekrany w drzewie: {sorted(obecne)}\n"
f"ekrany importowane: {sorted(zaimportowane)}")
def test_no_optional_module_survived(drzewo):
"""Katalogu modułów opcjonalnych nie ma wcale — most odkrywa wtedy zero."""
assert not (drzewo / "app" / "moduly").exists()
# ── nazwa produktu, z którego ten produkt powstał ─────────────────────────
# Osobno od listy wyżej, bo granica przebiega gdzie indziej: „astrololo" wolno
# występować w identyfikatorach WEWNĘTRZNYCH (nazwy loggerów, materiał do
# wyprowadzania kluczy, nagłówki między warstwami), a nie wolno w niczym, co
# dociera do przeglądarki. Zakaz na wszystko byłby albo obejściem przez zmianę
# nazw kluczy kryptograficznych, albo martwą regułą wyłączaną przy pierwszej
# okazji.
NAZWA_RDZENIA = "astrololo"
WIDOCZNE_DLA_KLIENTA = ("templates", "static")
def test_the_parent_product_is_not_named_in_anything_the_browser_receives(drzewo):
"""Szablony i zasoby są WSPÓLNE z pełną aplikacją, więc jej nazwa jechała
z nimi do tego obrazu — i astroklient przedstawiał się jako astrololo.
To jest najpoważniejszy z możliwych śladów: mówi klientowi wprost, że trzyma
okrojoną wersję czegoś większego, i podaje frazę, po której da się wyszukać,
czego mu nie dano."""
grzechy = []
for katalog in WIDOCZNE_DLA_KLIENTA:
for f in sorted((drzewo / "app" / katalog).rglob("*")):
if not f.is_file() or "__pycache__" in str(f):
continue
try:
tresc = f.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
if NAZWA_RDZENIA in tresc.lower():
grzechy.append(str(f.relative_to(drzewo)))
assert not grzechy, (
f"\nNazwa „{NAZWA_RDZENIA}” pada w plikach, które dostaje "
f"przeglądarka:\n " + "\n ".join(grzechy))
def test_the_rendered_pages_carry_this_products_own_name(wynik):
"""Sprawdzane na ODPOWIEDZI, nie na szablonie: liczy się to, co dociera do
człowieka, a nie to, co jest w pliku przed złożeniem."""
assert wynik["nazwa_w_naglowku"] == "astroklient"
assert wynik["nazwa_w_tytule"] == "astroklient"
assert not wynik["rdzen_w_tresci"], (
"nazwa pełnej aplikacji pada w treści strony oddawanej klientowi")
def test_the_api_documentation_is_not_reachable(wynik):
"""Tytuł aplikacji FastAPI wciąż niesie nazwę rdzenia, więc dokumentacja
OpenAPI byłaby wyciekiem tylnymi drzwiami — nawet gdy szablony są czyste."""
assert all(k == 404 for k in wynik["dokumentacja"].values()), wynik["dokumentacja"]
@@ -1,46 +0,0 @@
"""Astroklient jest produktem, a nie pełną aplikacją z wyłączonymi kawałkami.
Sprawdzamy trzy rzeczy, i wszystkie na ZŁOŻONYM drzewie, nie na kodzie w repo:
czego w nim nie ma, że to, co zostało, działa, i że nie zostało po niczym słowo.
"""
from __future__ import annotations
def test_the_build_starts_at_all(wynik):
assert wynik["dziala"]["/"] == 200
def test_every_astrological_screen_works(wynik):
assert all(k == 200 for k in wynik["dziala"].values()), wynik["dziala"]
def test_what_it_lacks_does_not_exist_rather_than_being_refused(wynik):
"""404, nie 403 i nie 500. Odmowa z powodem jest informacją, że coś tam jest."""
assert all(k == 404 for k in wynik["nie_ma"].values()), wynik["nie_ma"]
def test_the_catalogue_describes_this_product(wynik):
assert set(wynik["ekrany"]) == {"Horoskop", "Interpretacje", "Kalendarz",
"Synastria", "Sygnifikatory", "Pliki"}
def test_no_account_can_hold_a_permission_this_product_does_not_have(wynik):
"""Uprawnienia liczą się z KATALOGU, więc opisują ten obraz. Gdyby była tu
lista wpisana ręcznie, zostałaby po usuniętych funkcjach."""
assert "ai" not in wynik["uprawnienia"]
assert "export" not in wynik["uprawnienia"]
assert "admin" not in wynik["uprawnienia"]
assert "chart" in wynik["uprawnienia"] and "significators" in wynik["uprawnienia"]
def test_navigation_has_no_dead_links(wynik):
assert wynik["nawigacja"] == len(wynik["ekrany"])
def test_login_still_tells_right_password_from_wrong(wynik):
assert wynik["logowanie_dziala"] and wynik["zle_haslo_odmowa"]
def test_the_pool_comes_from_the_session_not_from_the_form(wynik):
"""Pula jedzie z zalogowanego konta. Gdyby brała się z żądania, wystarczyłoby
podstawić cudzy login, żeby czytać cudze pliki — sonda właśnie to próbuje."""
assert wynik["pula"] == "klient"
-16
View File
@@ -1,16 +0,0 @@
moduly
ekrany/skompiluj.py
ekrany/ustawienia.py
ekrany/konta.py
ekrany/pliki.py
accounts.py
files_state.py
templates/compile.html
templates/settings.html
templates/accounts.html
templates/files.html
static/s-compile.css
static/s-settings.css
static/s-accounts.css
static/s-files.css
static/compile.js
-66
View File
@@ -1,66 +0,0 @@
"""Przegląd baz na udziale i ich globalne włączanie/wyłączanie (DAN-15 / PRE-09).
Wymaganie przedefiniowane pod model serwerowy: pliki baz leżą na stałym NFS, więc
nie wybiera się folderu — potrzeba za to WIDZIEĆ, jakie bazy są dostępne (nazwa +
metaopis) i móc globalnie zdecydować, które biorą udział w interpretacji.
Stan przełączników trzymamy DEKLARATYWNIE w zmiennej `DISABLED_BASES`, a nie w
pliku, bo warstwa danych nie ma gdzie trwale zapisywać: udział z bazami jest
montowany read-only, a katalog cache to `emptyDir` (ginie przy restarcie poda).
Zapis do pliku po cichu wracałby więc do stanu sprzed restartu — a ciche
przywrócenie wyłączonej bazy jest gorsze niż konieczność edycji konfiguracji.
Dopasowanie jest tolerancyjne: wpis pasuje po nazwie pliku ALBO po ścieżce
względnej — żeby dało się wyłączyć zarówno „stara_baza.xlsx", jak i
„archiwum/stara_baza.xlsx".
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
from pathlib import Path
def disabled_entries() -> list[str]:
return [e.strip() for e in os.getenv("DISABLED_BASES", "").split(",") if e.strip()]
def _relative(path: Path, root: Path) -> str:
try:
return str(path.relative_to(root))
except ValueError:
return path.name
def is_enabled(path: str | Path, root: str | Path, entries: list[str] | None = None) -> bool:
"""Czy baza bierze udział w wyszukiwaniu interpretacji."""
entries = disabled_entries() if entries is None else entries
if not entries:
return True
p = Path(path)
rel = _relative(p, Path(root))
return not any(e == p.name or e == rel for e in entries)
def list_bases(root: str | Path, paths: list[str]) -> list[dict]:
"""Bazy dostępne na udziale + metaopis. Celowo TANI opis (dane z systemu
plików): przy setkach plików liczenie rekordów oznaczałoby wczytanie każdego."""
root = Path(root)
entries = disabled_entries()
out: list[dict] = []
for raw in paths:
p = Path(raw)
try:
st = p.stat()
size_mb, modified = round(st.st_size / (1024 * 1024), 2), st.st_mtime
except OSError:
size_mb, modified = None, None
out.append({
"name": p.name,
"path": _relative(p, root),
"size_mb": size_mb,
"modified": (datetime.fromtimestamp(modified, tz=timezone.utc).strftime("%Y-%m-%d")
if modified else None),
"enabled": is_enabled(p, root, entries),
})
return out
-70
View File
@@ -1,70 +0,0 @@
"""Rekordy-pułapki (canary) — wykrywanie wycieku baz (DAN-26).
Zabezpieczenie DETEKCYJNE, nie prewencyjne. Kilka unikalnych, wiarygodnie
wyglądających rekordów wplecionych w bazy: nie zmieniają interpretacji (są
ODSIEWANE z wyników, więc nie trafiają ani do użytkownika, ani do promptu LLM —
wymóg LOG-30), ale jeśli kiedyś pojawią się w cudzej kopii, są dowodem pochodzenia.
Pułapkę rozpoznajemy po MARKERZE: unikalny ciąg, który nie występuje w realnych
danych (wpleciony np. w pole znaczące). Markery i WARIANT tego wdrożenia biorą się
z konfiguracji (ENV `CANARY_MARKERS`, `CANARY_VARIANT`) — każda kopia może dostać
swój zestaw. Który wariant trafił do którego wdrożenia trzyma rejestr po stronie
ops (osobno, poza kodem — patrz docs/canary-registry.md).
Dwa sygnały:
* ODSIEWANIE — pułapka w wynikach znika, zanim opuści warstwę danych (log info);
* TRIPWIRE — zapytanie CELUJE wprost w marker (ktoś enumeruje bazę, a nie liczy
realny horoskop) → log warning, bo to podejrzane zachowanie.
"""
from __future__ import annotations
import logging
import os
log = logging.getLogger("astrololo.data.canary")
def _load_markers() -> list[str]:
return [m.strip() for m in os.getenv("CANARY_MARKERS", "").split(",") if m.strip()]
MARKERS = _load_markers()
VARIANT = os.getenv("CANARY_VARIANT", "")
def _row_is_canary(row: dict, markers: list[str]) -> bool:
"""Czy KTÓRAKOLWIEK tekstowa wartość wiersza zawiera marker pułapki."""
for v in row.values():
if isinstance(v, str):
for m in markers:
if m in v:
return True
return False
def screen(
rows: list[dict], query_value: str = "",
markers: list[str] | None = None, variant: str | None = None,
) -> tuple[list[dict], dict]:
"""Zwraca (widoczne_wiersze, raport).
Odsiewa pułapki z wyników (nie opuszczą warstwy danych). Gdy zapytanie celuje
wprost w marker — podnosi TRIPWIRE (możliwa enumeracja bazy). Bez skonfigurowanych
markerów: przezroczyste (`active=False`), zero kosztu dla normalnego ruchu.
"""
markers = MARKERS if markers is None else markers
variant = VARIANT if variant is None else variant
if not markers:
return rows, {"active": False, "removed": 0, "tripwire": False}
visible = [r for r in rows if not _row_is_canary(r, markers)]
removed = len(rows) - len(visible)
tripwire = any(m in (query_value or "") for m in markers)
if tripwire:
log.warning(
"CANARY TRIPWIRE: zapytanie celuje wprost w rekord-pułapkę (wariant %s) "
"— możliwa enumeracja bazy", variant or "?")
elif removed:
log.info("Odsiano %d rekord(ów)-pułapek z wyników (wariant %s)", removed, variant or "?")
return visible, {"active": True, "removed": removed, "tripwire": tripwire}
-3
View File
@@ -37,9 +37,6 @@ class Settings:
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300"))) query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
# Warstwa SQL (po migracji) # 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( sql_url: str = field(
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db") default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
) )
-379
View File
@@ -1,379 +0,0 @@
"""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)
-525
View File
@@ -1,525 +0,0 @@
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
zrzut z węzła), miał je w całości.
Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi.
GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt
nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale
podatne na modyfikację".
**Dwa niezależne klucze**, po jednym na parę rozmówców:
* ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika,
* ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane.
Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych,
gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony.
Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF).
Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie
losowej jednorazówki w jedną stronę nie osłabia drugiej.
Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu):
[4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik]
Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu
i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć
po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu.
Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność
wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa
działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa
regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import struct
import time
from typing import Iterable, Iterator
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
log = logging.getLogger("astrololo.link")
MAGIC = b"AL1"
VERSION = "v1"
NONCE_BYTES = 12
KEY_BYTES = 32 # AES-256
LENGTH_PREFIX = 4
MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości
MAX_SKEW_SECONDS = 300.0
HEADER_ENC = "X-Astrololo-Enc"
HEADER_TS = "X-Astrololo-Enc-Ts"
CONTENT_TYPE = "application/vnd.astrololo.enc"
ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC"
ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA"
# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych —
# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie
# jej klucza nie może otwierać łącza do logiki ani do danych.
ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER"
ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED"
REQUEST, RESPONSE = b"req", b"res"
# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka
# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji.
PUBLIC_PATHS = frozenset({"/health"})
class LinkError(Exception):
"""Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz."""
# --------------------------------------------------------------------- klucze
def parse_key(raw: str) -> bytes:
"""Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty."""
text = raw.strip()
if not text:
raise LinkError("pusty klucz łącza")
try:
key = bytes.fromhex(text)
except ValueError:
try:
key = base64.b64decode(text, validate=True)
except (binascii.Error, ValueError) as exc:
raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc
if len(key) != KEY_BYTES:
raise LinkError(
f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez "
f"`openssl rand -hex 32`"
)
return key
def key_from_env(env_name: str) -> bytes | None:
"""Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu."""
raw = os.getenv(env_name, "")
return parse_key(raw) if raw.strip() else None
def encryption_required() -> bool:
"""Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu.
Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza
wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść
zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim
cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu.
"""
return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"}
def _subkey(link_key: bytes, direction: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None,
info=b"astrololo/link/" + direction,
).derive(link_key)
class Link:
"""Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze."""
def __init__(self, link_key: bytes) -> None:
self._by_direction = {
REQUEST: AESGCM(_subkey(link_key, REQUEST)),
RESPONSE: AESGCM(_subkey(link_key, RESPONSE)),
}
# ---------------------------------------------------------- pojedyncza ramka
def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes:
return b"|".join([MAGIC, direction, path.encode("utf-8"),
stamp.encode("ascii"), str(seq).encode("ascii")])
def seal(self, direction: bytes, path: str, stamp: str, seq: int,
plaintext: bytes) -> bytes:
nonce = os.urandom(NONCE_BYTES)
sealed = self._by_direction[direction].encrypt(
nonce, plaintext, self._aad(direction, path, stamp, seq))
return MAGIC + nonce + sealed
def open(self, direction: bytes, path: str, stamp: str, seq: int,
frame: bytes) -> bytes:
if not frame.startswith(MAGIC):
raise LinkError("ramka bez znacznika 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")
+3 -114
View File
@@ -4,19 +4,14 @@ 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 dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
ani prezentacji. ani prezentacji.
""" """
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations from __future__ import annotations
import base64
import binascii
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException from fastapi import FastAPI
from app import canary, files, link_crypto, security
from app.config import settings from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult from app.models import HealthInfo, SearchQuery, SearchResult
from pydantic import BaseModel
from app.providers.factory import build_provider from app.providers.factory import build_provider
provider = build_provider(settings) provider = build_provider(settings)
@@ -28,118 +23,12 @@ async def lifespan(app: FastAPI):
yield yield
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji — app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
# 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) @app.post("/search", response_model=SearchResult)
def search(query: SearchQuery) -> SearchResult: def search(query: SearchQuery) -> SearchResult:
result = provider.search(query) return 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) @app.get("/health", response_model=HealthInfo)
+1 -9
View File
@@ -18,18 +18,10 @@ class SearchQuery(BaseModel):
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.") key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
value: str = Field(..., description="Szukana wartość.") value: str = Field(..., description="Szukana wartość.")
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).") exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
# Górny limit celowo niski: to zapytanie oddaje SUROWE wiersze baz, więc wysoki limit: int = Field(50, ge=1, le=50000)
# pułap zamienia je w narzędzie do masowego pobrania (LOG-32). 5000 = tyle, ile
# realnie potrzebuje build_report na jeden obiekt.
limit: int = Field(50, ge=1, le=5000)
fields: list[str] | None = Field( fields: list[str] | None = Field(
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie." None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
) )
# 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): class SearchResult(BaseModel):
-7
View File
@@ -25,10 +25,3 @@ class DataProvider(ABC):
def warmup(self) -> None: def warmup(self) -> None:
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie.""" """Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
return None return None
def list_bases(self) -> list[dict]:
"""Bazy widoczne dla dostawcy + metaopis i stan włączenia (DAN-15/PRE-09).
Opcjonalne: dostawca SQL nie operuje na plikach, więc domyślnie pusto
UI pokaże wtedy, że nie ma czego przełączać, zamiast się wywrócić."""
return []
+1 -42
View File
@@ -91,48 +91,10 @@ class ExcelDataProvider(DataProvider):
base = Path(self.s.excel_dir) base = Path(self.s.excel_dir)
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")] 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 ---- # ---- publiczne API ----
def search(self, query: SearchQuery) -> SearchResult: def search(self, query: SearchQuery) -> SearchResult:
t0 = time.perf_counter() t0 = time.perf_counter()
# Lista wyłączonych baz wchodzi do klucza cache: bez tego zmiana ustawień cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
# 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 hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
if hit is not None: if hit is not None:
@@ -143,9 +105,6 @@ class ExcelDataProvider(DataProvider):
if not candidates: if not candidates:
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki # brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
candidates = [(p, "0") for p in self._excel_files()] 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] = [] rows: list[dict] = []
for path, _sheet in candidates: for path, _sheet in candidates:
-50
View File
@@ -1,50 +0,0 @@
"""Uwierzytelnianie międzywarstwowe (LOG-32).
Warstwa danych oddaje SUROWE wiersze baz to najbardziej wrażliwy punkt całego
systemu. Bez tego kontrolera wystarczyłoby uderzyć w nią bezpośrednio, z pominięciem
i logiki, i logowania w UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
musi go przynieść w nagłówku X-Astrololo-Token.
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) wtedy przy
starcie leci ostrzeżenie.
"""
from __future__ import annotations
import logging
import os
import secrets
from fastapi import Request
from fastapi.responses import JSONResponse
log = logging.getLogger("astrololo.security")
HEADER = "X-Astrololo-Token"
PUBLIC_PATHS = frozenset({"/health"})
def token() -> str:
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
return os.getenv("INTERNAL_TOKEN", "")
def enabled() -> bool:
return bool(token())
def install(app, layer: str) -> None:
if not enabled():
log.warning(
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or not enabled():
return await call_next(request)
got = request.headers.get(HEADER, "")
if not secrets.compare_digest(got, token()):
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
status_code=401)
return await call_next(request)
-2
View File
@@ -1,2 +0,0 @@
-r requirements.txt
pytest>=8.0
-7
View File
@@ -6,11 +6,4 @@ pandas>=2.2
openpyxl>=3.1 openpyxl>=3.1
pyarrow>=18.0 pyarrow>=18.0
SQLAlchemy>=2.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 pydantic>=2.10
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
-50
View File
@@ -1,50 +0,0 @@
"""Przegląd baz na udziale i ich globalne wyłączanie (DAN-15 / PRE-09)."""
import pytest
from app import bases
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.delenv("DISABLED_BASES", raising=False)
ROOT = "/dane"
PATHS = [f"{ROOT}/main_base.xlsx", f"{ROOT}/archiwum/stara.xlsx"]
def test_everything_enabled_by_default():
for b in bases.list_bases(ROOT, PATHS):
assert b["enabled"] is True
def test_disable_by_file_name(monkeypatch):
monkeypatch.setenv("DISABLED_BASES", "stara.xlsx")
state = {b["name"]: b["enabled"] for b in bases.list_bases(ROOT, PATHS)}
assert state == {"main_base.xlsx": True, "stara.xlsx": False}
def test_disable_by_relative_path(monkeypatch):
"""Wpis może wskazywać ścieżkę względną, nie tylko samą nazwę."""
monkeypatch.setenv("DISABLED_BASES", "archiwum/stara.xlsx")
assert bases.is_enabled(f"{ROOT}/archiwum/stara.xlsx", ROOT) is False
assert bases.is_enabled(f"{ROOT}/main_base.xlsx", ROOT) is True
def test_entries_are_trimmed_and_multiple(monkeypatch):
monkeypatch.setenv("DISABLED_BASES", " stara.xlsx , main_base.xlsx ")
assert [b["enabled"] for b in bases.list_bases(ROOT, PATHS)] == [False, False]
def test_listing_carries_metadata_not_content(tmp_path):
"""Metaopis: nazwa, ścieżka, rozmiar, data — ŻADNEJ treści bazy."""
f = tmp_path / "baza.xlsx"
f.write_bytes(b"x" * 2048)
item = bases.list_bases(tmp_path, [str(f)])[0]
assert set(item) == {"name", "path", "size_mb", "modified", "enabled"}
assert item["name"] == "baza.xlsx" and item["size_mb"] is not None and item["modified"]
def test_missing_file_does_not_crash_the_listing():
item = bases.list_bases(ROOT, [f"{ROOT}/nie-ma.xlsx"])[0]
assert item["size_mb"] is None and item["modified"] is None
-61
View File
@@ -1,61 +0,0 @@
"""Rekordy-pułapki (canary) — DAN-26.
Testujemy sam MECHANIZM na syntetycznych pułapkach: prawdziwe markery i injekcja
do baz przychodzą od właściciela produktu. Regresja byłaby CICHA i podwójnie zła:
albo pułapka wycieka do interpretacji/LLM (zdradza się i psuje wynik), albo
przestaje odsiewać i nie wiadomo o tym.
"""
import pathlib
from app import canary
MAIN = (pathlib.Path(__file__).resolve().parents[1] / "app" / "main.py").read_text(encoding="utf-8")
MARK = "ASTROLOLO-CANARY-7f3a9" # unikalny — nie wystąpi w realnych danych
ROWS = [
{"significator": "Ma Ari", "effect": "odważny, impulsywny"},
{"significator": "Ve Tau " + MARK, "effect": "pułapka — nie dotknie interpretacji"},
{"significator": "Su Leo", "effect": "dumny, twórczy"},
]
def test_no_markers_is_transparent():
"""Bez skonfigurowanych markerów — zero ingerencji, zero kosztu."""
out, rep = canary.screen(ROWS, "cokolwiek", markers=[])
assert out == ROWS and rep["active"] is False
def test_canary_row_is_fenced_from_results():
"""Pułapka znika z wyników — nie opuści warstwy danych (a więc i promptu LLM)."""
out, rep = canary.screen(ROWS, "Ve Tau", markers=[MARK])
assert rep["active"] and rep["removed"] == 1
assert all(MARK not in str(r) for r in out) # nigdzie nie ma markera
assert len(out) == 2 and {"significator": "Su Leo", "effect": "dumny, twórczy"} in out
def test_real_results_pass_through_untouched():
out, _ = canary.screen(ROWS, "Ari", markers=[MARK])
assert {"significator": "Ma Ari", "effect": "odważny, impulsywny"} in out
def test_tripwire_when_query_targets_a_marker():
"""Zapytanie CELUJĄCE w marker = ktoś enumeruje bazę, nie liczy horoskopu."""
_, rep = canary.screen(ROWS, "Ve Tau " + MARK, markers=[MARK])
assert rep["tripwire"] is True
def test_normal_query_does_not_trip():
_, rep = canary.screen(ROWS, "Ma Ari", markers=[MARK])
assert rep["tripwire"] is False
def test_marker_matched_in_any_string_field():
rows = [{"significator": "X", "effect": "opis " + MARK, "extra": 5}]
out, rep = canary.screen(rows, "X", markers=[MARK])
assert out == [] and rep["removed"] == 1 # marker w polu 'effect' też łapiemy
def test_endpoint_fences_before_returning():
"""/search odsiewa pułapki na wyjściu z warstwy danych (niezależnie od dostawcy)."""
assert "canary.screen(result.rows, query.value)" in MAIN
assert "result.rows = visible" in MAIN
-214
View File
@@ -1,214 +0,0 @@
"""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 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
@@ -1,189 +0,0 @@
"""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
+1 -25
View File
@@ -1,34 +1,10 @@
# 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 FROM python:3.12-slim
WORKDIR /app WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \ RUN pip install --no-cache-dir -r requirements.txt
&& rm -rf /wheels
COPY . . 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 EXPOSE 8003
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
+1 -15
View File
@@ -15,23 +15,9 @@ permisywnej reszty systemu. **Nie wchodzi do dystrybucji zamkniętego produktu.*
Tryb Moshiera (`FLG_MOSEPH`) — bez plików efemeryd, zero konfiguracji. 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 ~12 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) ## Uruchomienie (tylko profil porównawczy / dev / CI)
```bash ```bash
pip install -r requirements.txt # wymaga kompilatora C (patrz wyżej) pip install -r requirements.txt
uvicorn app.main:app --port 8003 uvicorn app.main:app --port 8003
``` ```
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`, Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
+2 -68
View File
@@ -15,17 +15,12 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from fastapi import FastAPI, HTTPException from fastapi import FastAPI
from pydantic import BaseModel from pydantic import BaseModel
import swisseph as swe import swisseph as swe
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji — app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)")
# 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). # Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED _FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
@@ -74,67 +69,6 @@ def positions(req: PositionsRequest) -> dict:
return {"engine": "swisseph", "positions": rows} 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") @app.get("/health")
def health() -> dict: def health() -> dict:
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"} return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
+1 -1
View File
@@ -6,7 +6,7 @@ i nie w bazie.
## API ## API
- `POST /api/query``QueryRequest``QueryResponse` - `POST /api/query``QueryRequest``QueryResponse`
- `POST /chart/positions``{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Zwraca też sektę i 7 Lots hermetycznych z domami (LOG-08). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`. - `POST /chart/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). 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/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/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/return``{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12)
-2
View File
@@ -24,8 +24,6 @@ ABBREVIATIONS: dict[str, str] = {
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron", "NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
# osie # osie
"Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli", "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 # aspekty
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine", "conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare", "opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
+9 -70
View File
@@ -5,41 +5,16 @@ Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
""" """
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
import httpx import httpx
from app import link_crypto
from app.config import settings from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
def _link() -> link_crypto.Link | None:
"""Klucz łącza logika↔dane. Czytany przy każdym wywołaniu, bo konfiguracja
może się zmienić bez restartu procesu (testy, podmiana sekretu)."""
key = link_crypto.key_from_env(link_crypto.ENV_LOGIC_DATA)
return link_crypto.Link(key) if key else None
class DataClient: class DataClient:
"""Rozmówca warstwy danych, opcjonalnie ZWIĄZANY Z PULĄ KONTA (PRE-29). def __init__(self, base_url: str | None = None) -> None:
Pula siedzi w INSTANCJI, nie w sygnaturach metod. Gdyby jechała argumentem,
trzeba by 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.base_url = (base_url or settings.data_url).rstrip("/")
self.tenant = tenant or ""
def search( def search(
self, self,
@@ -49,50 +24,14 @@ class DataClient:
limit: int, limit: int,
fields: list[str] | None = None, fields: list[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit, payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
"fields": fields, "tenant": self.tenant}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client: with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}/search", r = client.post(f"{self.base_url}/search", json=payload)
payload=payload, headers=_auth_headers(), r.raise_for_status()
link=_link()) return r.json()
def bases(self) -> dict[str, Any]: def health(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:
with httpx.Client(timeout=settings.http_timeout) as client: r = client.get(f"{self.base_url}/health")
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", headers=_auth_headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
+4 -68
View File
@@ -15,16 +15,6 @@ MAJOR = {
"trine": 120.0, "trine": 120.0,
"opposition": 180.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 = { DB_TOKEN = {
"conjunction": "[conj", "sextile": "[sex", "square": "[sq", "conjunction": "[conj", "sextile": "[sex", "square": "[sq",
"trine": "[tri", "opposition": "[opp", "trine": "[tri", "opposition": "[opp",
@@ -37,18 +27,6 @@ LUMINARIES = {"Sun", "Moon"}
DEFAULT_ORB = 8.0 DEFAULT_ORB = 8.0
LUMINARY_BONUS = 2.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: def separation(a: float, b: float) -> float:
"""Najmniejsza separacja kątowa [0,180].""" """Najmniejsza separacja kątowa [0,180]."""
@@ -71,67 +49,25 @@ def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: f
return dev_next < dev_now 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( def find_aspects(
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS, positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
minor: bool = False,
) -> list[dict]: ) -> list[dict]:
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę). """positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
Zwraca listę aspektów; gdy znane prędkości, każdy aspekt ma applying (bool) Zwraca listę aspektów głównych; gdy znane prędkości, każdy aspekt ma
i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny). Orb i bonus dla świateł applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
KONFIGUROWALNE (PRE-06); `minor=True` dokłada aspekty poboczne (30/45/150°).
Pary z RIGID_PAIRS (np. NN/SN) pomijane ich kąt jest definicyjny.
""" """
checks = {**MAJOR, **MINOR} if minor else MAJOR
out: list[dict] = [] out: list[dict] = []
n = len(positions) n = len(positions)
for i in range(n): for i in range(n):
for j in range(i + 1, n): for j in range(i + 1, n):
a, b = positions[i], positions[j] a, b = positions[i], positions[j]
if _is_rigid(a["name"], b["name"]):
continue
la, lb = a.get("decimal"), b.get("decimal") la, lb = a.get("decimal"), b.get("decimal")
if la is None or lb is None: if la is None or lb is None:
continue continue
sep = separation(float(la), float(lb)) sep = separation(float(la), float(lb))
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0) allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
for asp, angle in checks.items(): for asp, angle in MAJOR.items():
dev = abs(sep - angle) dev = abs(sep - angle)
if dev <= allowed: if dev <= allowed:
applying = _is_applying( applying = _is_applying(
+16 -142
View File
@@ -7,96 +7,26 @@ pozycje.
from __future__ import annotations from __future__ import annotations
from app.engine import houses as H from app.engine import houses as H
from app.engine import zodiac as Z
from app.engine.base import EphemerisEngine from app.engine.base import EphemerisEngine
from app.engine.formats import SIGNS, absolute, decimal, in_sign, norm360, sign_index from app.engine.formats import SIGNS, in_sign, norm360, sign_index
from app.engine.models import ChartMoment from app.engine.models import ChartMoment
def _fmt(name: str, lon: float, off: float = 0.0) -> dict: def _fmt(name: str, lon: float) -> dict:
lon = norm360(lon - off)
return { return {
"name": name, "name": name,
"sign": SIGNS[sign_index(lon)], "sign": SIGNS[sign_index(lon)],
"in_sign": in_sign(lon), "in_sign": in_sign(lon),
"decimal": round(lon, 6), "decimal": round(norm360(lon), 6),
} }
def _shift_pos(pdict: dict, off: float) -> None: def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> dict:
"""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.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) positions = engine.positions(moment)
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]} result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
result["aspects"] = find_aspects( # aspekty (LOG-06), konfigurowalne (PRE-06)
result["positions"], orb=aspect_orb,
luminary_bonus=aspect_luminary_bonus, minor=aspect_minor)
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
# 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"): if not hasattr(engine, "sidereal"):
return result return result
@@ -105,75 +35,19 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
asc = H.compute_asc(ramc, eps, moment.lat) asc = H.compute_asc(ramc, eps, moment.lat)
mc = H.compute_mc(ramc, eps) mc = H.compute_mc(ramc, eps)
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN 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 cusp_list = H.cusps(asc, mc, system)
# 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]: result["house_system"] = system
"""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"] = { result["angles"] = {
"Asc": _fmt("Asc", asc, off), "Asc": _fmt("Asc", asc),
"MC": _fmt("MC", mc, off), "MC": _fmt("MC", mc),
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off), "Dsc": _fmt("Dsc", norm360(asc + 180.0)),
"IC": _fmt("IC", norm360(mc + 180.0), off), "IC": _fmt("IC", norm360(mc + 180.0)),
} }
for a in result["angles"].values(): # glif znaku osi (LOG-22) result["cusps"] = [
a["sign_glyph"] = GL.sign_glyph(a["sign"]) {"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)}
result["cusps"] = _cusps_out(cusp_list) # PRYMARNY system — pod kosmogram i wstecz for i, c in enumerate(cusp_list)
for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
# Wiele systemów domów NARAZ (PRE-05/LOG-05) — do porównania obok siebie.
# Osie (Asc/MC) są wspólne; różni się tylko PODZIAŁ na domy. Prymarny zostaje
# w `cusps`/`house_system` (kosmogram i wstecz), a `house_systems` niesie pełen
# zestaw; `positions[].houses[system]` mówi, w którym domu obiekt siedzi wg
# danego systemu. Dokładamy tylko gdy poproszono o więcej niż jeden.
requested = [system] + [s for s in (house_systems or []) if s in H.SYSTEMS]
ordered = list(dict.fromkeys(requested)) # prymarny pierwszy, bez duplikatów
if len(ordered) > 1:
result["house_systems"] = []
for s in ordered:
cs = primary if s == system else H.cusps_detailed(ramc, eps, moment.lat, s)
block = {"system": s, "used_system": cs.system, "cusps": _cusps_out(cs.cusps)}
if cs.notice:
block["notice"] = cs.notice
if cs.notice not in result["house_warnings"]:
result["house_warnings"].append(cs.notice)
result["house_systems"].append(block)
for pdict, obj in zip(result["positions"], positions):
pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cs.cusps)
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
from app.engine.firdaria import is_day_birth
from app.engine.lots import compute_lots
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)
] ]
for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list)
return result return result
-177
View File
@@ -1,177 +0,0 @@
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
Litery i słowa (Sa Pis 26°08' conj Fortune) ↔ glify (♄ ♓ 26°08' ). Potrzebne
pod rysowanie kosmogramu (PRE-12) planety i znaki na kole rysujemy symbolami.
DWIE zasady z bazy wymagań, obie krytyczne:
* DAN-18: TYLKO tekstowy Unicode, NIGDY emoji. Część znaków (zodiak, , ) ma
domyślnie prezentację emoji kolorowy kwadrat zamiast czarno-białego glifu,
nieczytelny i niesterowalny przez CSS. Wymuszamy prezentację tekstową
selektorem wariantu U+FE0E (NIE U+FE0F, który robi odwrotnie).
* DAN-17: łańcuch musi znosić dowolny Unicode. Reverse (symboltekst)
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 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
+8 -518
View File
@@ -8,31 +8,13 @@ silnik dostarcza tylko RAMC i ε.
from __future__ import annotations from __future__ import annotations
import math import math
from dataclasses import dataclass
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401 from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
WHOLE_SIGN = "whole_sign" WHOLE_SIGN = "whole_sign"
EQUAL = "equal" 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" PORPHYRY = "porphyry"
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
# 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: def mean_obliquity(tt_jd: float) -> float:
@@ -49,27 +31,12 @@ def compute_mc(ramc_deg: float, eps_deg: float) -> float:
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float: 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) r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
asc = norm360(math.degrees(math.atan2( asc = math.atan2(
math.cos(r), math.cos(r),
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)), -(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
))) )
mc = compute_mc(ramc_deg, eps_deg) return norm360(math.degrees(asc))
return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else asc
def _trisect(a: float, b: float) -> tuple[float, float]: def _trisect(a: float, b: float) -> tuple[float, float]:
@@ -78,29 +45,10 @@ def _trisect(a: float, b: float) -> tuple[float, float]:
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0) 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]: def cusps(asc: float, mc: float, system: str) -> list[float]:
"""Zwraca 12 cusps (długości) domów 1..12.""" """Zwraca 12 cusps (długości) domów 1..12."""
if system == WHOLE_SIGN: if system == WHOLE_SIGN:
start = sign_index(_snap_to_sign_boundary(asc)) * 30.0 start = sign_index(asc) * 30.0
return [norm360(start + 30.0 * i) for i in range(12)] return [norm360(start + 30.0 * i) for i in range(12)]
if system == EQUAL: if system == EQUAL:
return [norm360(asc + 30.0 * i) for i in range(12)] return [norm360(asc + 30.0 * i) for i in range(12)]
@@ -116,472 +64,14 @@ def cusps(asc: float, mc: float, system: str) -> list[float]:
raise ValueError(f"nieznany system domów: {system}") raise ValueError(f"nieznany system domów: {system}")
def polar_circle(eps_deg: float) -> float:
"""Szerokość koła podbiegunowego [°] dla danego nachylenia ekliptyki.
NIE jest to stała 66,56°: ε zmienia się z datą (ok. 23,71° w 370 p.n.e.,
23,44° dziś), więc granica przesuwa się o ~0,3° w zakresie dat programu.
Powyżej niej stopnie ekliptyki bywają okołobiegunowe nie wschodzą ani nie
zachodzą przez co systemy oparte na łuku dobowym (Placidus, Koch) tracą
definicję."""
return 90.0 - abs(eps_deg)
# ── geometria wektorowa dla systemów dzielących koła wielkie ─────────────
# Wzory na te systemy krążą w literaturze w kilku wariantach i łatwo o pomyłkę
# w gałęzi albo znaku. Liczymy więc WPROST z geometrii: budujemy wektory kierunkowe
# w układzie równikowym, przecinamy płaszczyzny i dopiero wynik zamieniamy na
# długość ekliptyczną. Jest to dłuższe, ale jednoznaczne i sprawdzalne.
def _cross(a, b):
return (a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0])
def _dot(a, b):
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _equatorial_to_lon(v, eps_rad: float) -> float:
"""Wektor w układzie RÓWNIKOWYM → długość ekliptyczna [°]."""
x, y, z = v
y_ecl = y * math.cos(eps_rad) + z * math.sin(eps_rad)
return norm360(math.degrees(math.atan2(y_ecl, x)))
def _ecliptic_pole(eps_rad: float):
"""Biegun ekliptyki (normalna płaszczyzny ekliptyki) w układzie równikowym."""
return (0.0, -math.sin(eps_rad), math.cos(eps_rad))
def _horizon_north(ramc_rad: float, lat_rad: float):
"""Punkt północny horyzontu: RA = RAMC+180°, deklinacja = 90°−φ."""
return (-math.sin(lat_rad) * math.cos(ramc_rad),
-math.sin(lat_rad) * math.sin(ramc_rad),
math.cos(lat_rad))
# Domy POŚREDNIE (bez osi) i to, po której stronie MC leżą. Domy 11, 12, 2, 3
# są na wschód od MC (przesunięcie 0180°), domy 5, 6, 8, 9 — na zachód.
_INTERMEDIATE = {1: True, 2: True, 4: False, 5: False,
7: False, 8: False, 10: True, 11: True}
def _house_circle_cusp(north, q, eps_rad: float, mc: float, east_of_mc: bool) -> float:
"""Cusp = przecięcie ekliptyki z kołem domu.
Koło domu przechodzi przez punkty N/S horyzontu oraz przez punkt podziału `q`
(na równiku dla Regiomontanusa, na pierwszym wertykale dla Campanusa).
Przecięcie dwóch płaszczyzn daje PROSTĄ, czyli DWA antypodyczne kierunki;
wybieramy ten po właściwej stronie południka.
Używane WYŁĄCZNIE dla domów pośrednich. Osie (1, 4, 7, 10) znamy dokładnie
z Asc i MC liczenie ich drogą było błędem, bo leżą dokładnie na granicy
wschód/zachód" (przesunięcie 0° i 180°), gdzie porównanie zmiennoprzecinkowe
się chwieje i potrafi wybrać przeciwny punkt nieba."""
normal = _cross(north, q) # normalna płaszczyzny koła domu
line = _cross(normal, _ecliptic_pole(eps_rad))
lon = _equatorial_to_lon(line, eps_rad)
return lon if ((lon - mc) % 360.0 < 180.0) == east_of_mc else norm360(lon + 180.0)
def _culminating_mc(mc: float, eps: float, lat: float) -> float:
"""Punkt południka, który dla tej szerokości leży NAD horyzontem.
Systemy oparte na horyzoncie (Regiomontanus, Campanus, Topocentric) biorą jako
dziesiąty dom punkt GÓRUJĄCY, a nie matematyczne MC a za kołem podbiegunowym
to nie zawsze to samo. Punkt południka o deklinacji δ ma wysokość 90|φδ|,
więc jest nad horyzontem dokładnie wtedy, gdy |φδ| < 90.
Systemy dzielące samą ekliptykę (porphyry, equal, alcabitus, whole sign) tego
nie robią i tak samo zachowuje się wyrocznia."""
dec = math.degrees(math.asin(math.sin(math.radians(mc)) * math.sin(math.radians(eps))))
return norm360(mc + 180.0) if abs(lat - dec) > 90.0 else mc
def _with_exact_angles(intermediate, asc: float, mc: float) -> list[float]:
"""Składa 12 cuspów: osie wstawione dokładnie, reszta z geometrii."""
out = [0.0] * 12
out[0], out[3] = asc, norm360(mc + 180.0) # Asc, IC
out[6], out[9] = norm360(asc + 180.0), mc # Dsc, MC
for i, value in intermediate.items():
out[i] = value
return out
def _ra_to_ecliptic_lon(ra_deg: float, eps_rad: float) -> float:
"""Punkt ekliptyki o zadanej rektascensji (koło godzinne → ekliptyka)."""
r = math.radians(ra_deg)
return norm360(math.degrees(math.atan2(math.sin(r), math.cos(r) * math.cos(eps_rad))))
def _equator_point(ra_deg: float):
"""Kierunek punktu na równiku niebieskim o danej rektascensji."""
r = math.radians(ra_deg)
return (math.cos(r), math.sin(r), 0.0)
def _prime_vertical_point(ramc_rad: float, lat_rad: float, angle_deg: float):
"""Punkt pierwszego wertykału, `angle_deg` od punktu wschodu w stronę nadiru.
Pierwszy wertykał to koło przez wschód, zenit, zachód i nadir Campanus dzieli
właśnie je."""
east = (-math.sin(ramc_rad), math.cos(ramc_rad), 0.0)
zenith = (math.cos(lat_rad) * math.cos(ramc_rad),
math.cos(lat_rad) * math.sin(ramc_rad),
math.sin(lat_rad))
a = math.radians(angle_deg)
return tuple(east[i] * math.cos(a) - zenith[i] * math.sin(a) for i in range(3))
def _cusps_regiomontanus(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Równik niebieski dzielony na 12 równych łuków, rzut kołami przez N/S horyzontu."""
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
north = _horizon_north(rr, lr)
mid = {i: _house_circle_cusp(north, _equator_point(ramc + 90.0 + 30.0 * i), er, mc, e)
for i, e in _INTERMEDIATE.items()}
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
def _cusps_campanus(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Pierwszy wertykał dzielony na 12 równych łuków, rzut tak samo jak wyżej."""
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
north = _horizon_north(rr, lr)
mid = {i: _house_circle_cusp(north, _prime_vertical_point(rr, lr, 30.0 * i), er, mc, e)
for i, e in _INTERMEDIATE.items()}
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
def _cusps_morinus(ramc: float, eps: float) -> list[float]:
"""Równik dzielony od RAMC i rzutowany WPROST na ekliptykę — bez horyzontu.
Dlatego Morinus jako jedyny nie zależy od szerokości geograficznej, a jego
dom I nie pokrywa się z Ascendentem. Uwaga: to ZAMIANA WSPÓŁRZĘDNYCH punktu
równika (RA, dec=0) na ekliptyczne, a nie rzut kołem godzinnym te dwie
operacje dają różne wyniki i pomylenie ich kosztowało tu do 5°."""
er = math.radians(eps)
return [_equatorial_to_lon(_equator_point(ramc + 90.0 + 30.0 * i), er)
for i in range(12)]
def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
"""Łuki równika MC→Asc i Asc→IC dzielone na trzy; rzut kołami godzinnymi."""
er = math.radians(eps)
a = math.radians(asc)
ra_asc = norm360(math.degrees(math.atan2(math.sin(a) * math.cos(er), math.cos(a))))
day = (ra_asc - ramc) % 360.0 # łuk MC → Asc (domy 11, 12)
night = (ramc + 180.0 - ra_asc) % 360.0 # łuk Asc → IC (domy 2, 3)
ra = [0.0] * 12
ra[9] = ramc # dom 10 = MC
ra[10] = ramc + day / 3.0 # dom 11
ra[11] = ramc + 2.0 * day / 3.0 # dom 12
ra[0] = ra_asc # dom 1 = Asc
ra[1] = ra_asc + night / 3.0 # dom 2
ra[2] = ra_asc + 2.0 * night / 3.0 # dom 3
for i in range(6): # domy 49 naprzeciw 103
ra[i + 3] = ra[(i + 9) % 12] + 180.0
return [_ra_to_ecliptic_lon(x, er) for x in ra]
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (PolichPage).
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
# PolichPage: dom pośredni to Ascendent policzony pod własnym „biegunem"
# tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu. Rodzina jest CIĄGŁA:
# przy k=0 (biegun 0, przesunięcie 90°) daje MC, przy k=3 (biegun φ, przesunięcie 0)
# Ascendent, a domy 11 i 12 leżą po drodze.
#
# Cała trudność tego systemu siedziała w wyborze gałęzi — dwa koła wielkie
# przecinają się w dwóch punktach antypodycznych. Heurystyki („po której stronie
# MC", „w łuku kwadrantu", „wschodnia połowa horyzontu", śledzenie ciągłości
# krokami) myliły się na 611% przypadków powyżej ~70°, bo każda z nich rozstrzyga
# LOKALNIE, a przy dużych szerokościach kolejność domów potrafi się odwrócić.
#
# Rozwiązanie: nie wybierać w ogóle. Iloczyn wektorowy zenitu z biegunem ekliptyki
# jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot — dwuznaczność
# wprowadza dopiero atan2. Zostajemy więc w wektorach, a znak ustalamy RAZ, kotwicząc
# rodzinę na MC górującym. Stąd zgodność co do zera na całej dziedzinie, bez iteracji
# i bez zawężania szerokości.
# Dom → (przesunięcie RAMC [°], ułamek bieguna k/3).
_TOPO_STEP = {10: (-60.0, 1 / 3), 11: (-30.0, 2 / 3), # domy 11, 12
1: (30.0, 2 / 3), 2: (60.0, 1 / 3)} # domy 2, 3
def _cusps_topocentric(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Ascendenty pod biegunami tan(P) = tan(φ)·k/3, liczone wektorowo.
Domy 5, 6, 8, 9 bierzemy jako OPOZYCJE domów 11, 12, 2, 3 to nie skrót,
lecz własność konstrukcji: przeciwległe domy leżą na tym samym kole wielkim.
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością daje wynik
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
konstrukcji ascendent pod biegunem"."""
# Cuspy topocentrica są poprawne na CAŁEJ dziedzinie (zgodne z wyrocznią co do
# zera), ale powyżej koła podbiegunowego przestają DZIELIĆ OKRĄG: domy nachodzą
# na siebie, bo cusp VII (= I + 180°) wypada przed cuspem VI. Przypisanie planety
# do domu traci wtedy sens — co potwierdza sama wyrocznia, której swe_house_pos
# przeczy tam własnym cuspom (100% zgodności do 62°, 83,9% przy 66°, ok. 50%
# przy 72°). Odmawiamy, zamiast zwracać liczbę bez znaczenia.
#
# To INNY rodzaj granicy niż u Placidusa i Kocha: tam nie istnieją same cuspy,
# tu istnieją, tylko nie tworzą podziału. Próg jest wyprowadzony z warunku
# „dwanaście cuspów sumuje się do 360°", nie dobrany pod wynik testu — i wypada
# na kole podbiegunowym (zmierzone: 100% podziałów do 65°, 78% w pasie 66-67°).
if abs(lat) >= polar_circle(eps):
raise HouseSystemUndefined(
f"φ={lat:.4f}° powyżej koła podbiegunowego ({polar_circle(eps):.4f}° dla "
f"ε={eps:.4f}°): cuspy topocentryczne przestają dzielić okrąg, "
f"domy nachodzą na siebie")
er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
epole = _ecliptic_pole(er)
# Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
# cała rodzina obraca się razem z dziesiątym domem — stąd zwrot iloczynu.
culminating = _culminating_mc(mc, eps, lat)
sign = 1.0 if abs(((culminating - mc + 180.0) % 360.0) - 180.0) > 90.0 else -1.0
out = [0.0] * 12
for i, (offset, fraction) in _TOPO_STEP.items():
th = math.radians(ramc + offset)
pole = math.atan(tan_lat * fraction)
zenith = (math.cos(pole) * math.cos(th),
math.cos(pole) * math.sin(th),
math.sin(pole))
v = _cross(zenith, epole)
lon = _equatorial_to_lon(tuple(sign * x for x in v), er)
out[i] = lon
out[(i + 6) % 12] = norm360(lon + 180.0)
out[0], out[3] = asc, norm360(culminating + 180.0)
out[6], out[9] = norm360(asc + 180.0), culminating
return out
# ── systemy łuku dobowego (Placidus, Koch) ───────────────────────────────
# Różnią się od wszystkich poprzednich tym, że NIE MAJĄ wzoru zamkniętego: cusp
# jest zdefiniowany warunkiem na samego siebie („punkt, który przebył 1/3 swojego
# półłuku"), więc trzeba go znaleźć iteracyjnie. Mają też jako jedyne REALNĄ
# granicę dziedziny — powyżej koła podbiegunowego stopnie ekliptyki bywają
# okołobiegunowe, nie wschodzą ani nie zachodzą, i półłuk po prostu nie istnieje.
class HouseSystemUndefined(ValueError):
"""System domów nie ma definicji dla podanych parametrów (nie: błąd liczenia).
Podnoszone zamiast zwrócenia liczby, bo cicha podmiana systemu jest gorsza
niż błąd: wykres wygląda poprawnie, a planety siedzą w innych domach, niż
astrolog zamawiał. Warstwa aplikacyjna łapie to w cusps_detailed() i robi
JAWNY fallback."""
_ITER_MAX = 100
_ITER_TOL_DEG = 1e-11
def _declination_of_ecliptic_lon(lon_deg: float, eps_rad: float) -> float:
"""Deklinacja punktu LEŻĄCEGO NA EKLIPTYCE o danej długości."""
return math.degrees(math.asin(math.sin(eps_rad) * math.sin(math.radians(lon_deg))))
def _ascensional_difference(dec_deg: float, lat_deg: float) -> float:
"""Różnica wschodnia: o ile półłuk dobowy odbiega od 90°.
sin(AD) = tan(φ)·tan(δ). Gdy |tan(φ)·tan(δ)| 1, punkt jest okołobiegunowy
(nigdy nie wschodzi albo nigdy nie zachodzi) i półłuk nie istnieje."""
v = math.tan(math.radians(lat_deg)) * math.tan(math.radians(dec_deg))
if abs(v) >= 1.0:
raise HouseSystemUndefined(
f"punkt okołobiegunowy (tan φ·tan δ = {v:.6f}): półłuk dobowy nie istnieje")
return math.degrees(math.asin(v))
# Dom → (ułamek półłuku, czy łuk NOCNY). Domy 11 i 12 dzielą łuk dzienny licząc
# od MC; domy 2 i 3 — łuk nocny, licząc WSTECZ od IC.
_PLACIDUS_STEP = {10: (1 / 3, False), 11: (2 / 3, False),
1: (2 / 3, True), 2: (1 / 3, True)}
def _placidus_cusp(ramc: float, eps: float, lat: float,
fraction: float, nocturnal: bool) -> float:
"""Punkt ekliptyki, który przebył `fraction` swojego półłuku.
Warunek jest uwikłany: półłuk zależy od deklinacji, deklinacja od długości,
a długość od położenia więc iterujemy po punkcie stałym. Zbieżność jest
szybka z dala od koła podbiegunowego i psuje się przy nim, dlatego brak
zbieżności traktujemy jako wyjście poza dziedzinę, a nie jako wynik."""
eps_rad = math.radians(eps)
# Start od podziału równomiernego — to Porphyry na równiku, czyli dokładnie
# ten przypadek, w którym Placidus się do niego sprowadza.
ra = ramc + 180.0 - 90.0 * fraction if nocturnal else ramc + 90.0 * fraction
for _ in range(_ITER_MAX):
dec = _declination_of_ecliptic_lon(_ra_to_ecliptic_lon(ra, eps_rad), eps_rad)
ad = _ascensional_difference(dec, lat)
nxt = (ramc + 180.0 - fraction * (90.0 - ad) if nocturnal
else ramc + fraction * (90.0 + ad))
if abs(nxt - ra) < _ITER_TOL_DEG:
return _ra_to_ecliptic_lon(nxt, eps_rad)
ra = nxt
raise HouseSystemUndefined(
f"brak zbieżności po {_ITER_MAX} krokach (φ={lat:.4f}, RAMC={ramc:.4f})")
def _cusps_placidus(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Półłuki dobowe i nocne dzielone na trzy — każdy punkt swoim własnym łukiem."""
if abs(lat) >= polar_circle(eps):
raise HouseSystemUndefined(
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
inter = {i: _placidus_cusp(ramc, eps, lat, f, noct)
for i, (f, noct) in _PLACIDUS_STEP.items()}
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
return _with_exact_angles(inter, asc, mc)
# Koch dzieli CZAS, nie łuk na niebie. Kryterium: ile czasu minęło od wschodu
# tego stopnia zodiaku, który stoi na MC. Ten odcinek (półłuk dobowy stopnia MC)
# dzielimy na trzy i dla punktów podziału liczymy ZWYKŁY Ascendent — stąd nazwa
# „system miejsca urodzenia". Zgodne z definicją Astrodienst (astro.com/astrowiki).
#
# W przeciwieństwie do Placidusa NIE wymaga iteracji: półłuk zależy od deklinacji
# stopnia MC, którą znamy wprost. Granicę dziedziny dzieli natomiast z Placidusem —
# gdy stopień MC jest okołobiegunowy, „moment jego wschodu" nie istnieje.
_KOCH_OFFSET = {10: -2 / 3, 11: -1 / 3, 1: 1 / 3, 2: 2 / 3}
def _cusps_koch(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Ascendenty dla chwil trójdzielących drogę stopnia MC od wschodu do górowania."""
if abs(lat) >= polar_circle(eps):
raise HouseSystemUndefined(
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
dec_mc = _declination_of_ecliptic_lon(mc, math.radians(eps))
half_arc = 90.0 + _ascensional_difference(dec_mc, lat)
inter = {i: compute_asc(ramc + f * half_arc, eps, lat)
for i, f in _KOCH_OFFSET.items()}
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
return _with_exact_angles(inter, asc, mc)
def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]:
"""Kanoniczne wejście: (RAMC, ε, φ) → 12 cusps.
Systemy proste (whole sign / equal / porphyry) potrzebują tylko Asc i MC,
ale systemy egzotyczne dzielą inne koła wielkie i wymagają pełnego zestawu
(RAMC, ε, φ). Ta funkcja jest wspólnym punktem wejścia dla obu rodzajów
i to 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 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: def assign_house(lon: float, cusp_list: list[float]) -> int:
"""Numer domu (1..12), w którym leży dana długość ekliptyczna. """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) lon = norm360(lon)
forward = _runs_forward(cusp_list)
for i in range(12): for i in range(12):
start = cusp_list[i] start = cusp_list[i]
end = cusp_list[(i + 1) % 12] end = cusp_list[(i + 1) % 12]
if forward: span = (end - start) % 360.0
span, offset = (end - start) % 360.0, (lon - start) % 360.0 offset = (lon - start) % 360.0
else:
span, offset = (start - end) % 360.0, (start - lon) % 360.0
if offset < span: if offset < span:
return i + 1 return i + 1
return 12 return 12
-60
View File
@@ -1,60 +0,0 @@
"""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 AB) 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 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
-140
View File
@@ -1,140 +0,0 @@
"""Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja.
Aspekty głowne (LOG-06) mierzą kąt wzdłuż EKLIPTYKI. Ale dwa ciała mogą być
powiązane też inaczej a te powiązania klasyczna astrologia liczy naprawdę,
nie na oko:
* **Paralela / kontrparalela deklinacji.** Deklinacja to szerokość" na równiku
niebieskim jak daleko na północ/południe od równika stoi ciało. Dwa ciała na
tej samej deklinacji (parallel) działają jak koniunkcja, na przeciwnej
(kontrparalela) jak opozycja, mimo że wzdłuż ekliptyki mogą być gdziekolwiek.
Baza interpretacyjna zna to zjawisko pod skrótem P. Dec.".
* **Antyscja / kontrantyscja.** Odbicie punktu względem osi przesileń
(0° Raka 0° Koziorożca). Dwa punkty w antyscji 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 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 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
+4 -20
View File
@@ -117,28 +117,12 @@ class SkyfieldEngine(EphemerisEngine):
Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo. Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo.
""" """
from app.engine.houses import mean_obliquity
t = self.ts.from_datetime(moment.when_utc) t = self.ts.from_datetime(moment.when_utc)
ramc = norm360(t.gast * 15.0 + moment.lon) ramc = norm360(t.gast * 15.0 + moment.lon)
return ramc, true_obliquity(t) eps = mean_obliquity(t.tt)
return ramc, eps
def health(self) -> dict: def health(self) -> dict:
return {"engine": self.name, "status": "ok", "kernel": self.kernel} 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 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
-368
View File
@@ -1,368 +0,0 @@
"""Tabele pomocnicze horoskopu (LOG-23).
Zbiór wyliczeń, które astrolog czyta obok" pozycji: bilans żywiołów i jakości,
faza Księżyca, stopnie krytyczne, dzień i godziny planetarne, syzygia prenatalna
oraz podziały (dwunastniki i nawamsa).
Dwie rzeczy wymagają prawdziwego liczenia, nie tabelki:
* **godziny planetarne** 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 NIERÓWNE: dzień (wschódzachód) i noc (zachódwschó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
-103
View File
@@ -1,103 +0,0 @@
"""Systemy zodiaku (LOG-04): tropikalny, syderyczny (ayanamsy), draconic + RA.
Wszystkie pozycje silnika 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·`, 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 19002100 (patrz tests/test_zodiac.py).
RA (right ascension): konwersja ekliptykaró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 0360°; 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)
-525
View File
@@ -1,525 +0,0 @@
"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33).
Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token
międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź
a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń
produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu,
zrzut z węzła), miał je w całości.
Ten moduł zamyka 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 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
w buforze zamiast zakładać, że każdy kawałek to komplet.
"""
if link is None:
yield from response.iter_bytes()
return
if response.headers.get(HEADER_ENC) != VERSION:
raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony")
stamp = response.headers.get(HEADER_TS, "")
check_stamp(stamp)
path = response.request.url.path
buffer = bytearray()
seq = 0
for chunk in response.iter_bytes():
buffer += chunk
for frame in unframe_incremental(buffer):
yield link.open(RESPONSE, path, stamp, seq, frame)
seq += 1
if buffer:
raise LinkError("strumień urwał się w połowie ramki")
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
link: Link | None) -> Iterator[str]:
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
dopiero na znakach nowej linii.
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
działał bez zmian.
"""
import json as _json
import httpx as _httpx
request_headers = dict(headers or {})
if link is None:
if encryption_required():
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
raise LinkError(
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
)
with client.stream("POST", url, json=payload, headers=request_headers) as response:
response.raise_for_status()
for text_line in response.iter_lines():
if text_line:
yield text_line
return
path = _httpx.URL(url).path
stamp = stamp_now()
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
with client.stream("POST", url, content=body, headers=request_headers) as response:
response.raise_for_status()
buffer = bytearray()
for plain in open_response_stream(response, link):
buffer += plain
while True:
nl = buffer.find(b"\n")
if nl < 0:
break
text_line = bytes(buffer[:nl])
del buffer[:nl + 1]
if text_line:
yield text_line.decode("utf-8")
if buffer:
yield bytes(buffer).decode("utf-8")
-1
View File
@@ -1 +0,0 @@
"""Warstwa dostawców modeli językowych (LOG-31)."""
-42
View File
@@ -1,42 +0,0 @@
"""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."""
-75
View File
@@ -1,75 +0,0 @@
"""Katalog modeli do wyboru w UI (LOG-31).
To **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")}
-121
View File
@@ -1,121 +0,0 @@
"""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)
-133
View File
@@ -1,133 +0,0 @@
"""Okna kontekstu modeli i planowanie budżetu tokenów (LOG-30/31).
Po co to istnieje: horoskop MA powstać niezależnie od objętości promptu. Żeby to
zagwarantować, trzeba wiedzieć dwie rzeczy o każdym modelu ile zmieści na
wejściu (okno kontekstu) i ile maksymalnie wypisze na wyjściu. Bez tego łatwo
wysłać prompt, który wypełnia całe okno i **nie zostawia miejsca na odpowiedź**
model kończy wtedy na `max_tokens` z pustą albo uciętą treścią.
Zasada naczelna: **zawsze rezerwuj miejsce na odpowiedź.** Budżet promptu liczy
się jako `okno_kontekstu zarezerwowane_wyjście margines`, nigdy odwrotnie.
Wartości 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)
-335
View File
@@ -1,335 +0,0 @@
"""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",
}
+5 -406
View File
@@ -4,7 +4,6 @@ W górę: udostępnia API dla warstwy prezentacji.
W dół: woła warstwę bazodanową (DataClient). W dół: woła warstwę bazodanową (DataClient).
Nie serwuje HTML, nie czyta plików/baz tylko reguły i pośrednictwo. 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 __future__ import annotations
from datetime import datetime from datetime import datetime
@@ -13,22 +12,12 @@ import httpx
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from app import link_crypto, security
from app.clients.data_client import DataClient from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse from app.models import QueryRequest, QueryResponse
from app.service import QueryService from app.service import QueryService
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji — app = FastAPI(title="astrololo · warstwa logiczna")
# 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() 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 --- # --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
_engine = None _engine = None
@@ -48,14 +37,8 @@ class PositionsRequest(BaseModel):
lat: float = 0.0 lat: float = 0.0
lon: float = 0.0 lon: float = 0.0
objects: list[str] | None = None objects: list[str] | None = None
house_system: str = "whole_sign" # whole_sign | equal | porphyry (PRYMARNY — pod kosmogram) house_system: str = "whole_sign" # whole_sign | equal | porphyry
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) stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
@app.post("/api/query", response_model=QueryResponse) @app.post("/api/query", response_model=QueryResponse)
@@ -75,14 +58,7 @@ def chart_positions(req: PositionsRequest) -> dict:
engine = get_engine() engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon) moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
try: chart = build_chart(engine, moment, req.house_system)
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: if req.stations:
from app.engine.stations import find_stations from app.engine.stations import find_stations
@@ -90,143 +66,9 @@ def chart_positions(req: PositionsRequest) -> dict:
st = find_stations(engine, moment, p["name"]) st = find_stations(engine, moment, p["name"])
if st: if st:
p["stations"] = st p["stations"] = st
if req.tables:
from app.engine.tables import build_tables
chart["tables"] = build_tables(engine, moment, chart)
return 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") @app.post("/chart/compare")
def chart_compare(req: PositionsRequest) -> dict: def chart_compare(req: PositionsRequest) -> dict:
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic. """Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
@@ -252,10 +94,6 @@ class ReportRequest(BaseModel):
lon: float = 0.0 lon: float = 0.0
limit: int = 5000 limit: int = 5000
group: bool = False # grupowanie identycznych opisów 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") @app.post("/chart/report")
@@ -271,249 +109,14 @@ def chart_report(req: ReportRequest) -> dict:
chart = build_chart(engine, moment) # pozycje z domami + aspekty chart = build_chart(engine, moment) # pozycje z domami + aspekty
try: try:
report = build_report( report = build_report(
chart["positions"], DataClient(tenant=req.tenant), chart["positions"], DataClient(),
aspects=chart.get("aspects"), parallels=chart.get("parallels"), aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
per_object_limit=req.limit, group=req.group,
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"} return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
return {"engine": engine.name, **report} 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): class ProfectionsRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC) when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0 lat: float = 0.0
@@ -599,10 +202,6 @@ class TimelineRequest(BaseModel):
to_date: str to_date: str
techniques: list[str] | None = None # profection | solar_return | solar_arc techniques: list[str] | None = None # profection | solar_return | solar_arc
interpret: bool = False # dopnij interpretacje z bazy (1B->2B) interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
# 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/timeline") @app.post("/chart/timeline")
-4
View File
@@ -8,10 +8,6 @@ from pydantic import BaseModel, Field
class QueryRequest(BaseModel): class QueryRequest(BaseModel):
"""To, co przychodzi z formularza (przez warstwę prezentacji).""" """To, co przychodzi z formularza (przez warstwę prezentacji)."""
# 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 = ""
query: str = Field(..., min_length=1, description="Szukana fraza.") query: str = Field(..., min_length=1, description="Szukana fraza.")
field: str = Field("name", description="Po którym polu szukać.") field: str = Field("name", description="Po którym polu szukać.")
-70
View File
@@ -1,70 +0,0 @@
"""Strumień postępu długiej operacji (NDJSON).
Po co: pisanie horoskopu trwa czasem minuty. Bez sygnału aplikacja wygląda na
zawieszoną. Zamiast udawanego paska postępu strumieniujemy **rzeczywiste**
zdarzenia z kolejnych etapów, żeby log pokazywał to, co faktycznie się dzieje.
Dlaczego NDJSON, a nie SSE: `EventSource` w przeglądarce obsługuje wyłącznie GET,
a to jest POST z ciałem. Strumień jedna linia = jeden obiekt JSON" czyta się
zwykłym `fetch()` i jest trywialny do sparsowania.
Dlaczego wątek: właściwa praca (silnik, baza, model) jest synchroniczna. Puszczamy
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"
-357
View File
@@ -1,357 +0,0 @@
"""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 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 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 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")
-50
View File
@@ -1,50 +0,0 @@
"""Uwierzytelnianie międzywarstwowe (LOG-32).
Warstwa logiczna oddaje treść baz interpretacyjnych, więc samo zalogowanie w
prezentacji nie wystarczy bez tego kontrolera wystarczyłoby uderzyć w logikę
z pominięciem UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
musi go przynieść w nagłówku X-Astrololo-Token.
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) wtedy przy
starcie leci ostrzeżenie.
"""
from __future__ import annotations
import logging
import os
import secrets
from fastapi import Request
from fastapi.responses import JSONResponse
log = logging.getLogger("astrololo.security")
HEADER = "X-Astrololo-Token"
PUBLIC_PATHS = frozenset({"/health"})
def token() -> str:
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
return os.getenv("INTERNAL_TOKEN", "")
def enabled() -> bool:
return bool(token())
def install(app, layer: str) -> None:
if not enabled():
log.warning(
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or not enabled():
return await call_next(request)
got = request.headers.get(HEADER, "")
if not secrets.compare_digest(got, token()):
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
status_code=401)
return await call_next(request)
+2 -6
View File
@@ -14,7 +14,6 @@ from app.models import QueryRequest, QueryResponse
class QueryService: class QueryService:
def __init__(self, data_client: DataClient | None = None) -> None: def __init__(self, data_client: DataClient | None = None) -> None:
self._wstrzykniety = data_client is not None
self.data = data_client or DataClient() self.data = data_client or DataClient()
def handle(self, req: QueryRequest) -> QueryResponse: def handle(self, req: QueryRequest) -> QueryResponse:
@@ -22,11 +21,8 @@ class QueryService:
value = req.query.strip() value = req.query.strip()
key = req.field.strip().lower() key = req.field.strip().lower()
# 2) zapytanie w dół do warstwy bazodanowej — do PULI KONTA, jeśli podano. # 2) zapytanie w dół do warstwy bazodanowej
# Klient budowany na żądanie, bo pula wynika z tego, kto pyta, a nie ze raw = self.data.search(key=key, value=value, exact=req.exact, limit=req.limit)
# stanu procesu. Wstrzyknięty klient (testy) ma pierwszeństwo.
dane = self.data if (self._wstrzykniety or not req.tenant) else DataClient(tenant=req.tenant)
raw = dane.search(key=key, value=value, exact=req.exact, limit=req.limit)
# 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować) # 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować)
results = raw.get("rows", []) results = raw.get("rows", [])
+3 -31
View File
@@ -22,8 +22,6 @@ PLANET_ABBR = {
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl", "Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith) # punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith)
"North Node": "NN", "South Node": "SN", "Lilith": "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)) SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
@@ -114,24 +112,17 @@ def build_report(
positions: list[dict], positions: list[dict],
data: DataSource, data: DataSource,
aspects: list[dict] | None = None, aspects: list[dict] | None = None,
parallels: list[dict] | None = None,
per_object_limit: int = 5000, per_object_limit: int = 5000,
group: bool = False, group: bool = False,
) -> dict: ) -> dict:
"""positions: pozycje z build_chart (name, sign, direction, house). """positions: pozycje z build_chart (name, sign, direction, house).
Dla każdego obiektu fasety: w znaku", „w domu", w aspekcie" (dla każdego Dla każdego obiektu fasety: w znaku", „w domu" oraz w aspekcie" (dla każdego
aspektu głównego z listy `aspects`) oraz paralela deklinacji" (dla paraleli aspektu głównego z listy `aspects`, jeśli w bazie dopasowania). Duplikaty
z listy `parallels` LOG-07). Duplikaty (ten sam sygnifikator i opis) (ten sam sygnifikator i opis) odsiewane wewnątrz każdej fasety.
odsiewane wewnątrz każdej fasety.
""" """
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME 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] = [] items: list[dict] = []
provider = None provider = None
for p in positions: for p in positions:
@@ -187,25 +178,6 @@ def build_report(
"count": len(asp_samples), "samples": asp_samples, "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 # punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
for f in facets: for f in facets:
f["score"] = score_facet(f) f["score"] = score_facet(f)
-2
View File
@@ -4,5 +4,3 @@ httpx>=0.28
pydantic>=2.10 pydantic>=2.10
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain) # Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
skyfield>=1.49 skyfield>=1.49
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
+1 -80
View File
@@ -1,62 +1,5 @@
"""Testy aspektów (LOG-06) — czysta matematyka.""" """Testy aspektów (LOG-06) — czysta matematyka."""
from app.engine.aspects import RIGID_PAIRS, find_aspects, find_cross_aspects, separation from app.engine.aspects import find_aspects, separation
# ── synastria: aspekty MIĘDZY dwoma horoskopami (PRE-04) ──
def test_cross_aspects_between_two_charts():
a = [{"name": "Sun", "decimal": 10.0}, {"name": "Moon", "decimal": 100.0}]
b = [{"name": "Sun", "decimal": 12.0}, {"name": "Mars", "decimal": 70.0}]
pairs = {(c["obj1"], c["obj2"], c["aspect"]) for c in find_cross_aspects(a, b)}
assert ("Sun", "Sun", "conjunction") in pairs # A.Słońce ↔ B.Słońce (2°)
assert ("Sun", "Mars", "sextile") in pairs # A.Słońce(10) ↔ B.Mars(70) = 60°
def test_cross_aspect_obj1_is_person_a_obj2_person_b():
a = [{"name": "Venus", "decimal": 10.0}]
b = [{"name": "Mars", "decimal": 12.0}]
c = find_cross_aspects(a, b)[0]
assert c["obj1"] == "Venus" and c["obj2"] == "Mars"
def test_cross_aspects_do_not_apply_rigid_pair_filter():
"""NN osoby A vs SN osoby B to REALNY aspekt między ludźmi — nie wycinamy go
jak pary sztywnej w jednym horoskopie."""
a = [{"name": "North Node", "decimal": 10.0}]
b = [{"name": "South Node", "decimal": 12.0}]
assert find_cross_aspects(a, b) # niepuste
def test_cross_aspects_no_applying_flag():
"""Dwa natale — brak wspólnego czasu, więc bez applying/separating."""
a = [{"name": "Sun", "decimal": 10.0, "speed": 1.0}]
b = [{"name": "Moon", "decimal": 12.0, "speed": 13.0}]
assert "as" not in find_cross_aspects(a, b)[0]
# ── konfigurowalne aspekty i orby (PRE-06) ──
def test_minor_aspects_only_with_flag():
"""Domyślnie tylko główne; `minor=True` dokłada poboczne (30/45/150°)."""
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 40.0}] # 30° = półsekstyl
assert find_aspects(pos) == [] # główne: brak
assert [a["aspect"] for a in find_aspects(pos, minor=True)] == ["semisextile"]
def test_orb_is_configurable():
"""Węższy orb odsiewa aspekt, który szerszy łapie."""
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 78.0}] # 68° = sextile dev 8°
assert find_aspects(pos, orb=5.0) == []
assert [a["aspect"] for a in find_aspects(pos, orb=10.0)] == ["sextile"]
def test_luminary_bonus_widens_orb_for_lights():
"""Bonus dolicza orb tylko gdy w parze jest Słońce/Księżyc."""
lights = [{"name": "Sun", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}] # dev 9° od sextile
assert find_aspects(lights, orb=8.0, luminary_bonus=0.0) == [] # 9 > 8
assert [a["aspect"] for a in find_aspects(lights, orb=8.0, luminary_bonus=2.0)] == ["sextile"] # 9 < 10
plain = [{"name": "Venus", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}]
assert find_aspects(plain, orb=8.0, luminary_bonus=2.0) == [] # brak światła → bez bonusu
def test_separation_wraparound(): def test_separation_wraparound():
@@ -112,28 +55,6 @@ def test_separating_when_moving_apart():
assert a["applying"] is False and a["as"] == "S" assert a["applying"] is False and a["as"] == "S"
def test_rigid_pair_nodes_not_reported():
# SN = NN + 180° z definicji — trywialna opozycja, nie aspekt
pos = [
{"name": "North Node", "decimal": 42.0, "speed": -0.053},
{"name": "South Node", "decimal": 222.0, "speed": -0.053},
{"name": "Sun", "decimal": 42.5, "speed": 0.96},
]
pairs = {frozenset((a["obj1"], a["obj2"])) for a in find_aspects(pos)}
assert frozenset({"North Node", "South Node"}) not in pairs
# aspekty węzłów do innych obiektów zostają nietknięte
assert frozenset({"Sun", "North Node"}) in pairs
assert frozenset({"Sun", "South Node"}) in pairs
def test_no_rigid_pairs_in_real_chart(own_engine, reference_moment):
from app.engine.chart import build_chart
chart = build_chart(own_engine, reference_moment)
found = [a for a in chart["aspects"] if frozenset((a["obj1"], a["obj2"])) in RIGID_PAIRS]
assert not found, f"trywialne aspekty par sztywnych w horoskopie: {found}"
def test_no_as_flag_without_speeds(): def test_no_as_flag_without_speeds():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}] pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert "as" not in find_aspects(pos)[0] assert "as" not in find_aspects(pos)[0]
-67
View File
@@ -2,9 +2,6 @@
Waliduje względem astro.com dla horoskopu referencyjnego. Waliduje względem astro.com dla horoskopu referencyjnego.
""" """
import pytest
from app.engine import houses as H
from app.engine.chart import build_chart from app.engine.chart import build_chart
@@ -28,67 +25,3 @@ def test_house_systems_available(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, system) chart = build_chart(own_engine, reference_moment, system)
assert chart["house_system"] == system assert chart["house_system"] == system
assert len(chart["cusps"]) == 12 assert len(chart["cusps"]) == 12
# ── wiele systemów domów naraz (PRE-05/LOG-05) ──
def test_multiple_house_systems_side_by_side(own_engine, reference_moment):
"""Prymarny zostaje w cusps/house_system; `house_systems` niesie pełen zestaw
do porównania, z prymarnym NA POCZĄTKU i bez duplikatów."""
chart = build_chart(own_engine, reference_moment, "whole_sign",
house_systems=["equal", "porphyry", "whole_sign"])
assert chart["house_system"] == "whole_sign" # prymarny bez zmian
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal", "porphyry"]
for h in chart["house_systems"]:
assert len(h["cusps"]) == 12
def test_single_system_has_no_comparison_block(own_engine, reference_moment):
"""Bez dodatkowych systemów nie zaśmiecamy wyniku."""
assert "house_systems" not in build_chart(own_engine, reference_moment, "whole_sign")
def test_object_gets_a_house_in_every_system(own_engine, reference_moment):
"""Sedno porównania: każdy obiekt ma dom w KAŻDYM systemie, a prymarny jest
spójny z polem `.house`."""
chart = build_chart(own_engine, reference_moment, "whole_sign", house_systems=["porphyry"])
sun = next(p for p in chart["positions"] if p["name"] == "Sun")
assert set(sun["houses"]) == {"whole_sign", "porphyry"}
assert sun["houses"]["whole_sign"] == sun["house"]
def test_primary_always_first_even_if_not_listed(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, "equal", house_systems=["porphyry"])
systems = [h["system"] for h in chart["house_systems"]]
assert systems[0] == "equal" and "porphyry" in systems
def test_unknown_extra_system_is_ignored(own_engine, reference_moment):
"""Nieznana nazwa systemu jest po prostu pomijana, nie wywala horoskopu.
(Placidus był tu kiedyś przykładem systemu nieobsługiwanego od Etapu 2
jest liczony, więc przykładem musi być nazwa, której naprawdę nie znamy.)"""
chart = build_chart(own_engine, reference_moment, "whole_sign",
house_systems=["nie-ma-takiego-systemu", "equal"])
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal"]
@pytest.mark.parametrize("system", H.SYSTEMS)
def test_every_advertised_system_builds_a_chart(own_engine, reference_moment, system):
"""Każdy system z houses.SYSTEMS musi przejść przez build_chart. Pilnuje,
żeby rozszerzenie SYSTEMS bez przepięcia chart.py nie dało 500 przy wyborze
systemu, który jest na liście, ale nie w użytej funkcji."""
chart = build_chart(own_engine, reference_moment, system)
assert chart["house_system"] == system
assert len(chart["cusps"]) == 12
assert all(1 <= p["house"] <= 12 for p in chart["positions"])
def test_multiple_systems_side_by_side_cover_all(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, H.WHOLE_SIGN,
house_systems=list(H.SYSTEMS))
blocks = chart["house_systems"]
assert blocks[0]["system"] == H.WHOLE_SIGN # prymarny zawsze pierwszy
assert {b["system"] for b in blocks} == set(H.SYSTEMS)
for p in chart["positions"]:
assert set(p["houses"]) == set(H.SYSTEMS)
-114
View File
@@ -1,114 +0,0 @@
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
Sedno: glify muszą być KOMPLETNE (każdy obiekt/znak/aspekt, który liczymy, ma
symbol), DWUKIERUNKOWE (symbol tekst z powrotem) i TEKSTOWE, nigdy emoji
(DAN-18) inaczej na kosmogramie wyjdą kolorowe kwadraty zamiast czarno-białych
symboli.
"""
import pytest
from app.engine import glyphs as G
from app.engine.aspects import MAJOR
from app.engine.formats import SIGNS
from app.engine.models import DEFAULT_OBJECTS
VS15, VS16 = "", ""
def _all_glyphs():
return (list(G.PLANET.values()) + list(G.POINT.values()) + list(G.LOT.values())
+ list(G.SIGN.values()) + list(G.ASPECT.values()) + [G.RETROGRADE])
# ------------------------------------------------------------- kompletność
def test_every_default_object_has_a_glyph():
"""Każdy obiekt z kanonicznego zestawu musi mieć symbol — inaczej na kole
zostanie dziura."""
missing = [name for name in DEFAULT_OBJECTS if G.glyph_for(name) is None]
assert not missing, f"obiekty bez glifu: {missing}"
def test_every_sign_has_a_glyph():
missing = [s for s in SIGNS if G.sign_glyph(s) is None]
assert not missing, f"znaki bez glifu: {missing}"
def test_every_major_aspect_has_a_glyph():
missing = [a for a in MAJOR if G.aspect_glyph(a) is None]
assert not missing, f"aspekty główne bez glifu: {missing}"
def test_fortune_and_retrograde_from_requirement():
"""Wymóg wprost wymienia ⊗ (Fortuna) i ℞ (retrogradacja)."""
assert G.glyph_for("Fortune") == ""
assert G.RETROGRADE == ""
# ------------------------------------------------------ DAN-18: tekst, nie emoji
def test_no_glyph_uses_emoji_variation_selector():
"""VS16 (U+FE0F) wymusiłby prezentację emoji — nie może go być NIGDZIE."""
offenders = [g for g in _all_glyphs() if VS16 in g]
assert not offenders, f"glify z wariantem emoji (FE0F): {offenders!r}"
def test_signs_force_text_presentation():
"""Znaki zodiaku mają domyślnie wariant emoji → MUSZĄ nieść VS15."""
for s in SIGNS:
assert VS15 in G.sign_glyph(s), f"{s} bez wymuszenia tekstu (VS15)"
def test_venus_mars_force_text_but_sun_does_not():
"""♀/♂ mają wariant emoji (VS15 konieczny); ☉ nie ma (VS15 zbędny)."""
assert VS15 in G.glyph_for("Venus")
assert VS15 in G.glyph_for("Mars")
assert VS15 not in G.glyph_for("Sun") # bez zaśmiecania niepotrzebnym selektorem
# ---------------------------------------------------------- dwukierunkowość
def test_object_round_trip():
for name in DEFAULT_OBJECTS:
assert G.name_for_glyph(G.glyph_for(name)) == name
def test_sign_round_trip():
for s in SIGNS:
assert G.sign_for_glyph(G.sign_glyph(s)) == s
def test_aspect_round_trip():
for a in MAJOR:
assert G.aspect_for_glyph(G.aspect_glyph(a)) == a
def test_reverse_tolerates_missing_variant_selector():
"""Symbol wklejony bez VS15 też musi się rozpoznać — bo z zewnątrz przyjdzie
dowolna forma (DAN-17: łańcuch znosi każdy Unicode)."""
assert G.sign_for_glyph("") == "Pisces" # ♓ bez VS15
assert G.sign_for_glyph("" + VS15) == "Pisces" # ♓ z VS15
assert G.name_for_glyph("") == "Venus" # ♀ bez VS15
# --------------------------------------------------------- glifikacja tekstu
def test_glyphify_matches_requirement_example():
"""Przykład z wymagań: [Sa [Pis 26°08' [conj [PF -> ♄ ♓ 26°08' ☌ ⊗."""
out = G.glyphify("[Sa [Pis 26°08' [conj [PF")
assert G.glyph_for("Saturn") in out
assert G.sign_glyph("Pisces") in out
assert G.aspect_glyph("conjunction") in out
assert G.glyph_for("Fortune") in out
assert "26°08'" in out # stopnie nietknięte
def test_glyphify_handles_retrograde_and_leaves_unknown():
out = G.glyphify("[Ma Rx w [Xyz")
assert G.glyph_for("Mars") in out
assert G.RETROGRADE in out
assert "[Xyz" in out # nieznany token bez zmian
def test_glyphify_empty_is_safe():
assert G.glyphify("") == ""

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